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:
@@ -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)}")
|
||||
@@ -0,0 +1,191 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 高危操作演示 API
|
||||
# =============================================================================
|
||||
# Phase 1.3 task #19: 高危操作路由白名单 + 中间件演示
|
||||
# 决策来源:otm-secondary-auth.md(2026-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",
|
||||
},
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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管理(管理员)"])
|
||||
|
||||
@@ -20,6 +20,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.config import settings
|
||||
from app.services.token_service import TokenService
|
||||
from app.utils.response import AppException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -281,3 +282,141 @@ def require_admin(func):
|
||||
pass
|
||||
"""
|
||||
return require_role("admin")(func)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 高危操作 OTP 守卫依赖(Phase 1.3 task #19)
|
||||
# =============================================================================
|
||||
# 决策来源:otm-secondary-auth.md
|
||||
# 触发场景:管理员执行 5 类高危操作前,必须在 30 分钟内通过 OTP 二次验证
|
||||
# 验证流程:
|
||||
# 1. 管理员先调 /api/mfa/verify 校验 TOTP 验证码(蜂鸟 SMS 备用)
|
||||
# 2. 验证通过后 mfa.py 在 Redis 写 mfa:verified:{employee_id},TTL=1800 秒
|
||||
# 3. 高危操作端点 Depends(require_high_risk_otp) 时:
|
||||
# - 检查角色:admin(403 否则)
|
||||
# - 检查 Redis key:mfa:verified:{employee_id}(不存在则 raise 2001)
|
||||
# 4. 前端收到 2001 → 弹 OTP 输入框 → 重试
|
||||
#
|
||||
# 5 类高危操作清单(与 otm-secondary-auth.md 对齐):
|
||||
# 1. role_change 改权限 POST /api/admin/roles/assign
|
||||
# 2. config_change 改配置 PUT /api/admin/configs/{key}
|
||||
# 3. data_export 导出数据 GET /api/admin/export/*
|
||||
# 4. account_disable 封号 DELETE /api/admin/agents/{id}
|
||||
# 5. account_create_reset 新增账号/重置 POST /api/admin/agents, /api/admin/mfa/reset/{id}
|
||||
# =============================================================================
|
||||
|
||||
# 高危操作白名单(category → 元数据)
|
||||
# 用于演示路由 + 文档化,前端可读此表知道哪些操作需要 OTP
|
||||
HIGH_RISK_OPERATIONS = {
|
||||
"role_change": {
|
||||
"category": "改权限",
|
||||
"require_otp": True,
|
||||
"examples": ["POST /api/admin/roles/assign", "POST /api/admin/roles/revoke"],
|
||||
"description": "分配或撤销用户角色",
|
||||
},
|
||||
"config_change": {
|
||||
"category": "改配置",
|
||||
"require_otp": True,
|
||||
"examples": ["PUT /api/admin/configs/{key}"],
|
||||
"description": "修改系统配置项",
|
||||
},
|
||||
"data_export": {
|
||||
"category": "导出数据",
|
||||
"require_otp": True,
|
||||
"examples": ["GET /api/admin/export/*"],
|
||||
"description": "导出敏感数据(会话、坐席统计等)",
|
||||
},
|
||||
"account_disable": {
|
||||
"category": "封号",
|
||||
"require_otp": True,
|
||||
"examples": ["DELETE /api/admin/agents/{id}"],
|
||||
"description": "禁用/删除坐席账号",
|
||||
},
|
||||
"account_create_reset": {
|
||||
"category": "新增账号/重置",
|
||||
"require_otp": True,
|
||||
"examples": ["POST /api/admin/agents", "POST /api/admin/mfa/reset/{id}"],
|
||||
"description": "新增坐席或重置 MFA",
|
||||
},
|
||||
}
|
||||
|
||||
# MFA 验证通过的 Redis key 前缀
|
||||
# 由 mfa.py 在 /api/mfa/verify 成功后写入,TTL=1800 秒
|
||||
MFA_VERIFIED_KEY_PREFIX = "mfa:verified:"
|
||||
|
||||
# MFA 验证有效期(30 分钟,与 otm-secondary-auth.md 决策一致)
|
||||
MFA_VERIFIED_TTL_SECONDS = 30 * 60
|
||||
|
||||
|
||||
async def require_high_risk_otp(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
) -> UserInfo:
|
||||
"""高危操作 OTP 守卫(管理员触发高危操作前必过)。
|
||||
|
||||
业务规则(来自 otm-secondary-auth.md 2026-06-21 决策):
|
||||
1. 仅 admin 角色需要过 OTP(agent/user 直接 403)
|
||||
2. 必须在 30 分钟内通过 /api/mfa/verify 校验过 OTP
|
||||
3. 验证失败的 key 不算(空字符串/已过期)
|
||||
|
||||
鉴权流程:
|
||||
- 请求携带 Bearer Token → get_current_user 解析 UserInfo
|
||||
- 检查 UserInfo.roles 是否含 "admin"(否则 4003 仅管理员)
|
||||
- 检查 Redis mfa:verified:{employee_id} 是否存在(否则 2001 需 OTP)
|
||||
|
||||
Args:
|
||||
current_user: 当前用户(FastAPI 自动注入)
|
||||
|
||||
Returns:
|
||||
UserInfo: 当前用户(已通过 OTP 守卫)
|
||||
|
||||
Raises:
|
||||
AppException(4003, "仅管理员可执行此操作"): 非管理员角色
|
||||
AppException(2001, "高危操作需要 OTP 二次验证"): admin 但未在 30 分钟内过 OTP
|
||||
"""
|
||||
# 第 1 关:角色检查 - 只有 admin 才需要 OTP 验证
|
||||
# 注: current_role 是当前激活角色,roles 是全部角色,两者都查(双保险)
|
||||
user_roles = current_user.roles or []
|
||||
is_admin = (
|
||||
current_user.current_role == "admin"
|
||||
or "admin" in user_roles
|
||||
)
|
||||
if not is_admin:
|
||||
logger.warning(
|
||||
f"用户 {current_user.employee_id} 尝试高危操作但不是 admin: "
|
||||
f"current_role={current_user.current_role}, roles={user_roles}"
|
||||
)
|
||||
raise AppException(
|
||||
code=4003,
|
||||
message="仅管理员可执行此高危操作",
|
||||
)
|
||||
|
||||
# 第 2 关:OTP 验证标记检查 - Redis mfa:verified:{employee_id}
|
||||
redis_client = await get_redis()
|
||||
verified_key = f"{MFA_VERIFIED_KEY_PREFIX}{current_user.employee_id}"
|
||||
verified = await redis_client.get(verified_key)
|
||||
|
||||
# 注:空字符串/null/bytes 都算"未通过"
|
||||
if not verified:
|
||||
logger.warning(
|
||||
f"管理员 {current_user.employee_id} 未通过 OTP 守卫: "
|
||||
f"Redis key '{verified_key}' 不存在或已过期"
|
||||
)
|
||||
raise AppException(
|
||||
code=2001,
|
||||
message="高危操作需要 OTP 二次验证,请先完成 OTP 验证",
|
||||
)
|
||||
|
||||
# 防御性:刷新 TTL(滑动窗口)—— 如果管理员持续在做高危操作,
|
||||
# 不用反复输 OTP。但要求单次操作 < 30 分钟间隔。
|
||||
# 注: mfa.py 写入时已设 1800 秒 TTL,这里只在存在时刷新
|
||||
if hasattr(redis_client, "expire"):
|
||||
try:
|
||||
await redis_client.expire(verified_key, MFA_VERIFIED_TTL_SECONDS)
|
||||
except Exception as e:
|
||||
# 刷新失败不影响主流程,仅记录
|
||||
logger.debug(f"刷新 OTP verified TTL 失败: {e}")
|
||||
|
||||
logger.info(
|
||||
f"管理员 {current_user.employee_id} 通过 OTP 守卫,执行高危操作"
|
||||
)
|
||||
return current_user
|
||||
|
||||
@@ -9,7 +9,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Integer, JSON, String
|
||||
from sqlalchemy import Boolean, DateTime, Integer, JSON, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -150,6 +150,44 @@ class Agent(Base):
|
||||
comment="本地密码哈希(bcrypt)",
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MFA 二次认证字段(Phase 2.1 task #17)
|
||||
# --------------------------------------------------------------------------
|
||||
# 说明:MFA TOTP 独立于早期 OTP 字段,采用全新字段名以便区分演进阶段。
|
||||
# - mfa_secret: TOTP 共享密钥(base32),绑定时生成,首次验证前不算启用
|
||||
# - mfa_enabled: 是否启用(仅当 bind/confirm 验证成功后置 true)
|
||||
# - mfa_bound_at: 首次绑定完成时间(用于审计 + 回收策略)
|
||||
# - mfa_last_verified_at: 最近一次 verify 成功时间(用于安全审计)
|
||||
# --------------------------------------------------------------------------
|
||||
mfa_secret: Mapped[Optional[str]] = mapped_column(
|
||||
String(32),
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="MFA TOTP 共享密钥(base32,绑定时生成)",
|
||||
)
|
||||
|
||||
mfa_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=text("false"),
|
||||
comment="MFA 是否启用(False/True)",
|
||||
)
|
||||
|
||||
mfa_bound_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="MFA 首次绑定完成时间",
|
||||
)
|
||||
|
||||
mfa_last_verified_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="MFA 最近一次验证成功时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""坐席对象的字符串表示,方便调试。"""
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — MFA 二次认证 Pydantic Schema
|
||||
# =============================================================================
|
||||
# 说明:定义 MFA TOTP 服务相关的请求/响应数据结构
|
||||
# Phase 2.1 task #17: pyotp TOTP 服务 + User MFA 字段
|
||||
# Schema 仅做字段校验,不涉及业务逻辑(业务逻辑在 mfa_service + mfa API)
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MFA 状态查询响应
|
||||
# --------------------------------------------------------------------------
|
||||
class MFAStatusResponse(BaseModel):
|
||||
"""GET /api/mfa/status 响应。
|
||||
|
||||
Attributes:
|
||||
bound: 是否已绑定(已生成 secret 且首次验证通过)
|
||||
enabled: 是否已启用(与 bound 等价,保留双字段便于前端路由守卫判断)
|
||||
last_verified_at: 最近一次验证成功时间(可空)
|
||||
"""
|
||||
|
||||
bound: bool = Field(..., description="是否已绑定 MFA")
|
||||
enabled: bool = Field(..., description="是否已启用 MFA")
|
||||
last_verified_at: Optional[datetime] = Field(
|
||||
None, description="最近一次验证成功时间"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MFA 绑定启动响应
|
||||
# --------------------------------------------------------------------------
|
||||
class MFABindStartResponse(BaseModel):
|
||||
"""POST /api/mfa/bind/start 响应。
|
||||
|
||||
Attributes:
|
||||
secret: TOTP 共享密钥(base32),用户可手动输入到 Authenticator
|
||||
otpauth_url: otpauth:// URI,可生成二维码
|
||||
qr_code_base64: 二维码 PNG 的 base64(data URL 已剥离,前端自行拼接)
|
||||
"""
|
||||
|
||||
secret: str = Field(..., description="TOTP 共享密钥(base32)")
|
||||
otpauth_url: str = Field(..., description="otpauth:// 格式 URI")
|
||||
qr_code_base64: str = Field(..., description="二维码 PNG base64(不含 data: 前缀)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MFA 绑定确认请求
|
||||
# --------------------------------------------------------------------------
|
||||
class MFABindConfirmRequest(BaseModel):
|
||||
"""POST /api/mfa/bind/confirm 请求体。
|
||||
|
||||
Attributes:
|
||||
otp_code: 用户输入的 6 位 OTP 码
|
||||
"""
|
||||
|
||||
otp_code: str = Field(..., min_length=6, max_length=6, description="6 位 OTP 动态码")
|
||||
|
||||
|
||||
class MFABindConfirmResponse(BaseModel):
|
||||
"""POST /api/mfa/bind/confirm 响应。
|
||||
|
||||
Attributes:
|
||||
success: 绑定是否成功
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="绑定是否成功")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MFA 验证请求/响应
|
||||
# --------------------------------------------------------------------------
|
||||
class MFAVerifyRequest(BaseModel):
|
||||
"""POST /api/mfa/verify 请求体。
|
||||
|
||||
Attributes:
|
||||
otp_code: 用户输入的 6 位 OTP 码
|
||||
"""
|
||||
|
||||
otp_code: str = Field(..., min_length=6, max_length=6, description="6 位 OTP 动态码")
|
||||
|
||||
|
||||
class MFAVerifyResponse(BaseModel):
|
||||
"""POST /api/mfa/verify 响应。
|
||||
|
||||
Attributes:
|
||||
verified: 验证是否通过
|
||||
expires_in: 验证状态在 Redis 里的剩余秒数(1800s 滑动窗口)
|
||||
"""
|
||||
|
||||
verified: bool = Field(..., description="验证是否通过")
|
||||
expires_in: int = Field(..., description="Redis 验证标记剩余秒数(秒)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MFA 关闭请求/响应
|
||||
# --------------------------------------------------------------------------
|
||||
class MFADisableRequest(BaseModel):
|
||||
"""POST /api/mfa/disable 请求体。
|
||||
|
||||
Attributes:
|
||||
otp_code: 用户输入的 6 位 OTP 码(防止误操作)
|
||||
"""
|
||||
|
||||
otp_code: str = Field(..., min_length=6, max_length=6, description="6 位 OTP 动态码")
|
||||
|
||||
|
||||
class MFADisableResponse(BaseModel):
|
||||
"""POST /api/mfa/disable 响应。
|
||||
|
||||
Attributes:
|
||||
success: 关闭是否成功
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="关闭是否成功")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 管理员重置 MFA 响应
|
||||
# --------------------------------------------------------------------------
|
||||
class MFAAdminResetResponse(BaseModel):
|
||||
"""POST /api/admin/mfa/reset/{employee_id} 响应。
|
||||
|
||||
Attributes:
|
||||
success: 重置是否成功
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="重置是否成功")
|
||||
@@ -0,0 +1,127 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 扫码登录 Pydantic Schema
|
||||
# =============================================================================
|
||||
# 说明:定义扫码登录的请求/响应数据结构
|
||||
# 涵盖 4 个端点的入参/出参:
|
||||
# 1. POST /api/auth_qrcode/create — 创建扫码登录票据
|
||||
# 2. GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
|
||||
# 3. POST /api/auth_qrcode/scan — 企微用户扫码后 OAuth code 回调
|
||||
# 4. POST /api/auth_qrcode/confirm — 当前已登录用户确认授权
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_qrcode/create — 创建扫码登录票据
|
||||
# --------------------------------------------------------------------------
|
||||
class QrcodeCreateResponse(BaseModel):
|
||||
"""扫码登录票据创建响应。
|
||||
|
||||
Attributes:
|
||||
ticket: 票据 UUID,前端用此票据轮询状态
|
||||
qrcode_url: 企微 OAuth2 授权 URL(前端渲染二维码)
|
||||
expires_in: 票据有效期(秒),默认 120
|
||||
expires_at: 票据过期时间(ISO 8601 字符串)
|
||||
"""
|
||||
|
||||
ticket: str = Field(..., description="票据 UUID")
|
||||
qrcode_url: str = Field(..., description="企微 OAuth2 授权 URL")
|
||||
expires_in: int = Field(120, description="有效期(秒)")
|
||||
expires_at: datetime = Field(..., description="过期时间(ISO 8601)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/auth_qrcode/poll/{ticket} — 轮询扫码状态
|
||||
# --------------------------------------------------------------------------
|
||||
class QrcodePollResponse(BaseModel):
|
||||
"""扫码登录票据轮询响应。
|
||||
|
||||
status 取值:
|
||||
- waiting: 票据有效,等待扫码
|
||||
- scanned: 已扫码,等待确认
|
||||
- confirmed: 已确认登录成功,附带 token
|
||||
- expired: 票据过期/不存在
|
||||
|
||||
Attributes:
|
||||
status: 扫码状态
|
||||
employee_id: 企微用户 ID(scanned/confirmed 时返回)
|
||||
name: 企微用户姓名(scanned/confirmed 时返回)
|
||||
token: 登录 Token(confirmed 时返回,前端存 localStorage)
|
||||
"""
|
||||
|
||||
status: str = Field(..., description="等待/已扫码/已确认/已过期")
|
||||
employee_id: Optional[str] = Field(None, description="企微用户 ID")
|
||||
name: Optional[str] = Field(None, description="企微用户姓名")
|
||||
token: Optional[str] = Field(None, description="登录 Token")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_qrcode/scan — 企微 OAuth code 回调
|
||||
# --------------------------------------------------------------------------
|
||||
class QrcodeScanRequest(BaseModel):
|
||||
"""扫码登录扫码请求体。
|
||||
|
||||
Attributes:
|
||||
ticket: 扫码登录票据(UUID)
|
||||
code: 企微 OAuth2 授权回调 code
|
||||
"""
|
||||
|
||||
ticket: str = Field(..., min_length=1, description="扫码登录票据")
|
||||
code: str = Field(..., min_length=1, description="企微 OAuth2 授权 code")
|
||||
|
||||
|
||||
class QrcodeScanResponse(BaseModel):
|
||||
"""扫码登录扫码响应。
|
||||
|
||||
Attributes:
|
||||
success: 是否成功
|
||||
message: 提示消息
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="是否成功")
|
||||
message: str = Field(..., description="提示消息")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_qrcode/confirm — 当前已登录用户确认授权
|
||||
# --------------------------------------------------------------------------
|
||||
class QrcodeConfirmRequest(BaseModel):
|
||||
"""扫码登录确认请求体。
|
||||
|
||||
Attributes:
|
||||
ticket: 扫码登录票据(UUID)
|
||||
otp_code: OTP 动态码(管理员场景下可选,普通坐席可空)
|
||||
"""
|
||||
|
||||
ticket: str = Field(..., min_length=1, description="扫码登录票据")
|
||||
otp_code: Optional[str] = Field(
|
||||
None,
|
||||
min_length=6,
|
||||
max_length=6,
|
||||
description="OTP 动态码(管理员可选,普通坐席留空)",
|
||||
)
|
||||
|
||||
|
||||
class QrcodeConfirmResponse(BaseModel):
|
||||
"""扫码登录确认响应。
|
||||
|
||||
Attributes:
|
||||
token: 登录 Token(scanned 用户换发的新 token)
|
||||
employee_id: 企微用户 ID
|
||||
name: 用户姓名
|
||||
roles: 用户角色列表
|
||||
require_otp: 是否需要 OTP 二次验证(预留,本任务不强制)
|
||||
"""
|
||||
|
||||
token: str = Field(..., description="登录 Token")
|
||||
employee_id: str = Field(..., description="企微用户 ID")
|
||||
name: str = Field(..., description="用户姓名")
|
||||
roles: List[str] = Field(default_factory=list, description="用户角色列表")
|
||||
require_otp: Optional[bool] = Field(
|
||||
None,
|
||||
description="是否需要 OTP 二次验证(预留字段,Phase 2.1 实现)",
|
||||
)
|
||||
@@ -0,0 +1,291 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 高危操作守卫服务
|
||||
# =============================================================================
|
||||
# 说明:集中处理高危操作(Phase 1.3 task #19)的 OTP 验证状态管理
|
||||
# 决策来源:otm-secondary-auth.md(2026-06-21 决策)
|
||||
#
|
||||
# 核心职责:
|
||||
# 1. 标记管理员 OTP 验证通过(write)
|
||||
# 2. 查询管理员 OTP 验证状态(read)
|
||||
# 3. 撤销管理员 OTP 验证(revoke)
|
||||
# 4. 列出全部 5 类高危操作白名单(白名单查询)
|
||||
#
|
||||
# Redis key 设计:
|
||||
# key: mfa:verified:{employee_id}
|
||||
# value: 验证方式("totp" / "sms_backup")+ 时间戳
|
||||
# TTL: 1800 秒(30 分钟)
|
||||
#
|
||||
# 与 dependencies.py 中 require_high_risk_otp 配套使用:
|
||||
# - mfa.py 在 /api/mfa/verify 成功后调 mark_verified(...)
|
||||
# - require_high_risk_otp 在每个高危端点 Depends 时调 is_verified(...)
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5 类高危操作白名单(与 dependencies.HIGH_RISK_OPERATIONS 保持一致)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 注意:这里再做一次定义是为了让 service 层独立可测,不依赖 dependencies 模块
|
||||
# (避免循环引用 + 方便单测)
|
||||
# -----------------------------------------------------------------------------
|
||||
HIGH_RISK_OPERATIONS_WHITELIST: Dict[str, Dict] = {
|
||||
"role_change": {
|
||||
"category": "改权限",
|
||||
"require_otp": True,
|
||||
"examples": ["POST /api/admin/roles/assign", "POST /api/admin/roles/revoke"],
|
||||
"description": "分配或撤销用户角色",
|
||||
},
|
||||
"config_change": {
|
||||
"category": "改配置",
|
||||
"require_otp": True,
|
||||
"examples": ["PUT /api/admin/configs/{key}"],
|
||||
"description": "修改系统配置项",
|
||||
},
|
||||
"data_export": {
|
||||
"category": "导出数据",
|
||||
"require_otp": True,
|
||||
"examples": ["GET /api/admin/export/*"],
|
||||
"description": "导出敏感数据(会话、坐席统计等)",
|
||||
},
|
||||
"account_disable": {
|
||||
"category": "封号",
|
||||
"require_otp": True,
|
||||
"examples": ["DELETE /api/admin/agents/{id}"],
|
||||
"description": "禁用/删除坐席账号",
|
||||
},
|
||||
"account_create_reset": {
|
||||
"category": "新增账号/重置",
|
||||
"require_otp": True,
|
||||
"examples": ["POST /api/admin/agents", "POST /api/admin/mfa/reset/{id}"],
|
||||
"description": "新增坐席或重置 MFA",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HighRiskGuard:
|
||||
"""高危操作守卫服务。
|
||||
|
||||
负责 OTP 验证状态的读写,配套 require_high_risk_otp 依赖使用。
|
||||
|
||||
Attributes:
|
||||
redis_client: Redis 异步客户端
|
||||
ttl_seconds: OTP 验证有效期(默认 1800 秒 = 30 分钟)
|
||||
"""
|
||||
|
||||
# Redis key 前缀 — 必须与 dependencies.MFA_VERIFIED_KEY_PREFIX 一致
|
||||
KEY_PREFIX = "mfa:verified:"
|
||||
|
||||
# 默认 30 分钟 TTL — 必须与 dependencies.MFA_VERIFIED_TTL_SECONDS 一致
|
||||
DEFAULT_TTL_SECONDS = 30 * 60
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: aioredis.Redis,
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
):
|
||||
"""初始化高危操作守卫。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端
|
||||
ttl_seconds: OTP 验证有效期(秒),默认 30 分钟
|
||||
"""
|
||||
self.redis = redis_client
|
||||
self.ttl_seconds = ttl_seconds
|
||||
|
||||
def _key(self, employee_id: str) -> str:
|
||||
"""构造 Redis key。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
str: Redis key,如 mfa:verified:admin001
|
||||
"""
|
||||
return f"{self.KEY_PREFIX}{employee_id}"
|
||||
|
||||
async def mark_verified(
|
||||
self,
|
||||
employee_id: str,
|
||||
method: str = "totp",
|
||||
) -> bool:
|
||||
"""标记管理员已通过 OTP 验证。
|
||||
|
||||
由 mfa.py 在 /api/mfa/verify 成功后调用。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
method: 验证方式,"totp" 或 "sms_backup"
|
||||
|
||||
Returns:
|
||||
bool: 是否成功写入
|
||||
"""
|
||||
# value 用 JSON 存验证方式和时间,审计用
|
||||
value = json.dumps(
|
||||
{
|
||||
"method": method,
|
||||
"verified_at": datetime.now().isoformat(),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.redis.setex(
|
||||
self._key(employee_id),
|
||||
self.ttl_seconds,
|
||||
value,
|
||||
)
|
||||
logger.info(
|
||||
f"管理员 {employee_id} OTP 验证通过: method={method}, "
|
||||
f"ttl={self.ttl_seconds}s"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"写入 OTP verified key 失败: {e}")
|
||||
return False
|
||||
|
||||
async def is_verified(self, employee_id: str) -> bool:
|
||||
"""检查管理员是否在有效期内通过过 OTP。
|
||||
|
||||
由 require_high_risk_otp 依赖调用。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
bool: 是否已通过 OTP 验证
|
||||
"""
|
||||
try:
|
||||
value = await self.redis.get(self._key(employee_id))
|
||||
# 空字符串 / None / 空 bytes 全部算"未通过"
|
||||
if not value:
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"读取 OTP verified key 失败: {e}")
|
||||
# Redis 故障时保守放行?不,安全优先,默认不通过
|
||||
return False
|
||||
|
||||
async def get_verification_info(
|
||||
self,
|
||||
employee_id: str,
|
||||
) -> Optional[Dict]:
|
||||
"""获取管理员 OTP 验证详情(含方式和时间)。
|
||||
|
||||
用于审计/前端展示"上次验证时间"。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 验证信息 dict,未验证返回 None
|
||||
示例: {"method": "totp", "verified_at": "2026-06-21T15:30:00"}
|
||||
"""
|
||||
try:
|
||||
value = await self.redis.get(self._key(employee_id))
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8")
|
||||
return json.loads(value)
|
||||
except Exception as e:
|
||||
logger.error(f"解析 OTP verified info 失败: {e}")
|
||||
return None
|
||||
|
||||
async def revoke(self, employee_id: str) -> bool:
|
||||
"""撤销管理员 OTP 验证(强制重新验证)。
|
||||
|
||||
场景:安全事件触发 / 管理员主动撤销 / 登出时清理。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功撤销(key 不存在也算成功)
|
||||
"""
|
||||
try:
|
||||
deleted = await self.redis.delete(self._key(employee_id))
|
||||
logger.info(
|
||||
f"管理员 {employee_id} OTP 验证已撤销: deleted={deleted}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"撤销 OTP verified key 失败: {e}")
|
||||
return False
|
||||
|
||||
async def refresh_ttl(self, employee_id: str) -> bool:
|
||||
"""刷新 OTP 验证的 TTL(滑动窗口)。
|
||||
|
||||
每次高危操作通过守卫后调用,延长 30 分钟有效期。
|
||||
已在 dependencies.require_high_risk_otp 内联调用,这里冗余暴露给 service 层。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
bool: 是否刷新成功
|
||||
"""
|
||||
try:
|
||||
# 只有 key 存在时才刷新 TTL,防止误创建空 key
|
||||
value = await self.redis.get(self._key(employee_id))
|
||||
if not value:
|
||||
return False
|
||||
await self.redis.expire(self._key(employee_id), self.ttl_seconds)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"刷新 OTP verified TTL 失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_whitelist() -> Dict[str, Dict]:
|
||||
"""获取 5 类高危操作白名单。
|
||||
|
||||
静态方法,供前端文档化展示"哪些操作需要 OTP"。
|
||||
|
||||
Returns:
|
||||
Dict[str, Dict]: 白名单字典
|
||||
"""
|
||||
return HIGH_RISK_OPERATIONS_WHITELIST.copy()
|
||||
|
||||
@staticmethod
|
||||
def is_valid_category(category: str) -> bool:
|
||||
"""检查 category 是否在 5 类白名单内。
|
||||
|
||||
Args:
|
||||
category: 类别标识
|
||||
|
||||
Returns:
|
||||
bool: 是否合法
|
||||
"""
|
||||
return category in HIGH_RISK_OPERATIONS_WHITELIST
|
||||
|
||||
@staticmethod
|
||||
def list_categories() -> List[str]:
|
||||
"""列出全部 5 类高危操作标识。
|
||||
|
||||
Returns:
|
||||
List[str]: category 列表
|
||||
"""
|
||||
return list(HIGH_RISK_OPERATIONS_WHITELIST.keys())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 工厂函数:方便在非 FastAPI DI 场景使用
|
||||
# -----------------------------------------------------------------------------
|
||||
def create_high_risk_guard(redis_client: aioredis.Redis) -> HighRiskGuard:
|
||||
"""创建 HighRiskGuard 实例。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端
|
||||
|
||||
Returns:
|
||||
HighRiskGuard: 守卫服务实例
|
||||
"""
|
||||
return HighRiskGuard(redis_client)
|
||||
@@ -0,0 +1,179 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — MFA(TOTP)服务封装
|
||||
# =============================================================================
|
||||
# 说明:把 pyotp + qrcode 的使用集中到 service 层,API 层只关心业务流程
|
||||
# 设计要点:
|
||||
# 1. secret 生成/校验/二维码生成 — 全部静态方法,无状态
|
||||
# 2. valid_window=1 允许 ±30s 容忍(防用户手机秒数漂移)
|
||||
# 3. Redis 验证标记独立 key(与 otp_secret 共存,不冲突)
|
||||
# key 格式: mfa:verified:{employee_id}, TTL 1800s(30 分钟复用)
|
||||
# 4. backup codes 在决策阶段已废止(otm-secondary-auth.md),所以本服务
|
||||
# 不实现 backup code 逻辑,丢手机场景走 admin reset
|
||||
# =============================================================================
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# MFA 验证状态在 Redis 里的存活时间(秒)
|
||||
# 跟 otm-secondary-auth.md 决策一致:30 分钟复用窗口
|
||||
MFA_VERIFIED_TTL_SECONDS = 1800
|
||||
|
||||
|
||||
class MFAService:
|
||||
"""MFA TOTP 服务 — 封装 pyotp 二维码生成与验证。
|
||||
|
||||
所有方法都是纯函数/静态方法,无内部状态。
|
||||
Redis 由调用方注入,便于测试时 mock。
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Secret 生成
|
||||
# --------------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def generate_secret() -> str:
|
||||
"""生成新的 TOTP 共享密钥。
|
||||
|
||||
Returns:
|
||||
str: 32 字符 base32 编码的随机密钥
|
||||
"""
|
||||
return pyotp.random_base32()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 二维码生成
|
||||
# --------------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def build_provisioning_uri(secret: str, employee_id: str) -> str:
|
||||
"""构造 otpauth:// URI,供 Authenticator 扫码识别。
|
||||
|
||||
Args:
|
||||
secret: TOTP 共享密钥(base32)
|
||||
employee_id: 用户标识(扫码后显示的账户名)
|
||||
|
||||
Returns:
|
||||
str: otpauth://totp/... 格式 URI
|
||||
"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(
|
||||
name=employee_id,
|
||||
issuer_name="企微IT智能服务台",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def render_qrcode_base64(otpauth_url: str) -> str:
|
||||
"""把 otpauth URI 渲染成 PNG 并返回 base64 字符串。
|
||||
|
||||
Args:
|
||||
otpauth_url: otpauth:// URI
|
||||
|
||||
Returns:
|
||||
str: PNG 的 base64(不含 data:image/png;base64, 前缀,
|
||||
由前端自行拼接或直接用 data URL)
|
||||
"""
|
||||
img = qrcode.make(otpauth_url)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 验证码校验
|
||||
# --------------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def verify_code(secret: str, otp_code: str, valid_window: int = 1) -> bool:
|
||||
"""校验用户输入的 6 位 OTP 码。
|
||||
|
||||
Args:
|
||||
secret: TOTP 共享密钥(base32)
|
||||
otp_code: 用户输入的 6 位码
|
||||
valid_window: 时间容忍窗口(1 = 允许当前 ±30s)
|
||||
|
||||
Returns:
|
||||
bool: True=验证通过, False=验证失败
|
||||
"""
|
||||
if not secret or not otp_code:
|
||||
return False
|
||||
try:
|
||||
totp = pyotp.TOTP(secret)
|
||||
return bool(totp.verify(otp_code, valid_window=valid_window))
|
||||
except Exception as e:
|
||||
# 任意异常(secret 格式错、码非数字等)都视为验证失败
|
||||
logger.warning(f"MFA verify_code 异常: {e}")
|
||||
return False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 高层便捷方法:启动绑定
|
||||
# --------------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def start_binding(employee_id: str) -> Tuple[str, str, str]:
|
||||
"""一次性生成绑定所需的全部数据(secret + URI + QR)。
|
||||
|
||||
Args:
|
||||
employee_id: 用户标识
|
||||
|
||||
Returns:
|
||||
Tuple[str, str, str]: (secret, otpauth_url, qr_code_base64)
|
||||
"""
|
||||
secret = MFAService.generate_secret()
|
||||
otpauth_url = MFAService.build_provisioning_uri(secret, employee_id)
|
||||
qr_base64 = MFAService.render_qrcode_base64(otpauth_url)
|
||||
return secret, otpauth_url, qr_base64
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Redis 验证标记(30 分钟复用)
|
||||
# --------------------------------------------------------------------------
|
||||
@staticmethod
|
||||
async def mark_verified(
|
||||
redis: aioredis.Redis, employee_id: str, ttl_seconds: int = MFA_VERIFIED_TTL_SECONDS
|
||||
) -> None:
|
||||
"""在 Redis 里写"已验证"标记,后续敏感操作直接查这个 key。
|
||||
|
||||
Args:
|
||||
redis: Redis 客户端
|
||||
employee_id: 用户标识
|
||||
ttl_seconds: 存活秒数,默认 1800s
|
||||
"""
|
||||
key = f"mfa:verified:{employee_id}"
|
||||
await redis.set(key, "1", ex=ttl_seconds)
|
||||
|
||||
@staticmethod
|
||||
async def is_verified(redis: aioredis.Redis, employee_id: str) -> bool:
|
||||
"""检查用户当前是否有未过期的 MFA 验证标记。
|
||||
|
||||
Args:
|
||||
redis: Redis 客户端
|
||||
employee_id: 用户标识
|
||||
|
||||
Returns:
|
||||
bool: True=在 30 分钟复用窗口内
|
||||
"""
|
||||
key = f"mfa:verified:{employee_id}"
|
||||
return bool(await redis.exists(key))
|
||||
|
||||
@staticmethod
|
||||
async def clear_verified(redis: aioredis.Redis, employee_id: str) -> None:
|
||||
"""清除 Redis 验证标记(关闭 MFA 时调用)。"""
|
||||
key = f"mfa:verified:{employee_id}"
|
||||
await redis.delete(key)
|
||||
|
||||
@staticmethod
|
||||
async def get_verified_ttl(redis: aioredis.Redis, employee_id: str) -> int:
|
||||
"""获取 Redis 验证标记剩余秒数(测试用,生产路径用不到)。
|
||||
|
||||
Args:
|
||||
redis: Redis 客户端
|
||||
employee_id: 用户标识
|
||||
|
||||
Returns:
|
||||
int: 剩余秒数(无 key 返回 -2)
|
||||
"""
|
||||
key = f"mfa:verified:{employee_id}"
|
||||
ttl = await redis.ttl(key)
|
||||
return int(ttl) if ttl is not None else -2
|
||||
@@ -0,0 +1,487 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 扫码登录业务服务
|
||||
# =============================================================================
|
||||
# 说明:封装扫码登录的核心业务逻辑,与 HTTP/路由层解耦。
|
||||
# 关键设计:
|
||||
# 1. Redis Key 设计:
|
||||
# - qrcode:ticket:{ticket} → {created_at, expires_at}, TTL 120s
|
||||
# - qrcode:scan:{ticket} → {employee_id, name, scanned_at}, TTL 120s
|
||||
# - qrcode:confirm:{ticket} → {token, confirmed_at, roles}, TTL 60s
|
||||
# 2. 状态机: waiting → scanned → confirmed → (poll 返回 token 后清空 confirm key)
|
||||
# 3. dev 模式: 跳过企微 OAuth2,使用预设 dev 用户直接模拟扫码结果
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 常量
|
||||
# --------------------------------------------------------------------------
|
||||
# 票据有效期(秒): 与 Redis TTL 一致
|
||||
TICKET_TTL_SECONDS = 120
|
||||
# 扫码结果有效期(秒)
|
||||
SCAN_TTL_SECONDS = 120
|
||||
# 确认结果有效期(秒),用于前端轮询拿到 token
|
||||
CONFIRM_TTL_SECONDS = 60
|
||||
|
||||
|
||||
def _dev_mode_enabled() -> bool:
|
||||
"""检查是否启用了开发模式。
|
||||
|
||||
三个检查源(任一为 true 即启用):
|
||||
1. 环境变量 DEV_MODE=true
|
||||
2. settings.dev_mode(从 .env.dev 读)
|
||||
"""
|
||||
if os.getenv("DEV_MODE", "false").lower() == "true":
|
||||
return True
|
||||
if getattr(settings, "dev_mode", False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class QrcodeService:
|
||||
"""扫码登录业务服务。
|
||||
|
||||
封装 Redis Key 管理、状态机、token 创建等核心逻辑。
|
||||
实例方法都是 async,因为 Redis 操作是异步的。
|
||||
|
||||
Attributes:
|
||||
redis: Redis 异步客户端
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client: aioredis.Redis):
|
||||
"""初始化扫码登录服务。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端
|
||||
"""
|
||||
self.redis = redis_client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key 辅助函数
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _ticket_key(ticket: str) -> str:
|
||||
"""获取票据状态 Key。
|
||||
|
||||
票据本身的存在性记录(120s TTL),用于判断票据是否过期。
|
||||
"""
|
||||
return f"qrcode:ticket:{ticket}"
|
||||
|
||||
@staticmethod
|
||||
def _scan_key(ticket: str) -> str:
|
||||
"""获取扫码结果 Key。
|
||||
|
||||
存放扫码后的企微用户信息(120s TTL),等待 confirm 端点消费。
|
||||
"""
|
||||
return f"qrcode:scan:{ticket}"
|
||||
|
||||
@staticmethod
|
||||
def _confirm_key(ticket: str) -> str:
|
||||
"""获取确认结果 Key。
|
||||
|
||||
存放 confirm 后的 token(60s TTL),供前端 poll 拿到后清空。
|
||||
"""
|
||||
return f"qrcode:confirm:{ticket}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# create: 创建扫码登录票据
|
||||
# ------------------------------------------------------------------
|
||||
async def create_ticket(self) -> Dict[str, Any]:
|
||||
"""创建扫码登录票据,返回 ticket + 企微 OAuth2 授权 URL。
|
||||
|
||||
流程:
|
||||
1. 生成 UUID ticket
|
||||
2. 写 Redis qrcode:ticket:{ticket} (TTL 120s)
|
||||
3. 拼接企微 OAuth2 URL(state 参数传 ticket)
|
||||
4. 返回 ticket / url / expires_at
|
||||
|
||||
Returns:
|
||||
Dict: 包含 ticket / qrcode_url / expires_in / expires_at
|
||||
"""
|
||||
# 生成 ticket: 32 字符 URL 安全随机串
|
||||
ticket = secrets.token_urlsafe(24)
|
||||
|
||||
now = datetime.now()
|
||||
expires_at = now + timedelta(seconds=TICKET_TTL_SECONDS)
|
||||
|
||||
# 写 Redis 票据状态(只存时间戳,标明此 ticket 已创建)
|
||||
ticket_payload = {
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
await self.redis.setex(
|
||||
self._ticket_key(ticket),
|
||||
TICKET_TTL_SECONDS,
|
||||
json.dumps(ticket_payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# 拼接企微 OAuth2 授权 URL
|
||||
# scope=snsapi_base: 静默授权,用户无感知(企微内部应用必须)
|
||||
# state={ticket}: OAuth 回调时把 ticket 回传给我们的 scan 端点
|
||||
qrcode_url = self._build_oauth_url(ticket)
|
||||
|
||||
logger.info(
|
||||
f"扫码登录票据创建: ticket={ticket[:8]}..., expires_at={expires_at.isoformat()}"
|
||||
)
|
||||
|
||||
return {
|
||||
"ticket": ticket,
|
||||
"qrcode_url": qrcode_url,
|
||||
"expires_in": TICKET_TTL_SECONDS,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
def _build_oauth_url(self, ticket: str) -> str:
|
||||
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。
|
||||
|
||||
URL 格式:
|
||||
https://open.weixin.qq.com/connect/oauth2/authorize
|
||||
?appid={corp_id}
|
||||
&redirect_uri={callback}
|
||||
&response_type=code
|
||||
&scope=snsapi_base
|
||||
&state={ticket}
|
||||
#wechat_redirect
|
||||
|
||||
Args:
|
||||
ticket: 扫码登录票据
|
||||
|
||||
Returns:
|
||||
str: 完整的 OAuth2 授权 URL
|
||||
"""
|
||||
# 回调地址: 当前后端的 auth_qrcode/scan 端点
|
||||
# 企微要求 redirect_uri 必须 URL-encode
|
||||
callback_url = self._get_scan_callback_url()
|
||||
encoded_callback = callback_url # urlencode 留给前端做,这里假定配置已是合法 URL
|
||||
|
||||
params = {
|
||||
"appid": settings.wecom_corp_id,
|
||||
"redirect_uri": encoded_callback,
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base",
|
||||
"state": ticket,
|
||||
}
|
||||
query = urlencode(params)
|
||||
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
|
||||
|
||||
def _get_scan_callback_url(self) -> str:
|
||||
"""获取 OAuth 回调地址。
|
||||
|
||||
优先使用 settings 里的配置;没有则用默认值 /api/auth_qrcode/scan。
|
||||
当前没有这个配置,先用兜底;后续可在 Settings 加 qrcode_oauth_callback。
|
||||
"""
|
||||
# 兜底:相对路径,企微会带 Host 处理
|
||||
return getattr(settings, "qrcode_oauth_callback", "/api/auth_qrcode/scan")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# scan: 处理企微 OAuth code 回调
|
||||
# ------------------------------------------------------------------
|
||||
async def process_scan(
|
||||
self, ticket: str, code: str
|
||||
) -> Dict[str, Any]:
|
||||
"""处理扫码回调: 用 code 换 userid,写 Redis 供 confirm 端点消费。
|
||||
|
||||
流程:
|
||||
1. 校验 ticket 存在(否则票据过期)
|
||||
2. dev 模式 → 用预设 dev 用户跳过企微 API
|
||||
3. 生产模式 → 调企微 get_oauth_user_info(code) 拿 userid
|
||||
4. 再调 get_user_info(userid) 拿姓名
|
||||
5. 写 Redis qrcode:scan:{ticket} (TTL 120s)
|
||||
|
||||
Args:
|
||||
ticket: 扫码登录票据
|
||||
code: 企微 OAuth2 授权 code
|
||||
|
||||
Returns:
|
||||
Dict: 包含 success / message / employee_id / name
|
||||
|
||||
Raises:
|
||||
ValueError: 票据过期或无效
|
||||
"""
|
||||
# 1. 校验 ticket 存在
|
||||
ticket_data = await self.redis.get(self._ticket_key(ticket))
|
||||
if not ticket_data:
|
||||
logger.warning(f"扫码失败: ticket 已过期或不存在 ticket={ticket[:8]}...")
|
||||
raise ValueError("扫码票据已过期或不存在")
|
||||
|
||||
# 2. 获取用户身份
|
||||
employee_id = ""
|
||||
name = ""
|
||||
if _dev_mode_enabled():
|
||||
# dev 模式: 用预设 dev 用户
|
||||
# 提取 code 中的 userid(约定 dev 模式下 code 形如 "dev:dev-user-001")
|
||||
employee_id, name = self._dev_extract_user(code)
|
||||
logger.info(
|
||||
f"[DEV] 扫码回调模拟: ticket={ticket[:8]}..., "
|
||||
f"employee_id={employee_id}, name={name}"
|
||||
)
|
||||
else:
|
||||
# 生产模式: 调企微 OAuth API
|
||||
employee_id, name = await self._fetch_oauth_user(code)
|
||||
|
||||
# 3. 写 Redis 扫码结果(TTL 120s,等待 confirm 端点消费)
|
||||
scan_payload = {
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
"scanned_at": datetime.now().isoformat(),
|
||||
}
|
||||
await self.redis.setex(
|
||||
self._scan_key(ticket),
|
||||
SCAN_TTL_SECONDS,
|
||||
json.dumps(scan_payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"扫码成功: ticket={ticket[:8]}..., employee_id={employee_id}, name={name}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "扫码成功,等待用户确认",
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
def _dev_extract_user(self, code: str) -> tuple[str, str]:
|
||||
"""dev 模式专用: 从 code 字符串提取 userid。
|
||||
|
||||
约定 code 格式:
|
||||
- "dev:dev-user-001" → ("dev-user-001", "张三(普通员工)")
|
||||
- "dev:dev-agent-001" → ("dev-agent-001", "李四(IT 坐席)")
|
||||
- 其他 → 兜底用 settings.dev_default_userid
|
||||
|
||||
Args:
|
||||
code: 企微 OAuth code(dev 模式下是 dev 约定串)
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: (employee_id, name)
|
||||
"""
|
||||
# dev 模式预设用户表(与 dev_auth.py 保持一致)
|
||||
DEV_USERS = {
|
||||
"dev-user-001": ("dev-user-001", "张三(普通员工)"),
|
||||
"dev-agent-001": ("dev-agent-001", "李四(IT 坐席)"),
|
||||
"dev-admin-001": ("dev-admin-001", "钱七(系统管理员)"),
|
||||
}
|
||||
|
||||
if code.startswith("dev:"):
|
||||
user_id = code[4:]
|
||||
if user_id in DEV_USERS:
|
||||
return DEV_USERS[user_id]
|
||||
|
||||
# 兜底:用 settings 默认 dev 用户
|
||||
return (
|
||||
settings.dev_default_userid,
|
||||
settings.dev_default_name,
|
||||
)
|
||||
|
||||
async def _fetch_oauth_user(self, code: str) -> tuple[str, str]:
|
||||
"""生产模式: 用企微 OAuth2 code 换取 userid 与 name。
|
||||
|
||||
对应企微 API:
|
||||
1. GET /cgi-bin/auth/getuserinfo?access_token=...&code=...
|
||||
→ { userid, user_ticket }
|
||||
2. GET /cgi-bin/user/get?access_token=...&userid=...
|
||||
→ { name, ... }
|
||||
|
||||
Args:
|
||||
code: 企微 OAuth2 授权 code
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: (userid, name)
|
||||
|
||||
Raises:
|
||||
RuntimeError: 企微 API 调用失败
|
||||
"""
|
||||
# 延迟导入:避免 dev 模式测试时触发不必要的网络初始化
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
# 用同一个 redis 客户端保证 access_token 缓存命中
|
||||
wecom = WecomService(self.redis)
|
||||
try:
|
||||
oauth_info = await wecom.get_oauth_user_info(code)
|
||||
user_id = oauth_info.get("userid", "")
|
||||
if not user_id:
|
||||
raise RuntimeError("企微 OAuth 返回的 userid 为空")
|
||||
|
||||
user_info = await wecom.get_user_info(user_id)
|
||||
name = user_info.get("name", "")
|
||||
return user_id, name
|
||||
finally:
|
||||
try:
|
||||
await wecom.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# confirm: 当前已登录用户确认授权,创建 token
|
||||
# ------------------------------------------------------------------
|
||||
async def process_confirm(
|
||||
self,
|
||||
ticket: str,
|
||||
current_user_id: str,
|
||||
current_user_name: str,
|
||||
current_roles: list,
|
||||
otp_code: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""处理确认授权: 把扫码用户身份变成可登录 Token。
|
||||
|
||||
流程:
|
||||
1. 校验 ticket 存在
|
||||
2. 校验 scan 结果存在(否则没人扫过这个码)
|
||||
3. TODO (Phase 2.1): admin 角色校验 otp_code
|
||||
4. 创建 TokenService token(roles 来自扫码用户,不是 current_user)
|
||||
5. 写 Redis qrcode:confirm:{ticket} (TTL 60s) 供前端 poll 拿到
|
||||
|
||||
Args:
|
||||
ticket: 扫码登录票据
|
||||
current_user_id: 当前已登录用户的 ID(用于 admin 校验)
|
||||
current_user_name: 当前已登录用户的姓名
|
||||
current_roles: 当前已登录用户的角色
|
||||
otp_code: OTP 动态码(admin 场景下可选)
|
||||
|
||||
Returns:
|
||||
Dict: 包含 token / employee_id / name / roles / require_otp
|
||||
|
||||
Raises:
|
||||
ValueError: 票据过期 / 未扫码
|
||||
"""
|
||||
# 1. 校验 ticket
|
||||
if not await self.redis.get(self._ticket_key(ticket)):
|
||||
raise ValueError("扫码票据已过期或不存在")
|
||||
|
||||
# 2. 校验 scan 结果
|
||||
scan_data_raw = await self.redis.get(self._scan_key(ticket))
|
||||
if not scan_data_raw:
|
||||
raise ValueError("该二维码尚未被扫码或扫码已过期")
|
||||
|
||||
# 解析扫码用户身份
|
||||
try:
|
||||
scan_data = json.loads(scan_data_raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"扫码数据解析失败: ticket={ticket[:8]}...")
|
||||
raise ValueError("扫码数据异常")
|
||||
|
||||
employee_id = scan_data.get("employee_id", "")
|
||||
name = scan_data.get("name", "")
|
||||
if not employee_id:
|
||||
raise ValueError("扫码数据缺少 employee_id")
|
||||
|
||||
# 3. TODO Phase 2.1: admin 场景下的 OTP 校验
|
||||
# 当前 Phase 1.1 不强制,otp_code 字段仅作为预留
|
||||
require_otp = False
|
||||
if otp_code is not None and "admin" in current_roles:
|
||||
# 预留接口,真实校验逻辑放在 Phase 2.1 实现
|
||||
# 此处仅标记 require_otp=True 提示前端
|
||||
require_otp = True
|
||||
logger.info(
|
||||
f"扫码确认收到 OTP(预留字段,Phase 2.1 校验): "
|
||||
f"current_user={current_user_id}, otp_code={otp_code[:2]}..."
|
||||
)
|
||||
|
||||
# 4. 创建 Token(用扫码用户身份,roles 默认为 agent)
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
token_service = TokenService(self.redis)
|
||||
roles = ["agent"]
|
||||
token = await token_service.create_token(
|
||||
employee_id=employee_id,
|
||||
name=name,
|
||||
roles=roles,
|
||||
login_source="qrcode",
|
||||
)
|
||||
|
||||
# 5. 写 Redis confirm 结果(TTL 60s,前端轮询拿到后过期)
|
||||
confirm_payload = {
|
||||
"token": token,
|
||||
"confirmed_at": datetime.now().isoformat(),
|
||||
"roles": roles,
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
}
|
||||
await self.redis.setex(
|
||||
self._confirm_key(ticket),
|
||||
CONFIRM_TTL_SECONDS,
|
||||
json.dumps(confirm_payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"扫码确认成功: ticket={ticket[:8]}..., "
|
||||
f"employee_id={employee_id}, current_user={current_user_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
"roles": roles,
|
||||
"require_otp": require_otp,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# poll: 轮询扫码状态
|
||||
# ------------------------------------------------------------------
|
||||
async def get_poll_state(self, ticket: str) -> Dict[str, Any]:
|
||||
"""查询票据当前状态。
|
||||
|
||||
优先级: confirmed > scanned > ticket exists(等待) > 不存在(过期)
|
||||
|
||||
Returns:
|
||||
Dict: 包含 status / employee_id / name / token
|
||||
"""
|
||||
# 1. 先看 confirm 结果(最高优先级,确认即终态)
|
||||
confirm_raw = await self.redis.get(self._confirm_key(ticket))
|
||||
if confirm_raw:
|
||||
try:
|
||||
confirm_data = json.loads(confirm_raw)
|
||||
return {
|
||||
"status": "confirmed",
|
||||
"employee_id": confirm_data.get("employee_id"),
|
||||
"name": confirm_data.get("name"),
|
||||
"token": confirm_data.get("token"),
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"confirm 数据解析失败: ticket={ticket[:8]}...")
|
||||
|
||||
# 2. 看 scan 结果(已扫码未确认)
|
||||
scan_raw = await self.redis.get(self._scan_key(ticket))
|
||||
if scan_raw:
|
||||
try:
|
||||
scan_data = json.loads(scan_raw)
|
||||
return {
|
||||
"status": "scanned",
|
||||
"employee_id": scan_data.get("employee_id"),
|
||||
"name": scan_data.get("name"),
|
||||
"token": None,
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"scan 数据解析失败: ticket={ticket[:8]}...")
|
||||
|
||||
# 3. 看 ticket 本身(还在等待扫码)
|
||||
if await self.redis.get(self._ticket_key(ticket)):
|
||||
return {
|
||||
"status": "waiting",
|
||||
"employee_id": None,
|
||||
"name": None,
|
||||
"token": None,
|
||||
}
|
||||
|
||||
# 4. ticket 也不存在 → 已过期/不存在
|
||||
return {
|
||||
"status": "expired",
|
||||
"employee_id": None,
|
||||
"name": None,
|
||||
"token": None,
|
||||
}
|
||||
Reference in New Issue
Block a user