This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/DEV/OAuth2与MFA/OAuth2-and-MFA.md
T
2026-05-19 15:17:50 +08:00

25 KiB
Raw Blame History

tags, create time
tags create time
OAuth2
MFA
Authentication
Authorization
Security
TOTP
OpenID-Connect
2FA
2026-05-17 20:00

OAuth2 + MFA

概述

本文档系统梳理 OAuth 2.0(授权框架)与 多因素认证(MFA) 的核心原理、设计模式和工程实践。涵盖 OAuth2 的四种授权流程、MFA 的多因素分类、两者在真实系统中的集成方式(包括 MFA-challenged 场景),以及 Go 后端和 React/TypeScript 前端的完整示例。旨在让读者能够从零搭建一个支持 MFA 的企业级认证体系。

[!question] 思考题 OAuth2 本质上是授权协议而非认证协议。那为什么业界常用它来做登录?又如何在 OAuth2 之上叠加 MFA 能力?带着这两个问题开始阅读。


一、OAuth2 核心概念

1.1 四个角色

graph LR
    A["用户 (Resource Owner)"] -->|"授予权限"| B["客户端 (Client)"]
    B -->|"请求令牌"| C["授权服务器 (AS)"]
    C -->|"发放 Access Token"| B
    B -->|"携带 Token"| D["资源服务器 (RS)"]
    D -->|"返回受保护资源"| B
    style A fill:#e1f5fe
    style B fill:#fff3e0
    style C fill:#e8f5e9
    style D fill:#fce4ec
角色 职责 常见身份
Resource Owner 资源拥有者 终端用户
Client 发起请求的应用 Web App / Mobile App / SPA
Authorization Server 验证用户并发放令牌 Keycloak / Auth0 / 自研
Resource Server 托管受保护资源的 API 后端微服务

[!tip] 关键认知 很多系统中 AS 和 RS 部署在同一域名下(如 auth.example.com 和 api.example.com),对外表现为一个统一平台。但它们在架构上是解耦的。

1.2 四种授权流程(Grant Types)

Grant Type 适用场景 安全性
Authorization Code 服务端渲染的后端应用(SPA + Backend) ★★★★★
Authorization Code + PKCE SPA、移动端等无法保密 Client Secret 的场景 ★★★★★
Implicit Flow 已废弃 不推荐使用 ★★★
Client Credentials 机器对机器通信(Service-to-Service) ★★★★☆
Device Code 无浏览器设备(IoT、电视) ★★★★

Authorization Code + PKCE(推荐方案)

这是当前最佳实践,尤其适合前后端分离架构:

sequenceDiagram
    participant U as 用户
    participant C as Client(SPA)
    participant AS as Auth Server
    participant RS as Resource Server

    U->>C: 1. 点击"登录"
    C->>C: 2. 生成 code_verifier & code_challenge
    C->>AS: 3. 重定向到 /authorize?code_challenge=S256
    AS->>U: 4. 展示登录页 + MFA 提示
    U->>AS: 5. 输入密码 + TOTP 验证码
    AS->>C: 6. 回调 /callback?code=AUTH_CODE
    C->>AS: 7. 用 code + code_verifier 换 token
    AS->>C: 8. 返回 {access_token, refresh_token, id_token}
    C->>RS: 9. 携带 access_token 请求 API
    RS->>RS: 10. 验证签名并返回资源

[!question] 为什么需要 PKCE? 传统的 Authorization Code 要求客户端持有 client_secret。但对于浏览器端或移动端的 SPA 应用,Secret 必然暴露在前端代码中——任何拦截都能窃取。PKCE 通过每次请求动态生成 code_challenge(基于 code_verifier),确保只有发起请求的客户端能用该 authorization code 换取 token,即使 code 被截获也无法利用。

1.3 Token 类型

// Access Token — 短期有效,用于访问 API
interface AccessToken {
  sub: string;           // 用户唯一标识
  iss: string;           // 签发者
  aud: string[];         // 目标受众(资源服务器)
  exp: number;           // 过期时间(通常 15min ~ 1hr)
  iat: number;           // 签发时间
  scopes: string[];      // 授权范围
  mfa_verified?: boolean; // MFA 是否已验证
  session_id: string;    // 关联会话
}

// Refresh Token — 长期有效,用于获取新的 Access Token
// ⚠️ 绝对不应该出现在前端浏览器中
interface RefreshToken {
  sub: string;
  jti: string;           // Token ID(用于吊销)
  exp: number;           // 过期时间(通常 7 ~ 30 days)
  device_fingerprint?: string;
}

