Files
wecom_it_smart_desk/src/backend/app/api/dev_auth.py
T
Simon 0042519f66 fix(security): P0-NEW12 调试端点代码层根因(debug.py 集中 + app_env 优先)
【触发】
2026-08-12 09:00 早班巡检:jumpserver-V2 容器内 `python urllib` 绕开 nginx 直测
FastAPI(backend:8000):
  - /test-ping 200 pong
  - /test-error 200 "服务器内部错误"
  - /version 200 1.1.0(含 git hash)
  - /openapi.json 404(P0-NEW11 已闭环)
真实风险:公网 404 全部依赖 nginx 边缘层兜底;若 nginx 配置被误改 /
失效 / 容器间网络可达 backend:8000,攻击者可拿到 3 个调试端点。

【修复(v1.0 + v1.1 二次修复)】
1. 新建 src/backend/app/api/debug.py 集中 3 个调试端点(/api/debug/ping
   /error /version),加双重门控:
   - 模块级:main.py `if _is_dev_mode():` 块内 include_router(debug_router)
   - 端点级:每个端点内部 _is_dev_mode() 二次校验(防 fail-open)
2. 删除 main.py 散落的 /test-ping /test-error /version
3. debug.py 的 _is_dev_mode() 委托 main.py:48 单一真源(避免三处定义漂移)
4. 【v1.1 二次修复】_is_dev_mode() / _dev_mode_enabled() 改为 app_env 优先:
   - 优先级 1: app_env=="production" → 永远 False(即使 DEV_MODE=true)
   - 优先级 2: 非 production → 看 DEV_MODE / settings.dev_mode
   同步修复 main.py + dev_auth.py(两个独立实现不能漏一处)
5. tests/conftest.py enable_dev_mode fixture 同步设 APP_ENV=development
   (避免 dev 测试 case 因 app_env 默认 production 而 fail)

【验证(公网 + 容器内)】
- 公网 12 端点:3 旧端点 404 + 3 新端点 404 + 3 dev 403(nginx IP 白名单)+
  1 health 200 + 3 P0-NEW11 404 
- 容器内直连 backend:8000 6 端点:100% 4xx (用户原话真实风险场景)
- 回归测试 10 passed + 1 skipped(psutil 缺包)

【部署】
jumpserver-V2 PSFTP + sudo cp + chown admin:admin + 清理 __pycache__ +
docker restart wecom_it_backend
  v1 部署:10:48
  v2 部署:10:55(app_env 优先修复后)

【关联】
- 缺陷单:docs/03-测试文档/05-缺陷单/BUG-安全-005-调试端点无门控-001.md
- 看板:项目状态看板 v1.9.7-DRAFT P0-NEW12  已修复
- 工作日志:.workbuddy/memory/2026-08-12.md
- MEMORY 铁律:.workbuddy/memory/MEMORY.md「调试端点铁律」5 条
2026-08-12 11:01:14 +08:00

199 lines
7.8 KiB
Python

