bf872da8bb
合入内容: - 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>
236 lines
9.4 KiB
Python
236 lines
9.4 KiB
Python
# =============================================================================
|
|
# 企微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)}") |