[!warning] 安全陷阱 Access Token 存 localStorage 还是 memory?答案:永远放内存。localStorage 和 sessionStorage 可被任意 JavaScript 读取,是 XSS 攻击的天然蜜罐。HTTP-only Cookie 适合后端渲染,但不适用于纯 SPA。


二、多因素认证(MFA)基础

2.1 三大因素分类

quadrantChart
    title "MFA 因素对比"
    x-axis "低用户体验" --> "高用户体验"
    y-axis "技术成熟度高" --> "技术成熟度低"
    "短信验证码": [0.4, 0.6]
    "邮箱验证码": [0.5, 0.75]
    "TOTP (Google Authenticator)": [0.75, 0.9]
    "生物识别 (指纹/人脸)": [0.85, 0.85]
    "硬件密钥 (YubiKey)": [0.6, 0.7]
    "Push Notification": [0.9, 0.8]
因素 例子 优势 劣势
Knowledge(所知) 密码、PIN 零成本,用户熟悉 SIM 卡劫持、钓鱼可绕过
Possession(所有) 手机 app、硬件密钥 独立于密码 依赖设备可用性
Inherence(所是) 指纹、面部识别 无缝体验 隐私争议、误识率

2.2 TOTP 算法详解(RFC 6238)

TOTP(Time-based One-Time Password)是目前最常用的 Possession 因素实现。

TOTP 算法流程

输入:共享密钥 K + 当前时间 T
输出:6~8 位数字验证码

步骤:
  1. T = floor(当前 Unix Timestamp / 30)       // 每 30 秒一个窗口
  2. Counter = big-endian byte representation of T
  3. HMAC-SHA256(K, Counter) → 20 字节结果
  4. Dynamic Truncation → 提取 4 字节
  5. Modulo 10^digits → 6 或 8 位数字

[!example] 为什么是 30 秒? 30 秒是安全窗口和安全性的折衷——太短用户来不及操作,太长增加了被盗用的风险。配合 ±1 个窗口的漂移容忍(允许前后两个值),实际容错范围是 90 秒。

Go 实现 TOTP 验证

package totp

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/binary"
	"fmt"
	"math"
	"time"
)

// Verify 校验 TOTP 码,tolerance 表示允许的时间窗口偏移量
func Verify(secret, code string, digits int, tolerance int, now time.Time) bool {
	window := now.Unix() / 30

	for offset := -tolerance; offset <= tolerance; offset++ {
		if verifyAtWindow(secret, code, int(window+offset), digits) {
			return true
		}
	}
	return false
}

func verifyAtWindow(secret, code string, window, digits int) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	buf := make([]byte, 8)
	binary.BigEndian.PutUint64(buf, uint64(window))
	mac.Write(buf)
	hash := mac.Sum(nil)

	offset := hash[len(hash)-1] & 0x0F
	codeBytes := binary.BigEndian.Uint32(hash[offset : offset+4]) & 0x7FFFFFFF
	computed := int(codeBytes) % int(math.Pow10(digits))
	expected := fmt.Sprintf("%06d", computed)

	// constant-time comparison, 防止 timing attack
	return hmac.Equal([]byte(expected), []byte(code))
}

[!note] 关键点解释

  • secret 是服务器与客户端共享的基础密钥(Base32 编码),通过 QR 码首次绑定
  • tolerance=1 是最常用的配置,提供 ±30s 的容差
  • 比较时使用 hmac.Equal 进行 constant-time comparison,防止 timing attack

2.3 WebAuthn / FIDO2(下一代 MFA)

WebAuthn 由 W3C 定义,取代传统 TOTP,将"possessing a device"变为"possessing a cryptographic key bound to a domain"。

流程:
  注册:
    1. 客户端请求凭证创建 → 服务端生成 challenge
    2. 浏览器调用 navigator.credentials.create()
    3. 用户通过生物识别/PIN 授权
    4. 硬件安全模块(TPM/Safe Area)生成公私钥对
    5. 公钥 + attestation 返回服务端,服务端持久化

  认证:
    1. 登录时服务端发送 challenge
    2. 浏览器调用 navigator.credentials.get()
    3. 硬件验证身份后使用私钥签名
    4. 服务端用已存储的公钥验证签名

