feat(merge): 4 个 worktree 合入 main(扫码+MFA+高危+P0)

合入内容:
- worktree-A (auth_qrcode): 13 测试  — Phase 1.1 后端扫码登录
- worktree-B (mfa): 21 测试  — Phase 2.1 MFA TOTP + User 字段
- worktree-C (high_risk_guard): 28 测试  — Phase 1.3 高危守卫
- worktree-D (p0-fixes): 16 测试  — P0/P1 合规(WS 签名+UUID+access_log)

合并方式: 各 worktree 提取 format-patch → 只 apply 新增文件 → 手动合并 router.py/dependencies.py 冲突

新文件 (16):
  backend/alembic/versions/022_qrcode_login.py
  backend/alembic/versions/023_mfa_fields.py
  backend/alembic/versions/025_messages_id_uuid.py
  backend/app/api/auth_qrcode.py
  backend/app/api/high_risk_routes.py
  backend/app/api/mfa.py
  backend/app/schemas/mfa.py
  backend/app/schemas/qrcode.py
  backend/app/services/high_risk_guard.py
  backend/app/services/mfa_service.py
  backend/app/services/qrcode_service.py
  backend/scripts/nginx-access-log-sanitize.sh
  backend/tests/test_auth_qrcode.py (13)
  backend/tests/test_high_risk_guard.py (28)
  backend/tests/test_mfa.py (21)
  backend/tests/test_messages_uuid.py
  backend/tests/test_ws_endpoints.py
  backend/tests/test_ws_push_to_employee.py (xfail 4)

修改 (4):
  backend/app/api/router.py — 注册 auth_qrcode/high_risk_routes/mfa 3 个 router
  backend/app/dependencies.py — 加 HIGH_RISK_OPERATIONS + require_high_risk_otp
  backend/app/models/agent.py — mfa_secret/mfa_enabled/mfa_bound_at/mfa_last_verified_at
  backend/tests/conftest.py — create_test_conversation 接 db_session

测试结果(新增 78 + xfail 4):
  tests/test_auth_qrcode.py      13 passed
  tests/test_high_risk_guard.py  28 passed
  tests/test_mfa.py              21 passed
  tests/test_messages_uuid.py     8 passed
  tests/test_ws_endpoints.py      8 passed
  tests/test_ws_push_to_employee.py 4 xfailed (端点路径不一致,pre-existing)

4 端 frontend build 全部通过(agent/portal/admin/h5)

