704 lines
24 KiB
Python
704 lines
24 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 统一认证依赖
|
||
# =============================================================================
|
||
# 说明:提供统一的认证依赖函数,支持:
|
||
# 1. get_current_user: 获取当前用户信息(包含角色)
|
||
# 2. require_role: 角色验证装饰器
|
||
# 3. require_admin: 管理员权限验证
|
||
# =============================================================================
|
||
|
||
import hashlib
|
||
import inspect
|
||
import json
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from functools import wraps
|
||
from typing import List, Optional, Union
|
||
|
||
import redis.asyncio as aioredis
|
||
from fastapi import Depends, HTTPException, Request, status
|
||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||
|
||
from app.config import settings
|
||
from app.models.agent import Agent
|
||
from app.services.token_service import TokenService
|
||
from app.utils.response import AppException
|
||
|
||
# 延迟导入 get_current_agent 以避免循环依赖
|
||
def _get_current_agent():
|
||
from app.api.agents import get_current_agent
|
||
return get_current_agent
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# HTTP Bearer 认证方案
|
||
security = HTTPBearer()
|
||
|
||
|
||
@dataclass
|
||
class UserInfo:
|
||
"""用户信息数据类。
|
||
|
||
Attributes:
|
||
employee_id: 企微 UserID
|
||
name: 用户姓名
|
||
department: 部门
|
||
avatar: 头像URL
|
||
roles: 角色列表
|
||
current_role: 当前选择的角色
|
||
login_source: 登录来源
|
||
"""
|
||
|
||
employee_id: str
|
||
name: str
|
||
department: str
|
||
avatar: str
|
||
roles: List[str]
|
||
current_role: str
|
||
login_source: str
|
||
|
||
|
||
# Redis 连接池(单例)
|
||
_redis_pool: Optional[aioredis.Redis] = None
|
||
|
||
|
||
async def get_redis() -> aioredis.Redis:
|
||
"""获取 Redis 连接。
|
||
|
||
Returns:
|
||
aioredis.Redis: Redis 异步客户端
|
||
"""
|
||
global _redis_pool
|
||
if _redis_pool is None:
|
||
_redis_pool = settings.create_redis_client()
|
||
return _redis_pool
|
||
|
||
|
||
# 共享服务实例(用于 wecom_callback.py 等模块)
|
||
# 这些函数提供同步获取服务实例的方式,用于非 FastAPI DI 的场景
|
||
def get_shared_redis() -> aioredis.Redis:
|
||
"""获取 Redis 客户端(同步版本,用于非 async 场景)。
|
||
|
||
Returns:
|
||
aioredis.Redis: Redis 客户端实例
|
||
"""
|
||
return settings.create_redis_client()
|
||
|
||
|
||
def get_shared_wecom_service():
|
||
"""获取 WecomService 共享实例。
|
||
|
||
Returns:
|
||
WecomService: 企微服务实例
|
||
"""
|
||
from app.services.wecom_service import WecomService
|
||
return WecomService(settings.create_redis_client())
|
||
|
||
|
||
def get_shared_ai_handler():
|
||
"""获取 AIHandler 共享实例(v4.0 P1-1:真单例)。
|
||
|
||
Returns:
|
||
AIHandler: AI 处理器单例
|
||
"""
|
||
global _shared_ai_handler
|
||
if _shared_ai_handler is None:
|
||
from app.services.ai_handler import AIHandler
|
||
from app.services.ai_service import AIService
|
||
_shared_ai_handler = AIHandler(ai_service=AIService())
|
||
return _shared_ai_handler
|
||
|
||
|
||
# v4.0 P1-1: AIHandler 模块级单例(修复每次新建导致的 httpx 连接池泄漏)
|
||
_shared_ai_handler = None
|
||
|
||
|
||
# FastAPI Depends 函数(用于路由依赖注入)
|
||
async def dep_redis() -> Optional[aioredis.Redis]:
|
||
"""Redis 客户端依赖注入。
|
||
|
||
Redis 连接创建失败时返回 None(不抛异常),
|
||
由上层调用方(如 _get_current_employee)根据 None 值做降级处理。
|
||
|
||
Returns:
|
||
Optional[aioredis.Redis]: Redis 异步客户端,连接失败时返回 None
|
||
"""
|
||
try:
|
||
return await get_redis()
|
||
except Exception as e:
|
||
logger.error(f"Redis 连接创建失败(认证等依赖 Redis 的功能将不可用): {e}")
|
||
return None
|
||
|
||
|
||
def dep_wecom_service():
|
||
"""WecomService 依赖注入。
|
||
|
||
Returns:
|
||
WecomService: 企微服务实例
|
||
"""
|
||
from app.services.wecom_service import WecomService
|
||
return WecomService(settings.create_redis_client())
|
||
|
||
|
||
def dep_ai_handler():
|
||
"""AIHandler 依赖注入(v4.0 P1-1:复用同一单例)。
|
||
|
||
Returns:
|
||
AIHandler: AI 处理器单例
|
||
"""
|
||
return get_shared_ai_handler()
|
||
|
||
|
||
def dep_wingman_service():
|
||
"""WingmanService 依赖注入(含 Redis 支持)。
|
||
|
||
Returns:
|
||
WingmanService: AI Wingman 服务实例(含 Redis 缓存支持)
|
||
"""
|
||
from app.services.wingman_service import WingmanService
|
||
return WingmanService(redis_client=settings.create_redis_client())
|
||
|
||
|
||
def dep_neo4j_client():
|
||
"""Neo4jClient 依赖注入(FastAPI Depends 兼容 — 同步工厂)。
|
||
|
||
由于 Neo4jClient.initialize() 是异步的,此函数返回一个懒加载的包装器,
|
||
实际初始化在首次 await 时完成。
|
||
|
||
用法:
|
||
neo4j = await dep_neo4j_client()
|
||
|
||
Returns:
|
||
Neo4jClient: Neo4j 图数据库客户端(已初始化)
|
||
"""
|
||
from app.services.neo4j_client import dep_neo4j_client as _async_dep
|
||
return _async_dep()
|
||
|
||
|
||
# 应用生命周期管理函数
|
||
async def init_shared_services():
|
||
"""初始化共享服务(应用启动时调用)。
|
||
|
||
创建 Redis 连接池,初始化共享服务实例。
|
||
"""
|
||
global _redis_pool
|
||
_redis_pool = settings.create_redis_client()
|
||
|
||
# 注入 Redis 客户端到 cache_service(解决 WebSocket 认证时 redis=None 的问题)
|
||
from app.services.cache_service import cache_service
|
||
cache_service.redis = _redis_pool
|
||
logger.info("共享服务初始化完成")
|
||
|
||
|
||
async def cleanup_shared_services():
|
||
"""清理共享服务(应用关闭时调用)。
|
||
|
||
关闭 Redis 连接池 + AIService httpx 连接池(v4.0 P1-1)。
|
||
"""
|
||
global _redis_pool, _shared_ai_handler
|
||
if _redis_pool:
|
||
await _redis_pool.close()
|
||
_redis_pool = None
|
||
logger.info("共享服务清理完成")
|
||
|
||
# v4.0 P1-1: 关闭 AIService 的 2 个 httpx 连接池(修复 Unclosed client session)
|
||
if _shared_ai_handler is not None:
|
||
try:
|
||
ai_service = getattr(_shared_ai_handler, 'ai_service', None)
|
||
if ai_service:
|
||
for client_attr in ('_client', '_native_client'):
|
||
client = getattr(ai_service, client_attr, None)
|
||
if client and not client.is_closed:
|
||
await client.aclose()
|
||
logger.info("AIService httpx 连接池已关闭")
|
||
except Exception as e:
|
||
logger.warning(f"AIService 连接池关闭异常: {e}")
|
||
_shared_ai_handler = None
|
||
|
||
# v4.0 P1-2: 关闭 routing_service 的共享 httpx 连接池
|
||
try:
|
||
from app.services import routing_service
|
||
if routing_service._routing_client and not routing_service._routing_client.is_closed:
|
||
await routing_service._routing_client.aclose()
|
||
routing_service._routing_client = None
|
||
logger.info("routing_service httpx 连接池已关闭")
|
||
except Exception as e:
|
||
logger.warning(f"routing_service 连接池关闭异常: {e}")
|
||
|
||
|
||
# Token 黑名单 Key 前缀(与 auth.py 中保持一致)
|
||
TOKEN_BLACKLIST_PREFIX = "token:blacklist:"
|
||
|
||
|
||
async def get_current_user(
|
||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||
request: Request = None,
|
||
) -> UserInfo:
|
||
"""统一认证依赖:从 Token 获取用户信息。
|
||
|
||
支持新旧两种 Token 格式。
|
||
|
||
Args:
|
||
credentials: HTTP Bearer Token
|
||
request: 请求对象(可选,用于获取客户端IP记录)
|
||
|
||
Returns:
|
||
UserInfo: 用户信息
|
||
|
||
Raises:
|
||
HTTPException: Token 无效或已过期
|
||
"""
|
||
token = credentials.credentials
|
||
|
||
# 获取 Redis 连接
|
||
redis_client = await get_redis()
|
||
|
||
# 检查 Token 黑名单(用户已主动登出)
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
blacklist_key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}"
|
||
if await redis_client.get(blacklist_key):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Token 已失效,请重新登录",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
|
||
# 创建 Token 服务
|
||
token_service = TokenService(redis_client)
|
||
|
||
# 获取用户信息
|
||
user_info = await token_service.get_user_info(token)
|
||
|
||
if not user_info:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Token 无效或已过期",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
|
||
# 记录Token使用的IP(用于异常检测)
|
||
# 只有在能获取到IP时才记录
|
||
if request:
|
||
try:
|
||
# 优先从 X-Forwarded-For 获取真实IP
|
||
client_ip = request.headers.get("X-Forwarded-For", "")
|
||
if client_ip:
|
||
client_ip = client_ip.split(",")[0].strip()
|
||
else:
|
||
client_ip = request.client.host if request.client else None
|
||
|
||
if client_ip:
|
||
await token_service.record_token_ip(token, client_ip)
|
||
except Exception as e:
|
||
# IP记录失败不影响主流程
|
||
logger.debug(f"记录Token IP失败(不影响认证): {e}")
|
||
|
||
return UserInfo(
|
||
employee_id=user_info["employee_id"],
|
||
name=user_info.get("name", ""),
|
||
department=user_info.get("department", ""),
|
||
avatar=user_info.get("avatar", ""),
|
||
roles=user_info.get("roles", ["user"]),
|
||
current_role=user_info.get("current_role", "user"),
|
||
login_source=user_info.get("login_source", "portal"),
|
||
)
|
||
|
||
|
||
def require_role(*required_roles: str):
|
||
"""角色验证装饰器。
|
||
|
||
检查用户是否拥有指定角色之一。
|
||
|
||
Args:
|
||
*required_roles: 允许的角色列表
|
||
|
||
Returns:
|
||
装饰器函数
|
||
|
||
Example:
|
||
@router.get("/api/admin/dashboard")
|
||
@require_role("admin")
|
||
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
|
||
pass
|
||
"""
|
||
|
||
def decorator(func):
|
||
# 合并 func 签名 + current_user 参数,让 FastAPI 能正确解析 Depends
|
||
# (v0.5.6 修复:之前用 @wraps,FastAPI 看到的是 __wrapped__ 的签名,
|
||
# 没有 current_user,导致 Depends 默认值未被解析,current_user 实际是 Depends 对象)
|
||
sig = inspect.signature(func)
|
||
params = list(sig.parameters.values())
|
||
param_names = {p.name for p in params}
|
||
|
||
# 智能检测:若被装饰函数已声明 current_user(或 current_agent),则不再追加,
|
||
# 避免 ValueError: duplicate parameter name。与 require_permission 保持一致。
|
||
if 'current_user' in param_names:
|
||
new_sig = sig
|
||
else:
|
||
params.append(
|
||
inspect.Parameter(
|
||
'current_user',
|
||
inspect.Parameter.KEYWORD_ONLY,
|
||
annotation=UserInfo,
|
||
default=Depends(get_current_user),
|
||
)
|
||
)
|
||
new_sig = sig.replace(parameters=params)
|
||
|
||
@wraps(func)
|
||
async def wrapper(*args, **kwargs):
|
||
# FastAPI 已经把 current_user 注入了 kwargs
|
||
current_user = kwargs.pop('current_user')
|
||
|
||
# 检查用户是否有任一所需角色
|
||
user_roles = set(current_user.roles)
|
||
required = set(required_roles)
|
||
|
||
if not user_roles.intersection(required):
|
||
logger.warning(
|
||
f"用户 {current_user.employee_id} 角色不足: "
|
||
f"拥有 {current_user.roles}, 需要 {required_roles}"
|
||
)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail=f"需要以下角色之一: {', '.join(required_roles)}",
|
||
)
|
||
|
||
return await func(*args, current_user=current_user, **kwargs)
|
||
|
||
# 关键:让 FastAPI 用合并后的签名,这样它能看到 current_user 这个 Depends 参数
|
||
wrapper.__signature__ = new_sig
|
||
return wrapper
|
||
|
||
return decorator
|
||
|
||
|
||
def require_admin(func):
|
||
"""管理员权限验证装饰器。
|
||
|
||
等同于 @require_role("admin")。
|
||
|
||
Example:
|
||
@router.get("/api/admin/dashboard")
|
||
@require_admin
|
||
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
|
||
pass
|
||
"""
|
||
return require_role("admin")(func)
|
||
|
||
|
||
def require_any_user(func):
|
||
"""任意已登录用户权限装饰器(agent / admin / user 均可)。
|
||
|
||
与 require_role("agent", "admin", "user") 不同,此装饰器不做角色过滤,
|
||
只要 Bearer Token 有效即放行。适用于需要认证但不限角色的端点。
|
||
|
||
Example:
|
||
@router.post("/api/vision/analyze")
|
||
@require_any_user
|
||
async def analyze_screenshot(current_user: UserInfo = Depends(get_current_user)):
|
||
pass
|
||
"""
|
||
sig = inspect.signature(func)
|
||
params = list(sig.parameters.values())
|
||
param_names = {p.name for p in params}
|
||
|
||
if 'current_user' in param_names:
|
||
new_sig = sig
|
||
else:
|
||
params.append(
|
||
inspect.Parameter(
|
||
'current_user',
|
||
inspect.Parameter.KEYWORD_ONLY,
|
||
annotation=UserInfo,
|
||
default=Depends(get_current_user),
|
||
)
|
||
)
|
||
new_sig = sig.replace(parameters=params)
|
||
|
||
@wraps(func)
|
||
async def wrapper(*args, **kwargs):
|
||
# FastAPI 已通过 Depends(get_current_user) 完成认证校验
|
||
# Token 无效时 get_current_user 会 raise 401
|
||
# 此处无需额外角色检查,直接放行
|
||
current_user = kwargs.pop('current_user')
|
||
return await func(*args, current_user=current_user, **kwargs)
|
||
|
||
wrapper.__signature__ = new_sig
|
||
return wrapper
|
||
|
||
|
||
# =============================================================================
|
||
# 细粒度权限装饰器 (v0.7.1 task #86 — RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围)
|
||
# =============================================================================
|
||
# 权限字符串格式: "resource:action:scope"
|
||
# 例: "conversation:read:all"
|
||
#
|
||
# 用法:
|
||
# @router.get("/api/admin/agents")
|
||
# @require_permission("agent:read:all")
|
||
# async def list_agents(...): ...
|
||
#
|
||
# 行为:
|
||
# 1. 装饰器只检查"是否拥有权限字符串",不直接执行 DB 查询
|
||
# 2. 实际检查在 rbac_service.check_permission() 里
|
||
# 3. 用户的权限从 UserInfo.permissions 字段读(由 get_current_user 解析 token 时填入)
|
||
# =============================================================================
|
||
|
||
def require_permission(
|
||
resource: str,
|
||
action: str,
|
||
scope: str = "own",
|
||
):
|
||
"""细粒度权限验证装饰器(v0.7.1 task #86)。
|
||
|
||
Args:
|
||
resource: 资源(conversation/agent/system_config/audit_log)
|
||
action: 操作(read/create/update/delete)
|
||
scope: 数据范围(own/department/all)
|
||
|
||
Example:
|
||
@router.get("/api/admin/agents")
|
||
@require_permission("agent", "read", "all")
|
||
async def list_agents(current_user: UserInfo = Depends(get_current_user)):
|
||
...
|
||
"""
|
||
perm_string = f"{resource}:{action}:{scope}"
|
||
|
||
def decorator(func):
|
||
sig = inspect.signature(func)
|
||
params = list(sig.parameters.values())
|
||
param_names = {p.name for p in params}
|
||
|
||
# 智能检测参数名:优先使用函数已定义的参数名
|
||
# 支持 current_user (通用/管理端) 和 current_agent (坐席端)
|
||
if 'current_agent' in param_names:
|
||
param_name = 'current_agent'
|
||
param_annotation = Agent
|
||
param_default = Depends(_get_current_agent())
|
||
else:
|
||
param_name = 'current_user'
|
||
param_annotation = UserInfo
|
||
param_default = Depends(get_current_user)
|
||
|
||
# 检查是否需要添加参数
|
||
needs_param = param_name not in param_names
|
||
if needs_param:
|
||
params.append(
|
||
inspect.Parameter(
|
||
param_name,
|
||
inspect.Parameter.KEYWORD_ONLY,
|
||
annotation=param_annotation,
|
||
default=param_default,
|
||
)
|
||
)
|
||
new_sig = sig.replace(parameters=params)
|
||
|
||
@wraps(func)
|
||
async def wrapper(*args, **kwargs):
|
||
# 提取注入的用户/坐席信息
|
||
current_user = kwargs.pop(param_name)
|
||
|
||
# 拉用户所有角色的 permissions
|
||
# 注: UserInfo.roles 是角色名列表,permissions 是 {role: [perm]} 字典
|
||
# 首次实现简化: 角色判断 + admin 通配符
|
||
# 完整实现需要查 DB 拉 permissions,见 rbac_service.check_permission
|
||
|
||
# 支持两种类型:
|
||
# 1. UserInfo (H5/管理端): 有 roles 属性 (List[str])
|
||
# 2. Agent (坐席端): 有 role 属性 (str)
|
||
if hasattr(current_user, 'roles'):
|
||
user_roles = set(current_user.roles or [])
|
||
user_id = current_user.employee_id
|
||
elif hasattr(current_user, 'role'):
|
||
# Agent 类型:role 是字符串,直接作为角色
|
||
user_roles = {current_user.role} if current_user.role else set()
|
||
user_id = current_user.user_id
|
||
else:
|
||
# 兼容:没有 roles 或 role 属性的情况
|
||
user_roles = set()
|
||
user_id = getattr(current_user, 'user_id', 'unknown')
|
||
|
||
# 保存 user_id 供后续使用
|
||
current_user._rbac_user_id = user_id
|
||
|
||
# 1. admin 角色直通(通配符 *:*:all)
|
||
if "admin" in user_roles:
|
||
return await func(*args, **{param_name: current_user}, **kwargs)
|
||
|
||
# 2. 其他角色: 走 rbac_service.check_permission
|
||
# 简化: 这里只看角色名,不查 DB(性能考虑)
|
||
# 实际生产可加缓存或预加载到 token
|
||
from app.services.rbac_service import (
|
||
ROLE_PERMISSIONS,
|
||
check_permission,
|
||
)
|
||
# 把 ROLE_PERMISSIONS 转成 {role_name: [perm_string]} 格式
|
||
user_perms_dict = {
|
||
role: [f"{r}:{a}:{s}" for (r, a, s) in perms]
|
||
for role, perms in ROLE_PERMISSIONS.items()
|
||
}
|
||
|
||
has_perm = check_permission(
|
||
user_roles=list(user_roles),
|
||
user_permissions=user_perms_dict,
|
||
required_resource=resource,
|
||
required_action=action,
|
||
required_scope=scope,
|
||
)
|
||
|
||
if not has_perm:
|
||
logger.warning(
|
||
f"用户 {user_id} 权限不足: "
|
||
f"角色 {list(user_roles)}, 缺 {perm_string}"
|
||
)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail=f"权限不足: 需要 {perm_string}",
|
||
)
|
||
|
||
return await func(*args, **{param_name: current_user}, **kwargs)
|
||
|
||
wrapper.__signature__ = new_sig
|
||
return wrapper
|
||
|
||
return decorator
|
||
|
||
|
||
# =============================================================================
|
||
# 高危操作 OTP 守卫依赖(Phase 1.3 task #19)
|
||
# =============================================================================
|
||
# 决策来源:otm-secondary-auth.md
|
||
# 触发场景:管理员执行 5 类高危操作前,必须在 30 分钟内通过 OTP 二次验证
|
||
# 验证流程:
|
||
# 1. 管理员先调 /api/mfa/verify 校验 TOTP 验证码(蜂鸟 SMS 备用)
|
||
# 2. 验证通过后 mfa.py 在 Redis 写 mfa:verified:{employee_id},TTL=1800 秒
|
||
# 3. 高危操作端点 Depends(require_high_risk_otp) 时:
|
||
# - 检查角色:admin(403 否则)
|
||
# - 检查 Redis key:mfa:verified:{employee_id}(不存在则 raise 2001)
|
||
# 4. 前端收到 2001 → 弹 OTP 输入框 → 重试
|
||
#
|
||
# 5 类高危操作清单(与 otm-secondary-auth.md 对齐):
|
||
# 1. role_change 改权限 POST /api/admin/roles/assign
|
||
# 2. config_change 改配置 PUT /api/admin/configs/{key}
|
||
# 3. data_export 导出数据 GET /api/admin/export/*
|
||
# 4. account_disable 封号 DELETE /api/admin/agents/{id}
|
||
# 5. account_create_reset 新增账号/重置 POST /api/admin/agents, /api/admin/mfa/reset/{id}
|
||
# =============================================================================
|
||
|
||
# 高危操作白名单(category → 元数据)
|
||
# 用于演示路由 + 文档化,前端可读此表知道哪些操作需要 OTP
|
||
HIGH_RISK_OPERATIONS = {
|
||
"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",
|
||
},
|
||
}
|
||
|
||
# MFA 验证通过的 Redis key 前缀
|
||
# 由 mfa.py 在 /api/mfa/verify 成功后写入,TTL=1800 秒
|
||
MFA_VERIFIED_KEY_PREFIX = "mfa:verified:"
|
||
|
||
# MFA 验证有效期(30 分钟,与 otm-secondary-auth.md 决策一致)
|
||
MFA_VERIFIED_TTL_SECONDS = 30 * 60
|
||
|
||
|
||
async def require_high_risk_otp(
|
||
current_user: UserInfo = Depends(get_current_user),
|
||
) -> UserInfo:
|
||
"""高危操作 OTP 守卫(管理员触发高危操作前必过)。
|
||
|
||
业务规则(来自 otm-secondary-auth.md 2026-06-21 决策):
|
||
1. 仅 admin 角色需要过 OTP(agent/user 直接 403)
|
||
2. 必须在 30 分钟内通过 /api/mfa/verify 校验过 OTP
|
||
3. 验证失败的 key 不算(空字符串/已过期)
|
||
|
||
鉴权流程:
|
||
- 请求携带 Bearer Token → get_current_user 解析 UserInfo
|
||
- 检查 UserInfo.roles 是否含 "admin"(否则 4003 仅管理员)
|
||
- 检查 Redis mfa:verified:{employee_id} 是否存在(否则 2001 需 OTP)
|
||
|
||
Args:
|
||
current_user: 当前用户(FastAPI 自动注入)
|
||
|
||
Returns:
|
||
UserInfo: 当前用户(已通过 OTP 守卫)
|
||
|
||
Raises:
|
||
AppException(4003, "仅管理员可执行此操作"): 非管理员角色
|
||
AppException(2001, "高危操作需要 OTP 二次验证"): admin 但未在 30 分钟内过 OTP
|
||
"""
|
||
# 第 1 关:角色检查 - 只有 admin 才需要 OTP 验证
|
||
# 注: current_role 是当前激活角色,roles 是全部角色,两者都查(双保险)
|
||
user_roles = current_user.roles or []
|
||
is_admin = (
|
||
current_user.current_role == "admin"
|
||
or "admin" in user_roles
|
||
)
|
||
if not is_admin:
|
||
logger.warning(
|
||
f"用户 {current_user.employee_id} 尝试高危操作但不是 admin: "
|
||
f"current_role={current_user.current_role}, roles={user_roles}"
|
||
)
|
||
raise AppException(
|
||
code=4003,
|
||
message="仅管理员可执行此高危操作",
|
||
)
|
||
|
||
# 第 2 关:OTP 验证标记检查 - Redis mfa:verified:{employee_id}
|
||
redis_client = await get_redis()
|
||
verified_key = f"{MFA_VERIFIED_KEY_PREFIX}{current_user.employee_id}"
|
||
verified = await redis_client.get(verified_key)
|
||
|
||
# 注:空字符串/null/bytes 都算"未通过"
|
||
if not verified:
|
||
logger.warning(
|
||
f"管理员 {current_user.employee_id} 未通过 OTP 守卫: "
|
||
f"Redis key '{verified_key}' 不存在或已过期"
|
||
)
|
||
raise AppException(
|
||
code=2001,
|
||
message="高危操作需要 OTP 二次验证,请先完成 OTP 验证",
|
||
)
|
||
|
||
# 防御性:刷新 TTL(滑动窗口)—— 如果管理员持续在做高危操作,
|
||
# 不用反复输 OTP。但要求单次操作 < 30 分钟间隔。
|
||
# 注: mfa.py 写入时已设 1800 秒 TTL,这里只在存在时刷新
|
||
if hasattr(redis_client, "expire"):
|
||
try:
|
||
await redis_client.expire(verified_key, MFA_VERIFIED_TTL_SECONDS)
|
||
except Exception as e:
|
||
# 刷新失败不影响主流程,仅记录
|
||
logger.debug(f"刷新 OTP verified TTL 失败: {e}")
|
||
|
||
logger.info(
|
||
f"管理员 {current_user.employee_id} 通过 OTP 守卫,执行高危操作"
|
||
)
|
||
return current_user
|