389 lines
14 KiB
Python
389 lines
14 KiB
Python
|
|
# =============================================================================
|
||
|
|
# 企微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())
|