[!tip] 为什么 WebAuthn 比 TOTP 更安全?

  • 抗钓鱼:密钥绑定特定 origin,攻击者无法诱骗用户使用其网站的密钥
  • 私钥不出设备:私钥永远不会离开安全硬件
  • 无需共享密钥:消除了二维码泄露导致的全局风险

三、OAuth2 + MFA 的深度集成

3.1 MFA-Challenged 模式

这是企业级认证中最关键的集成点。当用户认证成功但尚未完成 MFA 验证时,授权服务器应返回 mfa_required 错误,而非直接发放 token。

┌───────────── 第一次请求 ─────────────┐
│  Client → /authorize                 │
│  用户输入用户名 + 密码              │
│  ✅ 密码正确                        │
│  ❓ MFA 状态?                       │
│  → 需要 MFA                          │
│                                      │
│  Client ← {                         │
│    "error": "mfa_required",          │
│    "challenge_id": "uuid-xxxx",      │
│    "supported_factors": ["totp","webauthn"]   │
│  }                                  │
└──────────────────────────────────────┘

┌───────────── 第二次请求(补充 MFA)───┐
│  Client → /token                     │
│  headers:                            │
│    X-MFA-Challenge-ID: uuid-xxxx     │
│    X-MFA-Factor: totp               │
│    body:                             │
│      mfa_code: "123456"             │
│                                      │
│  ✅ MFA 验证成功                     │
│                                      │
│  Client ← {                          │
│    "access_token": "xxx",            │
│    "refresh_token": "yyy",           │
│    "mfa_verified": true              │
│  }                                  │
└───────────────────────────────────────┘

Go 后端处理 MFA Challenge

package auth

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"github.com/google/uuid"
	"github.com/redis/go-redis/v9"
)

type AuthHandler struct {
	rdb            *redis.Client
	tokenGenerator *TokenGenerator
	ctx            context.Context
}

type MFACreateChallengeRequest struct {
	UserID string `json:"user_id"`
}

type MFAResponse struct {
	ChallengeID      string   `json:"challenge_id"`
	SupportedFactors []string `json:"supported_factors"`
	ExpiresIn        int      `json:"expires_in"` // seconds
}

// MFAChallengeResponse MFA 验证通过后返回的 token 对
type MFAChallengeResponse struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	MFAVerified  bool   `json:"mfa_verified"`
	TokenType    string `json:"token_type"` // "Bearer"
}

// CreateMFAChallenge 在密码验证通过后调用,生成一个限时 challenge
func (h *AuthHandler) CreateMFAChallenge(w http.ResponseWriter, r *http.Request) {
	var req MFACreateChallengeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	challengeID := uuid.New().String()
	expiry := time.Now().Add(5 * time.Minute)

	// 将 challenge 存入 Redis,5 分钟过期
	key := fmt.Sprintf("mfa:challenge:%s", challengeID)
	h.rdb.Set(h.ctx, key, fmt.Sprintf(`{"user_id":"%s","exp":%d}`, req.UserID, expiry.Unix()), 5*time.Minute)

	json.NewEncoder(w).Encode(MFAResponse{
		ChallengeID:      challengeID,
		SupportedFactors: []string{"totp", "webauthn"},
		ExpiresIn:        300,
	})
}

// SubmitMFAVerify 提交 MFA 码进行验证
func (h *AuthHandler) SubmitMFAVerify(w http.ResponseWriter, r *http.Request) {
	var req struct {
		ChallengeID string `json:"challenge_id"`
		Factor      string `json:"factor"` // "totp" or "webauthn"
		Code        string `json:"code"`
	}
	json.NewDecoder(r.Body).Decode(&req)

	// 1. 校验 challenge 存在且未过期
	userID, err := h.validateChallenge(req.ChallengeID)
	if err != nil {
		http.Error(w, "invalid or expired challenge", http.StatusUnauthorized)
		return
	}

	// 2. 根据 factor 执行验证逻辑
	switch req.Factor {
	case "totp":
		if !totp.Verify(userID, req.Code, 6, 1, time.Now()) {
			http.Error(w, "invalid totp code", http.StatusUnauthorized)
			return
		}
	case "webauthn":
		// WebAuthn assertion verification...
	}

	// 3. 验证成功,发放 token
	tokenPair, err := h.tokenGenerator.Generate(userID, map[string]interface{}{
		"mfa_verified": true,
		"session_id":   req.ChallengeID,
	})
	if err != nil {
		http.Error(w, "token generation failed", http.StatusInternalServerError)
		return
	}

	json.NewEncoder(w).Encode(MFAChallengeResponse{
		AccessToken:  tokenPair.AccessToken,
		RefreshToken: tokenPair.RefreshToken,
		MFAVerified:  true,
		TokenType:    "Bearer",
	})
}

