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:
@@ -0,0 +1,291 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 高危操作守卫服务
|
||||
# =============================================================================
|
||||
# 说明:集中处理高危操作(Phase 1.3 task #19)的 OTP 验证状态管理
|
||||
# 决策来源:otm-secondary-auth.md(2026-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)
|
||||
Reference in New Issue
Block a user