bea288e414
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
445 lines
16 KiB
Python
445 lines
16 KiB
Python
# =============================================================================
|
||
# 企微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 对齐):
|
||
# - 复用 MFAService(pyotp + 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_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。
|
||
|
||
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_secret(mfa_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 保持 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"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})
|