3.2 MFA-Verified Scope(精细粒度控制)

不同 API 对安全级别的请求不同。高敏感操作需要 mfa_verified=true 的 token。

// 前端:根据操作敏感度判断是否需要触发 MFA

// decodeBase64URL JWT payload 解码 helper
function decodeBase64URL(base64url: string): string {
  const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
  return atob(base64);
}

async function sensitiveAction(apiPath: string) {
  const needsMFA = apiPath.match(/\/transfer|\/withdraw|\/settings-change/);

  if (needsMFA) {
    // 检查当前 token 是否已有 MFA 标记
    const token = getToken();
    const decoded = JSON.parse(decodeBase64URL(token.split('.')[1]));

    if (!decoded.mfa_verified) {
      // 先走 MFA flow,获取新 token
      const freshToken = await requestMFAFlow();
      setToken(freshToken);
    }
  }

  return fetch(apiPath, {
    headers: { Authorization: `Bearer ${getToken()}` },
  });
}
// 后端中间件:校验 Token 中的 MFA 级别
func MFAMiddleware(requiredLevel string) gin.HandlerFunc {
	return func(c *gin.Context) {
		tokenStr, _ := extractToken(c)
		claims, err := validateJWT(tokenStr)
		if err != nil {
			c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
			return
		}

		if requiredLevel == "high" && claims.MFAVerified != true {
			c.AbortWithStatusJSON(403, gin.H{
				"error": "mfa_verification_required",
				"required_level": "high",
			})
			return
		}

		c.Set("claims", claims)
		c.Next()
	}
}

// 使用示例
router.POST("/api/transfer", middleware.MFAMiddleware("high"), transferHandler)
router.GET("/api/profile", middleware.MFAMiddleware("low"), profileHandler)

3.3 Backup Codes(备用码)机制

当用户丢失手机或 TOTP app 无法使用时,Backup Codes 是最后的保底手段。

设计要点:

  1. 一次性使用 — 每个码只能用一次,验证后立即标记已用
  2. 预生成 + 安全存储 — 启用 TOTP 时同时生成 10 个码
  3. SHA-256 哈希存储 — 数据库中只存 hash,不存明文
  4. 唯一显示给用户 — 用户需截图或抄写保存(离线场景)
func GenerateBackupCodes(userID string) ([]string, []string) {
	codes := make([]string, 10)
	hashes := make([]string, 10)
	for i := 0; i < 10; i++ {
		b := make([]byte, 8)
		rand.Read(b)
		code := fmt.Sprintf("%08d", binary.BigEndian.Uint64(b) % 100000000)
		codes[i] = code
		hashes[i] = sha256.Sum256([]byte(code))
	}
	// hashes 存入数据库(一次性展示给用户)
	return codes, hashes
}

func VerifyBackupCode(userID, code string) bool {
	hash := sha256.Sum256([]byte(code))
	ok := db.QueryRow("SELECT id FROM backup_codes WHERE user_id=? AND hash=?", userID, hash).Scan(...)
	if ok {
		// 立即删除,确保一次性
		db.Exec("DELETE FROM backup_codes WHERE user_id=? AND hash=?", userID, hash)
	}
	return ok
}

[!warning] 安全注意

  • Backup Codes 的强度等同于 8 位数字(1 亿种组合),必须一次性使用后销毁
  • 剩余可用码数量应反馈给前端("还有 3 个备用码可用"),让用户心中有数
  • 使用 Backup Code 登录后,建议强制重新绑定 TOTP(视为设备变更)
  • 审计日志中单独标注 "auth_method: backup_code"

3.4 Session 管理与 Remember-Me

stateDiagram-v2
    [*] --> Unauthenticated

    Unauthenticated --> PasswordValidated: 输入用户名 + 密码
    PasswordValidated --> MFAChallenged: 需要 MFA
    PasswordValidated --> Authorized: MFA skip (trusted device)

    MFAChallenged --> Authorized: MFA verified
    MFAChallenged --> PasswordValidated: MFA timeout
    MFAChallenged --> Unauthenticated: cancel

    Authorized --> SessionActive: 发放 session
    SessionActive --> Authorized: token refresh
    SessionActive --> Remembered: 勾选"记住我"
    Remembered --> Authorized: 自动续期 (extended)
    Remembered --> Unauthenticated: remember token expired
    Authorized --> Unauthenticated: logout / revoke
