# ============================================================================= # 企微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())