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,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