# ============================================================================= # 企微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)