bf872da8bb
合入内容: - 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>
291 lines
9.3 KiB
Python
291 lines
9.3 KiB
Python
# =============================================================================
|
||
# 企微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) |