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:
@@ -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) 时:
|
||||
# - 检查角色:admin(403 否则)
|
||||
# - 检查 Redis key:mfa: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 角色需要过 OTP(agent/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
|
||||
|
||||
Reference in New Issue
Block a user