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)
|