vault backup: 2026-05-17 21:40:31

This commit is contained in:
hhs
2026-05-17 21:40:31 +08:00
parent bdeb889ee4
commit df6489e370
14 changed files with 3173 additions and 295 deletions
+728
View File
@@ -0,0 +1,728 @@
---
tags: ["OAuth2", "MFA", "Authentication", "Authorization", "Security", "TOTP", "OpenID-Connect", "2FA"]
create time: 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 四个角色
```mermaid
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(推荐方案)
这是当前最佳实践,尤其适合前后端分离架构:
```mermaid
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 类型
```typescript
// 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 三大因素分类
```mermaid
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 验证
```go
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
```go
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。
```typescript
// 前端:根据操作敏感度判断是否需要触发 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()}` },
});
}
```
```go
// 后端中间件:校验 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. **唯一显示给用户** — 用户需截图或抄写保存(离线场景)
```go
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
```mermaid
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)
```mermaid
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。这是用户体验和安全的关键平衡点。
```go
// 设备指纹生成与验证
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"
}
```
```typescript
// 前端:生成设备指纹
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 格式)
```json
{
"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) | 低 | 极低 |
```mermaid
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: 15min~1hr,Refresh: 7~30 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 |
---
## 关联笔记
- [[hhs/DEV/Security/Basics/RSA-and-ECC]]
- [[hhs/DEV/Security/JWT-Deep-Dive]]
- [[hhs/DEV/Go/Backend/Redis-Patterns]]
- [[hhs/DEV/React/frontend-state-management]]