策略 典型 TTL 适用场景
标准 Session 24h(配合 refresh token 滚动) 日常办公
Remember-Me 7~30 天 个人账号,低敏感度
No-Retain 关闭 remember-me 金融/医疗等高敏场景

[!warning] Refresh Token 轮换(Rotation) 每次使用 refresh token 换 access token 时,都应同步颁发新的 refresh token,使旧的那个失效。这样可以:

  • 检测 replay 攻击(旧 refresh token 再次出现即意味着泄露)
  • 限制单个刷新令牌的总生命周期
  • Go 实现中使用 Redis Set NX 保证原子性

四、端到端实战流程

4.1 完整登录链路(含 MFA)

sequenceDiagram
    actor U as User
    participant FE as Frontend (React)
    participant BE as Backend (Go)
    participant AS as Auth Server
    participant Redis as Redis Store

    U->>FE: 点击"登录"
    FE->>FE: generate code_verifier + code_challenge
    FE->>AS: 重定向到 auth server
    AS->>U: 展示登录表单

    U->>AS: 输入 username + password
    AS->>AS: 验证密码 ✓
    AS->>AS: 查询 MFA 状态
    Note over AS: 如果用户有 TOTP → 需要 MFA<br/>如果没有 → 直接发 token

    alt MFA Required
        AS->>FE: redirect back with error=mfa_required
        FE->>BE: POST /api/auth/challenge (with user info)
        BE->>Redis: store challenge record
        BE->>FE: return challenge_id
        FE->>U: 显示 TOTP 输入框
        U->>FE: 输入 6 位验证码
        FE->>BE: POST /api/auth/mfa-verify
        BE->>AS: 带 X-MFA-Challenge-ID + code 调 /token
        AS->>AS: 验证 TOTP + challenge
        AS->>BE: {access_token, refresh_token, mfa_verified:true}
        BE->>FE: token pair
        FE->>FE: save token in memory state
    else MFA Not Required
        AS->>FE: redirect back with authorization code
        FE->>AS: exchange code + verifier for tokens
        AS->>FE: {access_token, refresh_token}
        FE->>FE: save token in memory state
    end

    U->>FE: 进入应用主页

4.2 设备信任机制

对于可信设备,用户可以跳过 MFA。这是用户体验和安全的关键平衡点。

// 设备指纹生成与验证
func DeviceFingerprint(userAgent string, screenRes string, timezone string) string {
	seed := userAgent + screenRes + timezone
	h := sha256.Sum256([]byte(seed))
	return hex.EncodeToString(h[:16]) // 128-bit fingerprint
}

// 保存信任记录
func TrustDevice(userID, deviceFP string) error {
	key := fmt.Sprintf("device:trust:%s:%s", userID, deviceFP)
	return rdb.Set(ctx, key, "trusted", 30*24*time.Hour).Err() // 30 天
}

// 验证时跳过 MFA
func SkipMFAIfTrusted(userID, deviceFP string) bool {
	key := fmt.Sprintf("device:trust:%s:%s", userID, deviceFP)
	val, _ := rdb.Get(ctx, key).Result()
	return val == "trusted"
}
// 前端:生成设备指纹
async function getDeviceFingerprint(): Promise<string> {
  const data = [
    navigator.userAgent,
    `${screen.width}x${screen.height}`,
    Intl.DateTimeFormat().resolvedOptions().timeZone,
    new Date().getTimezoneOffset(),
  ].join('|');

  const encoder = new TextEncoder();
  const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data));
  const hashArray = Array.from(new Uint8Array(hashBuffer)).slice(0, 16);
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

[!tip] 设备信任的安全性边界

  • 设备指纹不是加密证明,仅作为用户体验优化
  • 核心保障依然在于 access token 的短期生命周期
  • 敏感操作(转账、修改密码)不应因设备信任而跳过 MFA
  • 建议定期重新验证(如每隔 7 天清除 trust 记录)

五、OpenID Connect(OIDC)扩展

OIDC 是在 OAuth2 之上的认证层,提供了标准化的身份令牌(Identity Token)。

OAuth2:  Client → AS → "Here's an access token for reading resources"
OIDC:    Client → AS → "Here's an access token AND here's who you are"