后续 TODO (用户操作):
1. 撤销 Gitea token 5ad83d... via Web UI
2. 跑 alembic upgrade head(生产 PG,025 messages UUID)
3. 应用 nginx access_log 脱敏(进容器改 conf)
4. 部署 backend + 4 端 dist + nginx reload

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Simon
2026-06-21 03:08:54 +08:00
parent f564d0e42a
commit bf872da8bb
22 changed files with 4704 additions and 27 deletions
+139
View File
@@ -20,6 +20,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import settings
from app.services.token_service import TokenService
from app.utils.response import AppException
logger = logging.getLogger(__name__)
@@ -281,3 +282,141 @@ def require_admin(func):
pass
"""
return require_role("admin")(func)
# =============================================================================
# 高危操作 OTP 守卫依赖(Phase 1.3 task #19
# =============================================================================
# 决策来源:otm-secondary-auth.md
# 触发场景:管理员执行 5 类高危操作前,必须在 30 分钟内通过 OTP 二次验证
# 验证流程:
# 1. 管理员先调 /api/mfa/verify 校验 TOTP 验证码(蜂鸟 SMS 备用)
# 2. 验证通过后 mfa.py 在 Redis 写 mfa:verified:{employee_id}TTL=1800 秒
# 3. 高危操作端点 Depends(require_high_risk_otp) 时:
# - 检查角色:admin403 否则)
# - 检查 Redis keymfa:verified:{employee_id}(不存在则 raise 2001
# 4. 前端收到 2001 → 弹 OTP 输入框 → 重试
#
# 5 类高危操作清单(与 otm-secondary-auth.md 对齐):
# 1. role_change 改权限 POST /api/admin/roles/assign
# 2. config_change 改配置 PUT /api/admin/configs/{key}
# 3. data_export 导出数据 GET /api/admin/export/*
# 4. account_disable 封号 DELETE /api/admin/agents/{id}
# 5. account_create_reset 新增账号/重置 POST /api/admin/agents, /api/admin/mfa/reset/{id}
# =============================================================================
# 高危操作白名单(category → 元数据)
# 用于演示路由 + 文档化,前端可读此表知道哪些操作需要 OTP
HIGH_RISK_OPERATIONS = {
"role_change": {
"category": "改权限",
"require_otp": True,
"examples": ["POST /api/admin/roles/assign", "POST /api/admin/roles/revoke"],
"description": "分配或撤销用户角色",
},
"config_change": {
"category": "改配置",
"require_otp": True,
"examples": ["PUT /api/admin/configs/{key}"],
"description": "修改系统配置项",
},
"data_export": {
"category": "导出数据",
"require_otp": True,
"examples": ["GET /api/admin/export/*"],
"description": "导出敏感数据(会话、坐席统计等)",
},
"account_disable": {
"category": "封号",
"require_otp": True,
"examples": ["DELETE /api/admin/agents/{id}"],
"description": "禁用/删除坐席账号",
},
"account_create_reset": {
"category": "新增账号/重置",
"require_otp": True,
"examples": ["POST /api/admin/agents", "POST /api/admin/mfa/reset/{id}"],
"description": "新增坐席或重置 MFA",
},
}
# MFA 验证通过的 Redis key 前缀
# 由 mfa.py 在 /api/mfa/verify 成功后写入,TTL=1800 秒
MFA_VERIFIED_KEY_PREFIX = "mfa:verified:"
# MFA 验证有效期(30 分钟,与 otm-secondary-auth.md 决策一致)
MFA_VERIFIED_TTL_SECONDS = 30 * 60
async def require_high_risk_otp(
current_user: UserInfo = Depends(get_current_user),
) -> UserInfo:
"""高危操作 OTP 守卫(管理员触发高危操作前必过)。
业务规则(来自 otm-secondary-auth.md 2026-06-21 决策):
1. 仅 admin 角色需要过 OTPagent/user 直接 403
2. 必须在 30 分钟内通过 /api/mfa/verify 校验过 OTP
3. 验证失败的 key 不算(空字符串/已过期)
鉴权流程:
- 请求携带 Bearer Token → get_current_user 解析 UserInfo
- 检查 UserInfo.roles 是否含 "admin"(否则 4003 仅管理员)
- 检查 Redis mfa:verified:{employee_id} 是否存在(否则 2001 需 OTP)
Args:
current_user: 当前用户(FastAPI 自动注入)
Returns:
UserInfo: 当前用户(已通过 OTP 守卫)
Raises:
AppException(4003, "仅管理员可执行此操作"): 非管理员角色
AppException(2001, "高危操作需要 OTP 二次验证"): admin 但未在 30 分钟内过 OTP
"""
# 第 1 关:角色检查 - 只有 admin 才需要 OTP 验证
# 注: current_role 是当前激活角色,roles 是全部角色,两者都查(双保险)
user_roles = current_user.roles or []
is_admin = (
current_user.current_role == "admin"
or "admin" in user_roles
)
if not is_admin:
logger.warning(
f"用户 {current_user.employee_id} 尝试高危操作但不是 admin: "
f"current_role={current_user.current_role}, roles={user_roles}"
)
raise AppException(
code=4003,
message="仅管理员可执行此高危操作",
)
# 第 2 关:OTP 验证标记检查 - Redis mfa:verified:{employee_id}
redis_client = await get_redis()
verified_key = f"{MFA_VERIFIED_KEY_PREFIX}{current_user.employee_id}"
verified = await redis_client.get(verified_key)
# 注:空字符串/null/bytes 都算"未通过"
if not verified:
logger.warning(
f"管理员 {current_user.employee_id} 未通过 OTP 守卫: "
f"Redis key '{verified_key}' 不存在或已过期"
)
raise AppException(
code=2001,
message="高危操作需要 OTP 二次验证,请先完成 OTP 验证",
)
# 防御性:刷新 TTL(滑动窗口)—— 如果管理员持续在做高危操作,
# 不用反复输 OTP。但要求单次操作 < 30 分钟间隔。
# 注: mfa.py 写入时已设 1800 秒 TTL,这里只在存在时刷新
if hasattr(redis_client, "expire"):
try:
await redis_client.expire(verified_key, MFA_VERIFIED_TTL_SECONDS)
except Exception as e:
# 刷新失败不影响主流程,仅记录
logger.debug(f"刷新 OTP verified TTL 失败: {e}")
logger.info(
f"管理员 {current_user.employee_id} 通过 OTP 守卫,执行高危操作"
)
return current_user