Files
wecom_it_smart_desk/backend/app/services/mfa_service.py
T
Simon bf872da8bb 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>
2026-06-21 03:08:54 +08:00

179 lines
6.4 KiB
Python

# =============================================================================
# 企微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