# =============================================================================
# 企微IT智能服务台 — 开发模式 Mock 登录
# =============================================================================
# ⚠️ 警告:此模块只在 DEV_MODE=true 时可用
# - 仅供本地开发 / 集成测试使用
# - 生产环境(DEV_MODE 未设置或 false)会直接 403
# - 部署前必须确认 .env / .env.production 没有 DEV_MODE=true
# 用法:
# GET /api/dev/login?userid=dev-user-001&name=测试&role=user
# GET /api/dev/users # 列出所有预设 dev 用户
# =============================================================================
import logging
import os
from datetime import datetime
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_redis
from app.models.employee import Employee
from app.services.token_service import TokenService
from app.utils.response import success_response
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dev", tags=["dev-mock"])
def _dev_mode_enabled() -> bool:
"""检查是否启用了开发模式。
优先级 (P0-NEW12 修复后,2026-08-12,与 main.py:_is_dev_mode 保持一致):
1. app_env 优先: production 永远 = 非 dev (即使 DEV_MODE=true 也无效)
背景: 预生产服历史配置 APP_ENV=production + DEV_MODE=true 不一致,
导致容器间网络可达 backend:8000 时仍能拿到 /api/dev/* 端点。
2. 非 production 环境(development / staging)下:
- DEV_MODE=true 环境变量 → 启用
- settings.dev_mode 字段 → 启用
3. 其余 → 非 dev
"""
# 优先级 1: app_env 决定总体框架(production 永远不是 dev)
app_env = getattr(settings, "app_env", "production").lower()
if app_env == "production":
return False
# 优先级 2: 非生产环境才看显式开关
env_val = os.getenv("DEV_MODE", "false").lower() == "true"
if env_val:
return True
# 兜底:从 settings 读
if hasattr(settings, "dev_mode") and getattr(settings, "dev_mode", False):
return True
return False
# -----------------------------------------------------------------------------
# 预设 dev 用户(便于测试不同角色)
# -----------------------------------------------------------------------------
PRESET_DEV_USERS = [
{"userid": "dev-user-001", "name": "张三(普通员工)", "role": "user", "department": "财务部"},
{"userid": "dev-agent-001", "name": "李四(IT 坐席)", "role": "agent", "department": "信息技术部"},
{"userid": "dev-supervisor-001", "name": "王五(部门主管)", "role": "supervisor", "department": "信息技术部"},
{"userid": "dev-security-001", "name": "赵六(安全团队)", "role": "security", "department": "信息安全部"},
{"userid": "dev-admin-001", "name": "钱七(系统管理员)", "role": "admin", "department": "信息技术部"},
{"userid": "dev-multi-001", "name": "周八(多角色测试)", "role": "user,agent,supervisor", "department": "测试部"},
]
# -----------------------------------------------------------------------------
# GET /api/dev/login — Mock 登录(返回 token)
# -----------------------------------------------------------------------------
@router.get("/login")
async def dev_login(
userid: str = Query("dev-user-001", description="用户 ID(模拟企微 userid)"),
name: str = Query("开发测试用户", description="用户姓名"),
role: str = Query("user", description="角色:user/agent/admin/supervisor/security,多个用逗号分隔"),
department: str = Query("信息技术部", description="部门"),
avatar: Optional[str] = Query(None, description="头像 URL(可选)"),
redis: aioredis.Redis = Depends(get_redis),
db: AsyncSession = Depends(get_db),
):
"""开发模式 Mock 登录。
用法:
GET /api/dev/login?userid=dev-agent-001&name=李四&role=agent
返回:
{
"code": 0,
"data": {
"token": "abc123...",
"user": { "userid": "...", "name": "...", "roles": [...] }
}
}
"""
if not _dev_mode_enabled():
logger.warning("🚨 /api/dev/login 被调用但 DEV_MODE 未启用,返回 403")
raise HTTPException(
status_code=403,
detail="DEV_MODE not enabled. Set DEV_MODE=true in .env.dev to use this endpoint."
)
# 解析多角色
roles = [r.strip() for r in role.split(",") if r.strip()]
if not roles:
roles = ["user"]
# 调 TokenService 创建 token(走完全真实的 token 流程)
token_service = TokenService(redis)
token = await token_service.create_token(
employee_id=userid,
name=name,
roles=roles,
department=department,
avatar=avatar or "",
login_source="dev",
)
# Mock 登录时同步写入 employees 表(便于坐席端显示员工姓名)
from sqlalchemy import select
stmt = select(Employee).where(Employee.employee_id == userid)
result = await db.execute(stmt)
employee = result.scalars().first()
if employee:
# 更新已有记录
employee.name = name
employee.department = department
employee.avatar = avatar or ""
employee.avatar_updated_at = datetime.utcnow()
else:
# 创建新记录
employee = Employee(
corp_id=settings.wecom_corp_id,
employee_id=userid,
name=name,
department=department,
position="",
avatar=avatar or "",
avatar_updated_at=datetime.utcnow(),
)
db.add(employee)
# 清 Redis 头像缓存,确保下次读取数据库最新头像(要求 C:清缓存 + 更新时间一致)
if avatar:
try:
await redis.delete(f"employee:avatar:{userid}")
except Exception as e:
logger.warning(f"删除头像Redis缓存失败: userid={userid}, error={e}")
await db.commit()
logger.info(f"🧪 [DEV] 同步员工信息到 employees 表: userid={userid}, name={name}")
logger.info(f"🧪 [DEV] Mock 登录成功: userid={userid}, roles={roles}")
return success_response(data={
"token": token,
"user": {
"userid": userid,
"name": name,
"department": department,
"avatar": avatar or "",
"roles": roles,
"login_source": "dev",
},
})
# -----------------------------------------------------------------------------
# GET /api/dev/users — 列出所有预设 dev 用户
# -----------------------------------------------------------------------------
@router.get("/users")
async def dev_list_users():
"""列出所有预设 dev 用户(便于前端测试用)。"""
if not _dev_mode_enabled():
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
return success_response(data=PRESET_DEV_USERS)
# -----------------------------------------------------------------------------
# GET /api/dev/health — 检查 dev 模式状态
# -----------------------------------------------------------------------------
@router.get("/health")
async def dev_health():
"""检查 dev 模式是否启用 + 关键依赖。"""
if not _dev_mode_enabled():
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
return success_response(data={
"dev_mode": True,
"env": os.getenv("APP_ENV", "unknown"),
"database_url": os.getenv("DATABASE_URL", "not set")[:50] + "...",
"redis_url": os.getenv("REDIS_URL", "not set"),
"preset_users": len(PRESET_DEV_USERS),
})