Files
wecom_it_smart_desk/backend/app/dependencies.py
T

423 lines
14 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 统一认证依赖
# =============================================================================
# 说明:提供统一的认证依赖函数,支持:
# 1. get_current_user: 获取当前用户信息(包含角色)
# 2. require_role: 角色验证装饰器
# 3. require_admin: 管理员权限验证
# =============================================================================
import inspect
import json
import logging
from dataclasses import dataclass
from functools import wraps
from typing import List, Optional
import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import settings
from app.services.token_service import TokenService
from app.utils.response import AppException
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 共享实例。
Returns:
AIHandler: AI 处理器实例
"""
from app.services.ai_handler import AIHandler
from app.services.ai_service import AIService
return AIHandler(ai_service=AIService())
# FastAPI Depends 函数(用于路由依赖注入)
async def dep_redis() -> aioredis.Redis:
"""Redis 客户端依赖注入。
Returns:
aioredis.Redis: Redis 异步客户端
"""
return await get_redis()
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 依赖注入。
Returns:
AIHandler: AI 处理器实例
"""
from app.services.ai_handler import AIHandler
from app.services.ai_service import AIService
return AIHandler(ai_service=AIService())
def dep_wingman_service():
"""WingmanService 依赖注入。
Returns:
WingmanService: AI Wingman 服务实例
"""
from app.services.wingman_service import WingmanService
return WingmanService()
# 应用生命周期管理函数
async def init_shared_services():
"""初始化共享服务(应用启动时调用)。
创建 Redis 连接池,初始化共享服务实例。
"""
global _redis_pool
_redis_pool = settings.create_redis_client()
logger.info("共享服务初始化完成")
async def cleanup_shared_services():
"""清理共享服务(应用关闭时调用)。
关闭 Redis 连接池。
"""
global _redis_pool
if _redis_pool:
await _redis_pool.close()
_redis_pool = None
logger.info("共享服务清理完成")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> UserInfo:
"""统一认证依赖:从 Token 获取用户信息。
支持新旧两种 Token 格式。
Args:
credentials: HTTP Bearer Token
Returns:
UserInfo: 用户信息
Raises:
HTTPException: Token 无效或已过期
"""
token = credentials.credentials
# 获取 Redis 连接
redis_client = await get_redis()
# 创建 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"},
)
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())
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)
# =============================================================================
# 高危操作 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) 时:
# - 检查角色:admin403 否则)
# - 检查 Redis keymfa: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 角色需要过 OTPagent/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