vault backup: 2026-05-17 21:40:31
This commit is contained in:
@@ -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]]
|
||||
Reference in New Issue
Block a user