179 lines
6.4 KiB
Python
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
|