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>
423 lines
14 KiB
Python
423 lines
14 KiB
Python
# =============================================================================
|
||
# 企微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) 时:
|
||
# - 检查角色: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
|