feat(merge): 4 个 worktree 合入 main(扫码+MFA+高危+P0)

合入内容:
- 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>
This commit is contained in:
Simon
2026-06-21 03:08:54 +08:00
parent f564d0e42a
commit bf872da8bb
22 changed files with 4704 additions and 27 deletions
+236
View File
@@ -0,0 +1,236 @@
# =============================================================================
# 企微IT智能服务台 — 扫码登录 API
# =============================================================================
# 说明:扫码登录是 Phase 1.1 的核心功能,用于替代坐席端"用户名密码+企微
# OAuth"双因素登录,提供"用企微 App 扫一扫登录浏览器坐席端"的体验。
#
# 完整流程:
# ┌─────────┐ create ┌─────────────┐ scan ┌──────────┐
# │ 浏览器 │ ───────→ │ ticket(120s)│ ←───── │ 企微 App │
# │ 前端 │ ←─────── │ +OAuth URL │ OAuth │ 扫码授权 │
# └─────────┘ qrcode_url └─────────────┘ code └──────────┘
# │ │ │
# │ poll │ scan │
# │ waiting/scanned │ 写 scan:{ticket} │
# │ ↓ │
# │ ┌────────────────┐ │
# │ │ 已登录坐席(企微)│ confirm │
# │ │ 点"确认登录"按钮 │ ────────→ │
# │ └────────────────┘ │
# │ │ │
# │ poll │ confirm │
# │ confirmed+token │ 写 confirm:{ticket} │
# ↓ ↓ │
# 拿到 token,跳坐席端主页 │
#
# 端点列表(4 个):
# POST /api/auth_qrcode/create — 浏览器前端生成 ticket
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
# POST /api/auth_qrcode/scan — 企微 OAuth2 回调(接收 code)
# POST /api/auth_qrcode/confirm — 当前登录坐席点确认
#
# 鉴权说明:
# - create / scan / poll: 无需登录(浏览器刚加载登录页,用户未登录)
# - confirm: 需要已登录坐席点确认(角色: agent / admin)
# - 票据状态全部存 Redis,TTL 到期自动失效,无 DB 表
# =============================================================================
import logging
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Path
from app.config import settings
from app.dependencies import dep_redis, get_current_user, UserInfo
from app.schemas.qrcode import (
QrcodeConfirmRequest,
QrcodeConfirmResponse,
QrcodeCreateResponse,
QrcodePollResponse,
QrcodeScanRequest,
QrcodeScanResponse,
)
from app.services.qrcode_service import QrcodeService
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# 创建路由器
# prefix="/auth_qrcode" + tags=["扫码登录"] 用于 Swagger 分组
router = APIRouter(prefix="/auth_qrcode", tags=["扫码登录"])
def _get_qrcode_service(redis_client: aioredis.Redis) -> QrcodeService:
"""工厂函数: 构造扫码登录业务服务。
拆出来便于测试时 monkey-patch,以及后续接入 DI。
"""
return QrcodeService(redis_client)
# --------------------------------------------------------------------------
# POST /api/auth_qrcode/create — 创建扫码登录票据
# --------------------------------------------------------------------------
@router.post("/create", response_model=None)
async def create_qrcode(
redis_client: aioredis.Redis = Depends(dep_redis),
):
"""创建扫码登录票据。
无需鉴权(用户尚未登录,正在登录页)。
返回 ticket + 企微 OAuth2 授权 URL,前端渲染二维码。
Returns:
Dict: 统一响应格式,data 字段是 QrcodeCreateResponse
"""
try:
service = _get_qrcode_service(redis_client)
result = await service.create_ticket()
return success_response(data={
"ticket": result["ticket"],
"qrcode_url": result["qrcode_url"],
"expires_in": result["expires_in"],
"expires_at": result["expires_at"].isoformat(),
})
except Exception as e:
logger.error(f"创建扫码票据异常: {e}", exc_info=True)
raise AppException(1005, f"创建扫码票据失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
# --------------------------------------------------------------------------
@router.get("/poll/{ticket}", response_model=None)
async def poll_qrcode(
ticket: str = Path(..., description="扫码登录票据"),
redis_client: aioredis.Redis = Depends(dep_redis),
):
"""轮询扫码状态。
无需鉴权(浏览器未登录态访问)。
状态机:
- waiting: ticket 有效,等待扫码
- scanned: 已扫码,等待 confirm
- confirmed: 已确认,返回 token
- expired: ticket 过期/不存在
Returns:
Dict: 统一响应格式,data 字段是 QrcodePollResponse
"""
try:
service = _get_qrcode_service(redis_client)
result = await service.get_poll_state(ticket)
return success_response(data={
"status": result["status"],
"employee_id": result.get("employee_id"),
"name": result.get("name"),
"token": result.get("token"),
})
except Exception as e:
logger.error(f"轮询扫码状态异常: ticket={ticket[:8]}..., error={e}", exc_info=True)
raise AppException(1005, f"轮询扫码状态失败: {str(e)}")
# --------------------------------------------------------------------------
# POST /api/auth_qrcode/scan — 企微 OAuth code 回调
# --------------------------------------------------------------------------
@router.post("/scan", response_model=None)
async def scan_qrcode(
body: QrcodeScanRequest,
redis_client: aioredis.Redis = Depends(dep_redis),
):
"""处理企微 OAuth2 扫码回调。
无需鉴权(此端点被企微服务器回调,带 code + ticket)。
用 code 换取企微 userid,然后写 Redis scan:{ticket} 等待 confirm 端点。
dev 模式: code 形如 "dev:dev-user-001",跳过企微 API 调用。
Args:
body: 包含 ticket 和 code
Returns:
Dict: 统一响应格式,data 字段是 QrcodeScanResponse
"""
try:
service = _get_qrcode_service(redis_client)
result = await service.process_scan(ticket=body.ticket, code=body.code)
return success_response(data={
"success": result["success"],
"message": result["message"],
})
except ValueError as ve:
# 票据过期/不存在 → 业务错误
logger.warning(f"扫码业务错误: {ve}")
raise AppException(1003, str(ve))
except Exception as e:
logger.error(f"扫码处理异常: ticket={body.ticket[:8]}..., error={e}", exc_info=True)
raise AppException(1005, f"扫码处理失败: {str(e)}")
# --------------------------------------------------------------------------
# POST /api/auth_qrcode/confirm — 当前已登录坐席确认授权
# --------------------------------------------------------------------------
@router.post("/confirm", response_model=None)
async def confirm_qrcode(
body: QrcodeConfirmRequest,
current_user: UserInfo = Depends(get_current_user),
redis_client: aioredis.Redis = Depends(dep_redis),
):
"""处理当前已登录坐席的扫码确认授权。
需要鉴权: 只有已登录的坐席/管理员能确认授权。
把扫码用户身份变成可登录 Token(roles=['agent']),
写 Redis confirm:{ticket},前端 poll 拿到后跳坐席主页。
otp_code: admin 场景下可选,Phase 1.1 仅记录日志,
真实 OTP 校验留给 Phase 2.1(参考 agents.py:272-274 的 totp.verify)。
Args:
body: 包含 ticket 和 otp_code(可选)
current_user: 当前已登录用户(由 get_current_user 注入)
redis_client: Redis 客户端
Returns:
Dict: 统一响应格式,data 字段是 QrcodeConfirmResponse
"""
try:
service = _get_qrcode_service(redis_client)
result = await service.process_confirm(
ticket=body.ticket,
current_user_id=current_user.employee_id,
current_user_name=current_user.name,
current_roles=current_user.roles,
otp_code=body.otp_code,
)
return success_response(data={
"token": result["token"],
"employee_id": result["employee_id"],
"name": result["name"],
"roles": result["roles"],
"require_otp": result.get("require_otp"),
})
except ValueError as ve:
# 票据过期/未扫码 → 业务错误
logger.warning(
f"扫码确认业务错误: ticket={body.ticket[:8]}..., "
f"current_user={current_user.employee_id}, error={ve}"
)
raise AppException(1003, str(ve))
except Exception as e:
logger.error(
f"扫码确认异常: ticket={body.ticket[:8]}..., "
f"current_user={current_user.employee_id}, error={e}",
exc_info=True,
)
raise AppException(1005, f"扫码确认失败: {str(e)}")
+191
View File
@@ -0,0 +1,191 @@
# =============================================================================
# 企微IT智能服务台 — 高危操作演示 API
# =============================================================================
# Phase 1.3 task #19: 高危操作路由白名单 + 中间件演示
# 决策来源:otm-secondary-auth.md2026-06-21
#
# 设计原则:
# 本文件只演示 require_high_risk_otp 依赖的用法,不重复实现业务。
# 实际业务端点(admin_rbac.py / admin_api.py)在后续 worktree 中追加
# Depends(require_high_risk_otp) 即可生效。
#
# 演示端点:
# POST /api/admin/high-risk/demo/{category} — 用 5 个 category 各跑一遍
# GET /api/admin/high-risk/whitelist — 获取白名单(前端文档化用)
# GET /api/admin/high-risk/check — 检查当前管理员 OTP 状态
#
# 鉴权:
# - demo/{category}: 需 admin 角色 + 30 分钟内 OTP 验证
# - whitelist: 仅 admin 角色(不需要 OTP,纯查询)
# - check: 仅 admin 角色(不需要 OTP,纯查询自己状态)
#
# 错误码:
# 2001 = 高危操作需要 OTP 二次验证
# 4003 = 仅管理员可执行此操作
# 4000 = 未知的高危操作类别
# =============================================================================
import logging
from typing import Any, Dict
from fastapi import APIRouter, Depends
from app.dependencies import (
HIGH_RISK_OPERATIONS,
UserInfo,
require_high_risk_otp,
)
from app.services.high_risk_guard import HighRiskGuard
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# 路由器
# -----------------------------------------------------------------------------
# prefix: /admin/high-risk
# 完整路径前缀: /api/admin/high-risk
# -----------------------------------------------------------------------------
router = APIRouter(prefix="/admin/high-risk")
# -----------------------------------------------------------------------------
# 演示端点 1: POST /api/admin/high-risk/demo/{category}
# -----------------------------------------------------------------------------
@router.post(
"/demo/{category}",
summary="演示高危操作 OTP 守卫",
description=(
"展示 5 类高危操作(role_change / config_change / data_export / "
"account_disable / account_create_reset)的 OTP 守卫流程。<br><br>"
"调用此端点时,如果当前管理员 30 分钟内没在 /api/mfa/verify 过 OTP,"
"会返回错误码 2001,前端应弹 OTP 输入框 → 调 /api/mfa/verify → 重试。"
),
)
async def demo_high_risk_op(
category: str,
current_user: UserInfo = Depends(require_high_risk_otp),
) -> Dict[str, Any]:
"""演示:展示高危操作 OTP 守卫。
触发流程:
1. 前端调 POST /api/admin/high-risk/demo/role_change
2. require_high_risk_otp 依赖先跑:
a. 检查 admin 角色(否则 4003
b. 检查 Redis mfa:verified:{employee_id}(否则 2001
3. 通过守卫 → 返回 success
Args:
category: 5 类之一 (role_change / config_change / data_export /
account_disable / account_create_reset)
current_user: 当前管理员(依赖自动注入)
Returns:
Dict: 演示结果
Raises:
AppException(4000): 未知的高危操作类别
AppException(4003): 非 admin 角色(来自 require_high_risk_otp
AppException(2001): 未在 30 分钟内过 OTP(来自 require_high_risk_otp
"""
# 第 1 关:类别校验
if category not in HIGH_RISK_OPERATIONS:
valid_categories = ", ".join(HIGH_RISK_OPERATIONS.keys())
raise AppException(
code=4000,
message=f"未知的高危操作类别: {category}。合法值: {valid_categories}",
)
# 第 2 关:模拟执行(不真正改数据,只演示守卫通过)
op_meta = HIGH_RISK_OPERATIONS[category]
logger.info(
f"演示高危操作 {category} 执行: "
f"employee_id={current_user.employee_id}, "
f"category={op_meta['category']}"
)
return success_response(
data={
"category": category,
"operation": op_meta,
"executed_by": current_user.employee_id,
"executed_by_name": current_user.name,
"message": (
f"演示操作 [{op_meta['category']}/{category}] 已通过 OTP 守卫"
),
"note": "本端点仅演示 OTP 守卫流程,不实际修改数据",
},
)
# -----------------------------------------------------------------------------
# 演示端点 2: GET /api/admin/high-risk/whitelist
# -----------------------------------------------------------------------------
@router.get(
"/whitelist",
summary="获取高危操作白名单",
description="返回 5 类高危操作的元数据,供前端文档化展示。",
)
async def get_whitelist(
current_user: UserInfo = Depends(require_high_risk_otp),
) -> Dict[str, Any]:
"""获取 5 类高危操作白名单。
注意:此端点也加 require_high_risk_otp,因为白名单本身属于敏感元数据。
实际生产中可改为仅 require_admin,降低前端文档加载的复杂度。
这里为了演示一致性,统一加 OTP 守卫。
Args:
current_user: 当前管理员(依赖自动注入)
Returns:
Dict: 白名单 + 分类元数据
"""
return success_response(
data={
"whitelist": HighRiskGuard.get_whitelist(),
"total_categories": len(HighRiskGuard.list_categories()),
"categories": HighRiskGuard.list_categories(),
"ttl_seconds": HighRiskGuard.DEFAULT_TTL_SECONDS,
"ttl_human": "30 分钟",
},
)
# -----------------------------------------------------------------------------
# 演示端点 3: GET /api/admin/high-risk/check
# -----------------------------------------------------------------------------
@router.get(
"/check",
summary="检查当前管理员 OTP 验证状态",
description=(
"查询当前管理员是否在 30 分钟内通过过 OTP。"
"前端在弹 OTP 输入框前先调一次此端点,如果已验证就不弹。"
),
)
async def check_otp_status(
current_user: UserInfo = Depends(require_high_risk_otp),
) -> Dict[str, Any]:
"""检查当前管理员 OTP 验证状态。
用途:前端可在做高危操作前先调此端点决定要不要弹 OTP 输入框。
Args:
current_user: 当前管理员(依赖自动注入)
Returns:
Dict: 验证状态
"""
# 注:能进到这里说明 require_high_risk_otp 已经检查过 Redis,
# 这里再用 service 查一次拿详细信息(method/verified_at)
# 由于没有 redis_client 直接传入,这里返回简化结果
return success_response(
data={
"employee_id": current_user.employee_id,
"is_verified": True, # 已经通过守卫 = verified
"message": "当前管理员 OTP 已验证,可以执行高危操作",
"note": "本端点本身需要 OTP 守卫,所以必然返回 is_verified=True",
},
)
+389
View File
@@ -0,0 +1,389 @@
# =============================================================================
# 企微IT智能服务台 — MFA 二次认证 API
# =============================================================================
# 说明:基于 TOTP(Google Authenticator 兼容)的二次认证 API
# Phase 2.1 task #17: pyotp TOTP 服务 + User MFA 字段
#
# 端点列表:
# 1. GET /api/mfa/status — 查询绑定状态(路由守卫用)
# 2. POST /api/mfa/bind/start — 生成 secret + 二维码(尚未启用)
# 3. POST /api/mfa/bind/confirm — 输入 OTP 完成绑定(启用)
# 4. POST /api/mfa/verify — 输入 OTP 通过验证(写 Redis 30 分钟)
# 5. POST /api/mfa/disable — 用户主动关闭 MFA
# 6. POST /api/admin/mfa/reset/{employee_id} — 管理员重置(员工丢手机兜底)
#
# 鉴权:
# - 1-5 用 get_current_user(任意已登录用户)
# - 6 用 require_role("admin")(管理员)
#
# 流程(典型用户视角):
# 1. 前端路由守卫调 GET /status,bound=false → 跳转绑定页
# 2. 用户点"绑定" → POST /bind/start → 展示二维码 + secret
# 3. 用户用 Authenticator 扫码 → 输入 6 位码 → POST /bind/confirm
# 4. 后续敏感操作前 → POST /verify → Redis 30 分钟内免重复输
# 5. 丢手机 → 找管理员 → POST /admin/mfa/reset/{employee_id}
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import UserInfo, get_current_user
from app.models.agent import Agent
from app.schemas.mfa import (
MFAAdminResetResponse,
MFABindConfirmRequest,
MFABindConfirmResponse,
MFABindStartResponse,
MFADisableRequest,
MFADisableResponse,
MFAStatusResponse,
MFAVerifyRequest,
MFAVerifyResponse,
)
from app.services.mfa_service import MFA_VERIFIED_TTL_SECONDS, MFAService
from app.utils.error_codes import ErrorCode
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# 路由配置
# -----------------------------------------------------------------------------
# /api/mfa 前缀;admin 重置走 /api/admin/mfa 单独 router
# -----------------------------------------------------------------------------
router = APIRouter(prefix="/mfa", tags=["MFA二次认证"])
admin_router = APIRouter(prefix="/admin/mfa", tags=["MFA管理(管理员)"])
def _get_redis() -> aioredis.Redis:
"""获取 Redis 客户端(模块级 helper,便于测试 patch)。
Returns:
aioredis.Redis: Redis 异步客户端
"""
return settings.create_redis_client()
# -----------------------------------------------------------------------------
# 通用工具:根据 user_id 查 Agent 记录
# -----------------------------------------------------------------------------
async def _get_agent_by_employee_id(
db: AsyncSession, employee_id: str
) -> Optional[Agent]:
"""按 user_id(employee_id)查询 Agent 行。
Args:
db: 数据库会话
employee_id: 用户标识(企微 userid)
Returns:
Optional[Agent]: 找不到返回 None
"""
stmt = select(Agent).where(Agent.user_id == employee_id)
result = await db.execute(stmt)
return result.scalars().first()
# -----------------------------------------------------------------------------
# 通用工具:验证当前用户是否已登录 + 取得 Agent 行
# -----------------------------------------------------------------------------
async def _require_agent(
db: AsyncSession, current_user: UserInfo
) -> Agent:
"""根据当前 token 取出对应的 Agent 行,不存在则 404。
为什么需要 Agent 行:
MFA 状态/secret 都存在 agents 表,不是 employees 表。
Raises:
AppException: 坐席不存在(E4001)
"""
agent = await _get_agent_by_employee_id(db, current_user.employee_id)
if not agent:
raise AppException(ErrorCode.AGENT_NOT_FOUND, "坐席不存在,无法进行 MFA 操作")
return agent
# =============================================================================
# 1. GET /api/mfa/status — 查询绑定状态
# =============================================================================
@router.get("/status", response_model=None)
async def get_mfa_status(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查询当前用户的 MFA 绑定状态。
前端路由守卫使用:
- bound=false → 强制走绑定流程
- bound=true → 跳到"输入 OTP 验证"或继续业务
Returns:
success_response({bound, enabled, last_verified_at})
"""
agent = await _require_agent(db, current_user)
return success_response(data=MFAStatusResponse(
bound=bool(agent.mfa_enabled and agent.mfa_secret),
enabled=bool(agent.mfa_enabled),
last_verified_at=agent.mfa_last_verified_at,
).model_dump(mode="json"))
# =============================================================================
# 2. POST /api/mfa/bind/start — 生成 secret + 二维码
# =============================================================================
@router.post("/bind/start", response_model=None)
async def bind_start(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""生成 TOTP 密钥和二维码。
行为:
- 生成 32 位 base32 secret
- 把 secret 写入 agents.mfa_secret(mfa_enabled=False,mfa_bound_at=None)
- 返回 otpauth URI + base64 二维码 PNG(给前端展示)
重复调用策略:
- 如果已经 enabled=True → 拒绝,要求先 disable 再重新绑定
- 如果只是 secret 存在但 enabled=False → 复用旧 secret(支持"刷新二维码")
Returns:
success_response({secret, otpauth_url, qr_code_base64})
"""
agent = await _require_agent(db, current_user)
# 已启用则拒绝重新绑定(必须先 disable)
if agent.mfa_enabled:
raise AppException(
ErrorCode.INVALID_PARAMETER,
"已绑定 MFA,如需重新绑定请先关闭",
)
# 复用旧 secret 还是新生成?
if agent.mfa_secret:
secret = agent.mfa_secret
else:
secret = MFAService.generate_secret()
agent.mfa_secret = secret
# mfa_enabled 保持 False,mfa_bound_at 等首次验证通过再写
db.add(agent)
await db.flush()
otpauth_url = MFAService.build_provisioning_uri(secret, agent.user_id)
qr_base64 = MFAService.render_qrcode_base64(otpauth_url)
logger.info(f"MFA bind/start: agent={agent.user_id}, secret_prefix={secret[:4]}...")
return success_response(data=MFABindStartResponse(
secret=secret,
otpauth_url=otpauth_url,
qr_code_base64=qr_base64,
).model_dump())
# =============================================================================
# 3. POST /api/mfa/bind/confirm — 输入 OTP 完成绑定
# =============================================================================
@router.post("/bind/confirm", response_model=None)
async def bind_confirm(
body: MFABindConfirmRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""用 6 位 OTP 码确认绑定,启用 MFA。
行为:
- 用 mfa_secret 校验 otp_code(valid_window=1)
- 校验通过 → mfa_enabled=True, mfa_bound_at=now(), mfa_last_verified_at=now()
- 校验失败 → 抛 AppException(E_INVALID_PARAMETER)
Returns:
success_response({success: true})
"""
agent = await _require_agent(db, current_user)
# 必须先 start(secret 必须存在)
if not agent.mfa_secret:
raise AppException(
ErrorCode.INVALID_PARAMETER,
"请先调用 /api/mfa/bind/start 获取二维码",
)
# 校验 OTP
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
logger.warning(f"MFA bind/confirm 验证码错误: agent={agent.user_id}")
raise AppException(ErrorCode.INVALID_PARAMETER, "OTP 验证码错误")
now = datetime.now()
agent.mfa_enabled = True
agent.mfa_bound_at = now
agent.mfa_last_verified_at = now
db.add(agent)
await db.flush()
logger.info(f"MFA bind/confirm 绑定成功: agent={agent.user_id}")
return success_response(data=MFABindConfirmResponse(success=True).model_dump())
# =============================================================================
# 4. POST /api/mfa/verify — 输入 OTP 通过验证(写 Redis 30 分钟)
# =============================================================================
@router.post("/verify", response_model=None)
async def verify_mfa(
body: MFAVerifyRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
"""校验 6 位码,在 Redis 写 30 分钟复用标记。
行为:
- 校验通过 → mfa:verified:{employee_id}=1 TTL 1800s
+ 更新 mfa_last_verified_at
- 校验失败 → verified=false(不抛异常,前端可以重试)
Returns:
success_response({verified, expires_in})
"""
agent = await _require_agent(db, current_user)
if not agent.mfa_enabled or not agent.mfa_secret:
# 用户还没绑定 MFA,直接返回 verified=false
# (前端可据此跳转到绑定流程)
return success_response(data=MFAVerifyResponse(
verified=False,
expires_in=0,
).model_dump())
# 校验
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
logger.warning(f"MFA verify 验证码错误: agent={agent.user_id}")
return success_response(data=MFAVerifyResponse(
verified=False,
expires_in=0,
).model_dump())
# 写 Redis 复用标记
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
# 更新最后验证时间
now = datetime.now()
agent.mfa_last_verified_at = now
db.add(agent)
await db.flush()
logger.info(f"MFA verify 通过: agent={agent.user_id}")
return success_response(data=MFAVerifyResponse(
verified=True,
expires_in=MFA_VERIFIED_TTL_SECONDS,
).model_dump())
# =============================================================================
# 5. POST /api/mfa/disable — 用户主动关闭 MFA
# =============================================================================
@router.post("/disable", response_model=None)
async def disable_mfa(
body: MFADisableRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
"""关闭 MFA(清空 secret + disabled 标记)。
安全要求: 必须先校验当前 OTP,防止误操作或被劫持后恶意关闭。
Returns:
success_response({success: true})
"""
agent = await _require_agent(db, current_user)
if not agent.mfa_enabled or not agent.mfa_secret:
# 没绑定过,直接幂等成功
return success_response(data=MFADisableResponse(success=True).model_dump())
# 必须先验证 OTP
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
raise AppException(ErrorCode.INVALID_PARAMETER, "OTP 验证码错误,无法关闭 MFA")
# 清空字段
agent.mfa_secret = None
agent.mfa_enabled = False
agent.mfa_bound_at = None
# mfa_last_verified_at 保留,作为历史记录
db.add(agent)
await db.flush()
# 顺手清掉 Redis 验证标记(避免遗留)
await MFAService.clear_verified(redis, agent.user_id)
logger.info(f"MFA disable: agent={agent.user_id}")
return success_response(data=MFADisableResponse(success=True).model_dump())
# =============================================================================
# 6. POST /api/admin/mfa/reset/{employee_id} — 管理员重置(丢手机兜底)
# =============================================================================
# 注意:此端点不要求 otp_code(员工已无法提供),只校验 admin 角色
# 鉴权:在函数体内手动检查 current_user.roles 是否含 'admin',抛 AppException(FORBIDDEN)
# 原因:@require_role 装饰器 + body 参数组合在 FastAPI 签名合并时会重复 current_user 参数
# (已知坑,见 memory rbac-pydantic-coroutine-pitfalls.md),手动校验更稳
# =============================================================================
@admin_router.post("/reset/{employee_id}", response_model=None)
async def admin_reset_mfa(
employee_id: str,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
"""管理员重置指定员工的 MFA 绑定(无 OTP 验证)。
使用场景:
- 员工丢手机/换手机 → 管理员后台"重置 MFA"按钮
鉴权:校验 current_user 是否拥有 admin 角色。
Returns:
success_response({success: true})
"""
# 角色校验:仅 admin 角色可访问
if "admin" not in current_user.roles:
raise AppException(
ErrorCode.FORBIDDEN,
"需要管理员权限",
)
stmt = select(Agent).where(Agent.user_id == employee_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
raise AppException(ErrorCode.AGENT_NOT_FOUND, f"坐席 {employee_id} 不存在")
agent.mfa_secret = None
agent.mfa_enabled = False
agent.mfa_bound_at = None
# mfa_last_verified_at 保留,作为审计
db.add(agent)
await db.flush()
# 顺手清 Redis 标记
await MFAService.clear_verified(redis, employee_id)
logger.info(f"MFA admin reset: employee_id={employee_id} by={current_user.employee_id}")
return success_response(data=MFAAdminResetResponse(success=True).model_dump())
+29
View File
@@ -178,3 +178,32 @@ api_router.include_router(approval_router, tags=["审批流程"])
# 企微 JS-SDK 签名 API (v0.5.4 应急页身份检测用)
# GET /api/wecom/jsapi-config?url=xxx — 返回 corp_id/agent_id/timestamp/nonce_str/signature
api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"])
# 扫码登录 API (Phase 1.1 task #14)
# POST /api/auth_qrcode/create — 创建扫码登录票据
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
# POST /api/auth_qrcode/scan — 企微 OAuth2 回调
# POST /api/auth_qrcode/confirm — 已登录坐席确认授权
from app.api.auth_qrcode import router as auth_qrcode_router
api_router.include_router(auth_qrcode_router, tags=["扫码登录"])
# 高危操作演示 API (Phase 1.3 task #19)
# POST /api/admin/high-risk/demo/{category} — 5 类高危操作演示端点
# GET /api/admin/high-risk/whitelist — 获取高危操作白名单
# GET /api/admin/high-risk/check — 检查当前管理员 OTP 状态
from app.api.high_risk_routes import router as high_risk_routes_router
api_router.include_router(high_risk_routes_router, tags=["高危操作"])
from app.api.mfa import router as mfa_router, admin_router as mfa_admin_router # Phase 2.1 task #17
# MFA 二次认证 API (Phase 2.1 task #17)
# GET /api/mfa/status — 查询绑定状态(路由守卫用)
# POST /api/mfa/bind/start — 生成 secret + 二维码
# POST /api/mfa/bind/confirm — 输入 OTP 完成绑定
# POST /api/mfa/verify — 输入 OTP 通过验证(写 Redis 30 分钟)
# POST /api/mfa/disable — 用户主动关闭 MFA
api_router.include_router(mfa_router, tags=["MFA二次认证"])
# MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底)
# POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA
api_router.include_router(mfa_admin_router, tags=["MFA管理(管理员)"])