Files
wecom_it_smart_desk/backend/app/api/otp.py
T

445 lines
16 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 统一 OTP 二次认证 API(三端认证重构 AUTH-03
# =============================================================================
# ⚠️ DEPRECATED 废弃说明 (2026-07-10)
# 本文件已废弃,不再使用。原因:
# 1. 认证重构后统一使用企微扫码登录,无需OTP二次验证
# 2. 企微扫码本身已是双因素(企微账号+手机设备)
# 3. 新增统一认证API见: app/api/auth.py
#
# 如需保留仅用于管理员紧急访问OTP的功能,请迁移到high_risk_routes.py
# =============================================================================
# 说明:三端(H5 员工端 / 坐席端 / 管理端)统一的 OTP(TOTP) 二次认证路由。
#
# 端点列表(前缀 /auth,nginx 会剥离 /api 前缀,对外即 /api/auth/otp-*):
# 1. GET /auth/otp-status — 查询绑定状态(路由守卫用)
# 2. POST /auth/otp-bind — 生成 secret + 二维码(尚未启用)
# 3. POST /auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
# 4. POST /auth/otp-unbind — 用户主动关闭 MFA
# 5. POST /auth/otp-admin-reset/{id} — 管理员重置(员工丢手机兜底)
# 6. GET /auth/otp-admin-users — 管理员查看全部坐席 MFA 绑定状态
#
# 设计要点(与 system_design.md 对齐):
# - 复用 MFAServicepyotp + qrcode)与 agents 表的 mfa_* 字段
# - 验证通过后在 Redis 写 mfa:verified:{employee_id}TTL=1800s
# - 与 dependencies.require_high_risk_otp 共用同一 Redis key(契约不变)
# - 取代原 /mfa/* 与 /admin/mfa/* 以及 agents 内联 /agents/otp-* 端点
#
# 鉴权:
# - 1-4 用 get_current_user(任意已登录用户)
# - 5-6 用 require_role("admin")(管理员)
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends
from sqlalchemy import func, 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, require_role
from app.models.agent import Agent
from app.schemas.mfa import (
MFABindConfirmRequest,
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__)
# -----------------------------------------------------------------------------
# 路由配置:统一前缀 /auth
# -----------------------------------------------------------------------------
router = APIRouter(prefix="/auth", tags=["OTP二次认证"])
def _get_redis() -> aioredis.Redis:
"""获取 Redis 客户端(模块级 helper,便于测试 patch)。
Returns:
aioredis.Redis: Redis 异步客户端
"""
return settings.create_redis_client()
# -----------------------------------------------------------------------------
# 通用工具:根据 user_id(employee_id) 查 Agent 记录
# -----------------------------------------------------------------------------
async def _get_agent_by_employee_id(
db: AsyncSession, employee_id: str
) -> Optional[Agent]:
"""按 user_idemployee_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。
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, "坐席不存在,无法进行 OTP 操作")
return agent
# =============================================================================
# 1. GET /auth/otp-status — 查询绑定状态
# =============================================================================
@router.get("/otp-status", response_model=None)
async def get_otp_status(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
"""查询当前用户的 OTP 绑定状态。
前端路由守卫使用:
- bound=false → 强制走绑定流程
- bound=true → 跳到"输入 OTP 验证"或继续业务
Returns:
success_response({bound, enabled, last_verified_at, verified})
"""
agent = await _require_agent(db, current_user)
# 是否处于 30 分钟验证窗口内(与 require_high_risk_otp 共用 key
verified = await MFAService.is_verified(redis, agent.user_id)
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") | {"verified": verified})
# =============================================================================
# 2. POST /auth/otp-bind — 生成 secret + 二维码
# =============================================================================
@router.post("/otp-bind", response_model=None)
async def bind_otp(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""生成 TOTP 密钥和二维码。
行为:
- 生成 32 位 base32 secret
- 把 secret 写入 agents.mfa_secretmfa_enabled=False, mfa_bound_at=None
- 返回 otpauth URI + base64 二维码 PNG(给前端展示)
重复调用策略:
- 已 enabled=True → 拒绝,要求先 unbind 再重新绑定
- 仅 secret 存在但 enabled=False → 复用旧 secret(支持"刷新二维码"
Returns:
success_response({secret, otpauth_url, qr_code_base64})
"""
agent = await _require_agent(db, current_user)
# 已启用则拒绝重新绑定(必须先 unbind)
if agent.mfa_enabled:
raise AppException(
ErrorCode.INVALID_PARAMETER,
"已绑定 OTP,如需重新绑定请先关闭",
)
# 复用旧 secret 还是新生成?
if agent.mfa_secret:
secret = agent.mfa_secret
else:
secret = MFAService.generate_secret()
agent.mfa_secret = secret
# mfa_enabled 保持 Falsemfa_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"OTP bind: 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 /auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
# =============================================================================
@router.post("/otp-verify", response_model=None)
async def verify_otp(
body: MFAVerifyRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
"""校验 6 位码,在 Redis 写 30 分钟复用标记。
两种场景(三端认证重构 AUTH-05):
- 已绑定(mfa_enabled=True → 常规验证,写 Redis 标记
- 首次绑定(mfa_enabled=False 但有 mfa_secret)→ 验证后启用 MFA 并直接签发 token
- 未初始化(无 mfa_secret)→ 返回 verified=false
Returns:
success_response({verified, expires_in, token?})
"""
agent = await _require_agent(db, current_user)
# 场景1: 未初始化 OTP(无 secret)→ 无法验证
if not agent.mfa_secret:
return success_response(data=MFAVerifyResponse(
verified=False,
expires_in=0,
).model_dump(exclude={"token"}))
# 校验 OTP 码(两种场景共用)
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
logger.warning(f"OTP verify 验证码错误: agent={agent.user_id}")
return success_response(data=MFAVerifyResponse(
verified=False,
expires_in=0,
).model_dump(exclude={"token"}))
# OTP 码校验通过
# 场景2: 首次绑定(mfa_enabled=False 但有 secret
# 行为:启用 MFA + 记录绑定时间 + 写 Redis 标记 + 直接签发 token
# 避免前端还需要二次调用 login
is_first_bind = not agent.mfa_enabled
now = datetime.now()
if is_first_bind:
agent.mfa_enabled = True
agent.mfa_bound_at = now
agent.mfa_last_verified_at = now
db.add(agent)
await db.flush()
# 写 Redis 复用标记
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
# 签发 token(直接复用 agents 登录的 token 创建逻辑)
from app.services.token_service import TokenService
token_service = TokenService(redis)
token = await token_service.create_token(
employee_id=agent.user_id,
name=agent.name or current_user.name,
roles=current_user.roles,
avatar=getattr(current_user, "avatar", None),
login_source=getattr(current_user, "login_source", "agent"),
)
logger.info(f"OTP 首次绑定成功并签发 token: agent={agent.user_id}")
return success_response(data={
**MFAVerifyResponse(
verified=True,
expires_in=MFA_VERIFIED_TTL_SECONDS,
).model_dump(),
"token": token,
"user_id": agent.user_id,
"name": agent.name or current_user.name,
"role": agent.role,
"is_first_bind": True,
})
# 场景3: 已绑定常规验证(mfa_enabled=True
# 写 Redis 复用标记(与 require_high_risk_otp 共用 key
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
# 更新最后验证时间
agent.mfa_last_verified_at = now
db.add(agent)
await db.flush()
logger.info(f"OTP verify 通过: agent={agent.user_id}")
return success_response(data=MFAVerifyResponse(
verified=True,
expires_in=MFA_VERIFIED_TTL_SECONDS,
).model_dump(exclude={"token"}))
# =============================================================================
# 4. POST /auth/otp-unbind — 用户主动关闭 OTP
# =============================================================================
@router.post("/otp-unbind", response_model=None)
async def unbind_otp(
body: MFADisableRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
"""关闭 OTP(清空 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 验证码错误,无法关闭 OTP")
# 清空字段
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"OTP unbind: agent={agent.user_id}")
return success_response(data=MFADisableResponse(success=True).model_dump())
# =============================================================================
# 5. POST /auth/otp-admin-reset/{employee_id} — 管理员重置(丢手机兜底)
# =============================================================================
# 注意:此端点不要求 otp_code(员工已无法提供),只校验 admin 角色
# 鉴权:@require_role("admin") 装饰器强制
# =============================================================================
@router.post("/otp-admin-reset/{employee_id}", response_model=None)
@require_role("admin")
async def admin_reset_otp(
employee_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
"""管理员重置指定员工的 OTP 绑定(无 OTP 验证)。
使用场景:
- 员工丢手机 / 换手机 → 管理员后台"重置 OTP"按钮
Returns:
success_response({success: true})
"""
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"OTP admin reset: employee_id={employee_id} by={current_user.employee_id}")
return success_response(data={"success": True})
# =============================================================================
# 6. GET /auth/otp-admin-users — 管理员查看全部坐席 OTP 绑定状态
# =============================================================================
@router.get("/otp-admin-users", response_model=None)
@require_role("admin")
async def admin_list_otp_users(
db: AsyncSession = Depends(get_db),
keyword: str = None,
bound: str = None,
page: int = 1,
page_size: int = 20,
):
"""管理员查看全部坐席的 OTP 绑定状态(支持搜索/过滤/分页)。
Query params:
keyword: 搜索姓名或 employee_id(模糊匹配)
bound: "true"=已绑定, "false"=未绑定, 空=全部
page: 页码(默认 1)
page_size: 每页条数(默认 20
Returns:
success_response({total, items: [{employee_id, name, mfa_enabled,
mfa_bound_at, mfa_last_verified_at}, ...]})
"""
# 构建查询
stmt = select(Agent)
# 搜索过滤
if keyword:
stmt = stmt.where(
Agent.user_id.ilike(f"%{keyword}%") |
Agent.name.ilike(f"%{keyword}%")
)
if bound == "true":
stmt = stmt.where(Agent.mfa_enabled == True)
elif bound == "false":
stmt = stmt.where(Agent.mfa_enabled == False)
# 先查总数
count_stmt = stmt.with_only_columns(func.count()).order_by(None)
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
# 分页
stmt = stmt.order_by(Agent.user_id).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
agents = result.scalars().all()
items = [
{
"employee_id": a.user_id,
"name": getattr(a, "name", "") or "",
"mfa_enabled": bool(a.mfa_enabled),
"mfa_bound_at": a.mfa_bound_at.isoformat() if a.mfa_bound_at else None,
"mfa_last_verified_at": (
a.mfa_last_verified_at.isoformat() if a.mfa_last_verified_at else None
),
}
for a in agents
]
return success_response(data={"total": total, "items": items})