5.1 ID Token(JWT 格式)

{
  "iss": "https://auth.example.com",
  "sub": "user_abc123",
  "aud": ["client_id_xyz"],
  "exp": 1715970000,
  "iat": 1715966400,
  "auth_time": 1715966350,
  "amr": ["pwd", "otp"],        // Authentication Methods References
  "azp": "client_id_xyz",
  "nonce": "random-nonce-value"
}

[!note] amr (Authentication Methods References) amr 字段记录了认证方法。这就是 MFA 信息在 OIDC 层面的体现:

  • ["pwd"] → 仅有密码认证
  • ["pwd", "otp"] → 密码 + OTP(双因素完成)
  • ["pwd", "puk"] → 密码 + 硬件密钥

前端解析 id_token.amr 即可知道用户当前的认证强度。

5.2 Auth Server 选型推荐

自建还是用托管方案?以下是主流选型对比:

方案 适合场景 MFA 支持 开发成本 维护成本
Keycloak (开源) 企业内部、数据不出域 TOTP / WebAuthn / Email OTP 中 中(需运维)
Auth0 (SaaS) 快速上线、预算充足 全量支持 + adaptive MFA 低 极低
Cloudflare Access 边缘侧认证、API 网关 邮件 OTP / WebAuthn 低 极低
自研 (Go + go-oauth2/oauth2) 高度定制化需求 完全自控 高 高
Supabase Auth 初创产品、BFF 架构 TOTP (v2) 低 极低
graph TD
    A{"需要快速上线?"} -->|是| B["Auth0 / Supabase"]
    A -->|否| C{"数据合规要求?"}
    C -->|必须私有部署| D["Keycloak"]
    C -->|无限制| E["自建方案"]
    
    style B fill:#d4edda
    style D fill:#fff3cd
    style E fill:#f8d7da

[!tip] 决策建议

  • MVP / 创业公司:直接用 Auth0,节省数周开发时间
  • 企业内网工具:Keycloak + LDAP/AD 集成,一次投入长期受益
  • 高度敏感行业:考虑自建(可控审计流程),但务必复用成熟开源库而非从零造轮子

七、安全最佳实践清单

[!checklist] 生产环境 Checklist

OAuth2 侧

  • 必须使用 HTTPS(TLS 1.2+),否则 token 在网络中转易被截取
  • Authorization Code + PKCE 是所有公开客户端的标配
  • Access Token 放在内存(不存 localStorage/sessionStorage)
  • Refresh Token 使用 HTTP-only, Secure, SameSite=Strict Cookie 仅在后端
  • 实现 Refresh Token Rotation(每次旋转,旧 token 立即作废)
  • Token 设置合理的 TTL(Access: 15min1hr,Refresh: 730 days)
  • JWT 使用 RS256/ES256(非对称签名),避免 HS256 密钥泄漏风险
  • 严格校验 issuer、audience、expiry、nonce

MFA 侧

  • TOTP 使用 RFC 6238 兼容库,不要自己实现底层密码学
  • rate limiting:每个 IP 每 10 次失败锁定 5 分钟
  • 考虑支持 Backup Codes(一次性备用码,最多 10 个)
  • 优先推荐 WebAuthn/FIDO2 替代 SMS/TOTP(更抗钓鱼)
  • SMS 验证码有效期不超过 10 分钟
  • MFA 相关接口必须限制频率(不要让用户能爆破 6 位数字)
  • 支持 MFA enrollment revocation(用户可随时禁用并重新配置)

架构侧

  • Token 中不存储敏感数据(密码、MFA secret),只存元数据
  • 审计日志:记录每次 MFA 验证尝试(成功/失败/IP/device)
  • 敏感操作强制二次 MFA(即使 token 带有 mfa_verified: true)
  • 实现 Token Revocation List(黑屏/注销时立即失效)

八、常见威胁模型分析

攻击类型 防御手段
Phishing(钓鱼) WebAuthn(origin-binding 天然免疫)、DNS-over-HTTPS
Replay Attack(重放) PKCE、Nonce 校验、Refresh Token Rotation
Token Theft(窃听/存储) Short-lived Access Token、HTTPS-only、SameSite Cookie
Brute Force(爆破 MFA) Rate Limiting、Account Lockout
Session Hijacking HttpOnly Cookie、CSRF Token、Device Binding
SIM Swap(短信劫持) 优先 TOTP/WebAuthn、SMS 仅作为 fallback

关联笔记