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
+291
View File
@@ -0,0 +1,291 @@
# =============================================================================
# 企微IT智能服务台 — 高危操作守卫服务
# =============================================================================
# 说明:集中处理高危操作(Phase 1.3 task #19)的 OTP 验证状态管理
# 决策来源:otm-secondary-auth.md2026-06-21 决策)
#
# 核心职责:
# 1. 标记管理员 OTP 验证通过(write)
# 2. 查询管理员 OTP 验证状态(read)
# 3. 撤销管理员 OTP 验证(revoke)
# 4. 列出全部 5 类高危操作白名单(白名单查询)
#
# Redis key 设计:
# key: mfa:verified:{employee_id}
# value: 验证方式("totp" / "sms_backup"+ 时间戳
# TTL: 1800 秒(30 分钟)
#
# 与 dependencies.py 中 require_high_risk_otp 配套使用:
# - mfa.py 在 /api/mfa/verify 成功后调 mark_verified(...)
# - require_high_risk_otp 在每个高危端点 Depends 时调 is_verified(...)
# =============================================================================
import json
import logging
from datetime import datetime
from typing import Dict, List, Optional
import redis.asyncio as aioredis
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# 5 类高危操作白名单(与 dependencies.HIGH_RISK_OPERATIONS 保持一致)
# -----------------------------------------------------------------------------
# 注意:这里再做一次定义是为了让 service 层独立可测,不依赖 dependencies 模块
# (避免循环引用 + 方便单测)
# -----------------------------------------------------------------------------
HIGH_RISK_OPERATIONS_WHITELIST: Dict[str, Dict] = {
"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",
},
}
class HighRiskGuard:
"""高危操作守卫服务。
负责 OTP 验证状态的读写,配套 require_high_risk_otp 依赖使用。
Attributes:
redis_client: Redis 异步客户端
ttl_seconds: OTP 验证有效期(默认 1800 秒 = 30 分钟)
"""
# Redis key 前缀 — 必须与 dependencies.MFA_VERIFIED_KEY_PREFIX 一致
KEY_PREFIX = "mfa:verified:"
# 默认 30 分钟 TTL — 必须与 dependencies.MFA_VERIFIED_TTL_SECONDS 一致
DEFAULT_TTL_SECONDS = 30 * 60
def __init__(
self,
redis_client: aioredis.Redis,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
):
"""初始化高危操作守卫。
Args:
redis_client: Redis 异步客户端
ttl_seconds: OTP 验证有效期(秒),默认 30 分钟
"""
self.redis = redis_client
self.ttl_seconds = ttl_seconds
def _key(self, employee_id: str) -> str:
"""构造 Redis key。
Args:
employee_id: 企微 UserID
Returns:
str: Redis key,如 mfa:verified:admin001
"""
return f"{self.KEY_PREFIX}{employee_id}"
async def mark_verified(
self,
employee_id: str,
method: str = "totp",
) -> bool:
"""标记管理员已通过 OTP 验证。
由 mfa.py 在 /api/mfa/verify 成功后调用。
Args:
employee_id: 企微 UserID
method: 验证方式,"totp""sms_backup"
Returns:
bool: 是否成功写入
"""
# value 用 JSON 存验证方式和时间,审计用
value = json.dumps(
{
"method": method,
"verified_at": datetime.now().isoformat(),
},
ensure_ascii=False,
)
try:
await self.redis.setex(
self._key(employee_id),
self.ttl_seconds,
value,
)
logger.info(
f"管理员 {employee_id} OTP 验证通过: method={method}, "
f"ttl={self.ttl_seconds}s"
)
return True
except Exception as e:
logger.error(f"写入 OTP verified key 失败: {e}")
return False
async def is_verified(self, employee_id: str) -> bool:
"""检查管理员是否在有效期内通过过 OTP。
由 require_high_risk_otp 依赖调用。
Args:
employee_id: 企微 UserID
Returns:
bool: 是否已通过 OTP 验证
"""
try:
value = await self.redis.get(self._key(employee_id))
# 空字符串 / None / 空 bytes 全部算"未通过"
if not value:
return False
return True
except Exception as e:
logger.error(f"读取 OTP verified key 失败: {e}")
# Redis 故障时保守放行?不,安全优先,默认不通过
return False
async def get_verification_info(
self,
employee_id: str,
) -> Optional[Dict]:
"""获取管理员 OTP 验证详情(含方式和时间)。
用于审计/前端展示"上次验证时间"
Args:
employee_id: 企微 UserID
Returns:
Optional[Dict]: 验证信息 dict,未验证返回 None
示例: {"method": "totp", "verified_at": "2026-06-21T15:30:00"}
"""
try:
value = await self.redis.get(self._key(employee_id))
if not value:
return None
if isinstance(value, bytes):
value = value.decode("utf-8")
return json.loads(value)
except Exception as e:
logger.error(f"解析 OTP verified info 失败: {e}")
return None
async def revoke(self, employee_id: str) -> bool:
"""撤销管理员 OTP 验证(强制重新验证)。
场景:安全事件触发 / 管理员主动撤销 / 登出时清理。
Args:
employee_id: 企微 UserID
Returns:
bool: 是否成功撤销(key 不存在也算成功)
"""
try:
deleted = await self.redis.delete(self._key(employee_id))
logger.info(
f"管理员 {employee_id} OTP 验证已撤销: deleted={deleted}"
)
return True
except Exception as e:
logger.error(f"撤销 OTP verified key 失败: {e}")
return False
async def refresh_ttl(self, employee_id: str) -> bool:
"""刷新 OTP 验证的 TTL(滑动窗口)。
每次高危操作通过守卫后调用,延长 30 分钟有效期。
已在 dependencies.require_high_risk_otp 内联调用,这里冗余暴露给 service 层。
Args:
employee_id: 企微 UserID
Returns:
bool: 是否刷新成功
"""
try:
# 只有 key 存在时才刷新 TTL,防止误创建空 key
value = await self.redis.get(self._key(employee_id))
if not value:
return False
await self.redis.expire(self._key(employee_id), self.ttl_seconds)
return True
except Exception as e:
logger.error(f"刷新 OTP verified TTL 失败: {e}")
return False
@staticmethod
def get_whitelist() -> Dict[str, Dict]:
"""获取 5 类高危操作白名单。
静态方法,供前端文档化展示"哪些操作需要 OTP"
Returns:
Dict[str, Dict]: 白名单字典
"""
return HIGH_RISK_OPERATIONS_WHITELIST.copy()
@staticmethod
def is_valid_category(category: str) -> bool:
"""检查 category 是否在 5 类白名单内。
Args:
category: 类别标识
Returns:
bool: 是否合法
"""
return category in HIGH_RISK_OPERATIONS_WHITELIST
@staticmethod
def list_categories() -> List[str]:
"""列出全部 5 类高危操作标识。
Returns:
List[str]: category 列表
"""
return list(HIGH_RISK_OPERATIONS_WHITELIST.keys())
# -----------------------------------------------------------------------------
# 工厂函数:方便在非 FastAPI DI 场景使用
# -----------------------------------------------------------------------------
def create_high_risk_guard(redis_client: aioredis.Redis) -> HighRiskGuard:
"""创建 HighRiskGuard 实例。
Args:
redis_client: Redis 异步客户端
Returns:
HighRiskGuard: 守卫服务实例
"""
return HighRiskGuard(redis_client)