Skip to content

API 安全实战指南

🎯 JWT/OAuth2.0 认证、签名验证、防重放攻击完整方案


1. 认证方案对比

1.1 主流认证方案

┌─────────────────────────────────────────────────────────────────────────┐
│                      认证方案对比                                        │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  方案            │ 特点                  │ 适用场景                     │
│  ───────────────┼──────────────────────┼────────────────────────────   │
│  Session/Cookie │ 有状态,服务端存储    │ 传统 Web 应用                │
│  JWT            │ 无状态,Token 自包含  │ SPA、移动端、微服务          │
│  OAuth 2.0      │ 第三方授权            │ 第三方登录、开放平台         │
│  API Key        │ 简单,适合服务间调用  │ 开放 API、后端服务间通信     │
│  mTLS           │ 双向证书认证          │ 高安全性服务间通信           │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

2. JWT (JSON Web Token)

2.1 JWT 结构

┌─────────────────────────────────────────────────────────────────────────┐
│                         JWT 结构                                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  xxxxx.yyyyy.zzzzz                                                      │
│    │     │     │                                                        │
│    │     │     └── Signature (签名)                                     │
│    │     └──────── Payload (载荷)                                       │
│    └────────────── Header (头部)                                        │
│                                                                         │
│  Header (Base64):                                                       │
│  ─────────────────                                                      │
│  {                                                                      │
│    "alg": "HS256",    // 签名算法                                       │
│    "typ": "JWT"       // 类型                                           │
│  }                                                                      │
│                                                                         │
│  Payload (Base64):                                                      │
│  ─────────────────                                                      │
│  {                                                                      │
│    "sub": "1234567890",  // 用户 ID                                     │
│    "name": "John Doe",   // 自定义数据                                  │
│    "role": "admin",                                                     │
│    "iat": 1516239022,    // 签发时间                                    │
│    "exp": 1516325422     // 过期时间                                    │
│  }                                                                      │
│                                                                         │
│  Signature:                                                             │
│  ───────────                                                            │
│  HMACSHA256(                                                            │
│    base64UrlEncode(header) + "." + base64UrlEncode(payload),           │
│    secret                                                               │
│  )                                                                      │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

2.2 JWT 认证流程

┌─────────────────────────────────────────────────────────────────────────┐
│                       JWT 认证流程                                       │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  Client                            Server                               │
│    │                                  │                                 │
│    │ ─── POST /login ───────────────▶│                                 │
│    │     { username, password }       │                                 │
│    │                                  │ 验证凭证                        │
│    │                                  │ 生成 JWT                        │
│    │ ◀─── 200 OK ───────────────────│                                 │
│    │     { accessToken, refreshToken }│                                 │
│    │                                  │                                 │
│    │  存储 Token (localStorage)       │                                 │
│    │                                  │                                 │
│    │ ─── GET /api/user ─────────────▶│                                 │
│    │     Authorization: Bearer xxx    │                                 │
│    │                                  │ 验证 JWT 签名                   │
│    │                                  │ 检查是否过期                    │
│    │ ◀─── 200 OK ───────────────────│                                 │
│    │     { user data }                │                                 │
│    │                                  │                                 │
│    │  Token 过期                      │                                 │
│    │                                  │                                 │
│    │ ─── POST /refresh ─────────────▶│                                 │
│    │     { refreshToken }             │                                 │
│    │ ◀─── 200 OK ───────────────────│                                 │
│    │     { newAccessToken }           │                                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

2.3 前端 JWT 管理

typescript
// token.ts
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';

export const tokenManager = {
    // 获取 Token
    getAccessToken: (): string | null => {
        return localStorage.getItem(ACCESS_TOKEN_KEY);
    },
    
    getRefreshToken: (): string | null => {
        return localStorage.getItem(REFRESH_TOKEN_KEY);
    },
    
    // 存储 Token
    setTokens: (accessToken: string, refreshToken: string): void => {
        localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
        localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
    },
    
    // 清除 Token
    clearTokens: (): void => {
        localStorage.removeItem(ACCESS_TOKEN_KEY);
        localStorage.removeItem(REFRESH_TOKEN_KEY);
    },
    
    // 解析 JWT Payload (不验证签名)
    parseJwt: (token: string): any => {
        try {
            const base64Url = token.split('.')[1];
            const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
            const jsonPayload = decodeURIComponent(
                atob(base64)
                    .split('')
                    .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
                    .join('')
            );
            return JSON.parse(jsonPayload);
        } catch {
            return null;
        }
    },
    
    // 检查 Token 是否过期
    isTokenExpired: (token: string): boolean => {
        const payload = tokenManager.parseJwt(token);
        if (!payload || !payload.exp) return true;
        
        // 提前 60 秒认为过期
        return payload.exp * 1000 < Date.now() + 60000;
    },
};

2.4 Token 无感刷新

typescript
// axios-auth.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { tokenManager } from './token';

let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];

// 订阅 Token 刷新
function subscribeTokenRefresh(callback: (token: string) => void): void {
    refreshSubscribers.push(callback);
}

// 通知所有订阅者
function onTokenRefreshed(token: string): void {
    refreshSubscribers.forEach((callback) => callback(token));
    refreshSubscribers = [];
}

// 刷新 Token
async function refreshToken(): Promise<string> {
    const refreshToken = tokenManager.getRefreshToken();
    if (!refreshToken) {
        throw new Error('No refresh token');
    }
    
    const response = await axios.post('/api/auth/refresh', { refreshToken });
    const { accessToken, refreshToken: newRefreshToken } = response.data.data;
    
    tokenManager.setTokens(accessToken, newRefreshToken);
    return accessToken;
}

export function setupAuthInterceptor(instance: AxiosInstance): void {
    // 请求拦截: 注入 Token
    instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
        const token = tokenManager.getAccessToken();
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    });
    
    // 响应拦截: 处理 401
    instance.interceptors.response.use(
        (response) => response,
        async (error) => {
            const originalRequest = error.config;
            
            // 非 401 或已重试,直接返回错误
            if (error.response?.status !== 401 || originalRequest._retry) {
                return Promise.reject(error);
            }
            
            // 刷新 Token 的请求失败,直接跳转登录
            if (originalRequest.url?.includes('/auth/refresh')) {
                tokenManager.clearTokens();
                window.location.href = '/login';
                return Promise.reject(error);
            }
            
            // 正在刷新中,加入等待队列
            if (isRefreshing) {
                return new Promise((resolve) => {
                    subscribeTokenRefresh((token) => {
                        originalRequest.headers.Authorization = `Bearer ${token}`;
                        resolve(instance(originalRequest));
                    });
                });
            }
            
            // 开始刷新
            originalRequest._retry = true;
            isRefreshing = true;
            
            try {
                const newToken = await refreshToken();
                onTokenRefreshed(newToken);
                originalRequest.headers.Authorization = `Bearer ${newToken}`;
                return instance(originalRequest);
            } catch (refreshError) {
                tokenManager.clearTokens();
                window.location.href = '/login';
                return Promise.reject(refreshError);
            } finally {
                isRefreshing = false;
            }
        }
    );
}

3. OAuth 2.0

3.1 授权码模式 (Authorization Code)

┌─────────────────────────────────────────────────────────────────────────┐
│                    OAuth 2.0 授权码模式                                  │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  User        Client           Auth Server        Resource Server       │
│   │            │                    │                    │              │
│   │─ 点击登录 ─▶│                    │                    │              │
│   │            │                    │                    │              │
│   │◀── 重定向到授权页面 ────────────▶│                    │              │
│   │                                 │                    │              │
│   │─────── 用户登录并授权 ─────────▶│                    │              │
│   │                                 │                    │              │
│   │◀─ 重定向回 Client (带 code) ────│                    │              │
│   │                                 │                    │              │
│   │            │─ POST /token ─────▶│                    │              │
│   │            │  (code + secret)   │                    │              │
│   │            │◀── access_token ───│                    │              │
│   │            │                    │                    │              │
│   │            │──── GET /api ──────────────────────────▶│              │
│   │            │    (access_token)  │                    │              │
│   │            │◀─── user data ─────────────────────────│              │
│   │            │                    │                    │              │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

3.2 前端实现

typescript
// oauth.ts - GitHub OAuth 示例

const GITHUB_CLIENT_ID = 'your_client_id';
const REDIRECT_URI = 'http://localhost:3000/callback';

// 1. 跳转到 GitHub 授权
export function loginWithGithub(): void {
    const params = new URLSearchParams({
        client_id: GITHUB_CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        scope: 'user:email',
        state: generateRandomState(), // CSRF 防护
    });
    
    // 保存 state 用于验证
    sessionStorage.setItem('oauth_state', params.get('state')!);
    
    window.location.href = `https://github.com/login/oauth/authorize?${params}`;
}

// 2. 回调页面处理
export async function handleOAuthCallback(): Promise<void> {
    const params = new URLSearchParams(window.location.search);
    const code = params.get('code');
    const state = params.get('state');
    const savedState = sessionStorage.getItem('oauth_state');
    
    // 验证 state 防止 CSRF
    if (!state || state !== savedState) {
        throw new Error('Invalid state');
    }
    
    sessionStorage.removeItem('oauth_state');
    
    if (!code) {
        throw new Error('No authorization code');
    }
    
    // 3. 用 code 换 token (需要后端中转,因为需要 client_secret)
    const response = await fetch('/api/auth/github/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code }),
    });
    
    const { accessToken, user } = await response.json();
    
    // 4. 存储 Token
    tokenManager.setTokens(accessToken, '');
    
    // 5. 跳转到首页
    window.location.href = '/';
}

function generateRandomState(): string {
    return Math.random().toString(36).substring(2);
}

4. API 签名

4.1 签名流程

┌─────────────────────────────────────────────────────────────────────────┐
│                         API 签名流程                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  请求参数:                                                              │
│  ───────────                                                            │
│  {                                                                      │
│    "user_id": 123,                                                      │
│    "amount": 100,                                                       │
│    "timestamp": 1703318400000,                                          │
│    "nonce": "abc123xyz"                                                 │
│  }                                                                      │
│                                                                         │
│  签名步骤:                                                              │
│  ───────────                                                            │
│  1. 参数按 key 排序                                                     │
│     amount=100&nonce=abc123xyz&timestamp=1703318400000&user_id=123     │
│                                                                         │
│  2. 拼接 AppKey 和 AppSecret                                           │
│     app_key=xxx&amount=100&nonce=abc123xyz&...&secret=yyy              │
│                                                                         │
│  3. 计算签名                                                            │
│     signature = MD5(拼接字符串) 或 HMAC-SHA256                         │
│                                                                         │
│  4. 添加到请求头                                                        │
│     X-App-Key: xxx                                                     │
│     X-Timestamp: 1703318400000                                         │
│     X-Nonce: abc123xyz                                                 │
│     X-Signature: 计算得到的签名                                         │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.2 前端实现

typescript
// signature.ts
import CryptoJS from 'crypto-js';

interface SignatureConfig {
    appKey: string;
    appSecret: string;
}

export function createSignatureInterceptor(config: SignatureConfig) {
    return (requestConfig: any) => {
        const timestamp = Date.now();
        const nonce = generateNonce();
        
        // 收集所有参数
        const params: Record<string, any> = {
            app_key: config.appKey,
            timestamp,
            nonce,
            ...requestConfig.params,
        };
        
        // Body 参数
        if (requestConfig.data && typeof requestConfig.data === 'object') {
            Object.assign(params, requestConfig.data);
        }
        
        // 生成签名
        const signature = generateSignature(params, config.appSecret);
        
        // 添加到 Header
        requestConfig.headers = {
            ...requestConfig.headers,
            'X-App-Key': config.appKey,
            'X-Timestamp': timestamp,
            'X-Nonce': nonce,
            'X-Signature': signature,
        };
        
        return requestConfig;
    };
}

function generateSignature(params: Record<string, any>, secret: string): string {
    // 1. 按 key 排序
    const sortedKeys = Object.keys(params).sort();
    
    // 2. 拼接字符串
    const queryString = sortedKeys
        .map((key) => `${key}=${params[key]}`)
        .join('&');
    
    // 3. 加上 secret
    const signString = `${queryString}&secret=${secret}`;
    
    // 4. HMAC-SHA256
    return CryptoJS.HmacSHA256(signString, secret).toString();
}

function generateNonce(length = 16): string {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    return Array.from({ length }, () => 
        chars.charAt(Math.floor(Math.random() * chars.length))
    ).join('');
}

5. 防重放攻击

5.1 攻击原理与防护

┌─────────────────────────────────────────────────────────────────────────┐
│                       重放攻击与防护                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  重放攻击 (Replay Attack):                                              │
│  ─────────────────────────                                              │
│  攻击者截获合法请求,原样重发以执行重复操作                               │
│                                                                         │
│  场景: 转账请求被重放,导致重复转账                                      │
│                                                                         │
│  防护方案:                                                              │
│  ───────────                                                            │
│                                                                         │
│  1. Timestamp + 有效期                                                  │
│     ─────────────────────                                               │
│     • 请求带 timestamp                                                 │
│     • 服务端检查: |服务器时间 - timestamp| < 5分钟                      │
│     • 超过有效期的请求拒绝                                              │
│                                                                         │
│  2. Nonce (随机数)                                                      │
│     ────────────────                                                    │
│     • 每次请求带唯一 nonce                                             │
│     • 服务端存储已使用的 nonce (Redis, 带过期时间)                     │
│     • 重复 nonce 的请求拒绝                                            │
│                                                                         │
│  3. Timestamp + Nonce (推荐组合)                                        │
│     ─────────────────────────                                           │
│     • 先检查 timestamp 有效期                                          │
│     • 再检查 nonce 是否使用过                                          │
│     • nonce 只需存储到 timestamp 过期                                  │
│                                                                         │
│  4. 幂等性设计                                                          │
│     ─────────────                                                       │
│     • 使用幂等 key (订单号)                                            │
│     • 相同 key 的请求只执行一次                                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

5.2 幂等 Key 实现

typescript
// idempotent.ts

// 生成幂等 Key
export function generateIdempotentKey(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

// 请求拦截器
export function setupIdempotentInterceptor(instance: AxiosInstance): void {
    instance.interceptors.request.use((config) => {
        // 仅对写操作添加幂等 Key
        if (['POST', 'PUT', 'PATCH'].includes(config.method?.toUpperCase() || '')) {
            // 如果没有手动设置,自动生成
            if (!config.headers['X-Idempotent-Key']) {
                config.headers['X-Idempotent-Key'] = generateIdempotentKey();
            }
        }
        return config;
    });
}

// 业务使用
async function createOrder(data: OrderData): Promise<Order> {
    // 生成订单幂等 Key (用户点击支付时生成,重试使用相同 Key)
    const idempotentKey = `order-${data.userId}-${Date.now()}`;
    
    return http.post('/api/orders', data, {
        headers: {
            'X-Idempotent-Key': idempotentKey,
        },
    });
}

6. 敏感数据保护

6.1 前端加密

typescript
// crypto.ts
import CryptoJS from 'crypto-js';

// RSA 加密敏感字段 (需要后端提供公钥)
const SERVER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;

// 密码加密 (RSA)
export async function encryptPassword(password: string): Promise<string> {
    // 使用 Web Crypto API
    const encoder = new TextEncoder();
    const data = encoder.encode(password);
    
    // 导入公钥
    const publicKey = await importPublicKey(SERVER_PUBLIC_KEY);
    
    // RSA 加密
    const encrypted = await crypto.subtle.encrypt(
        { name: 'RSA-OAEP' },
        publicKey,
        data
    );
    
    // Base64 编码
    return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
}

async function importPublicKey(pem: string): Promise<CryptoKey> {
    const pemContents = pem
        .replace('-----BEGIN PUBLIC KEY-----', '')
        .replace('-----END PUBLIC KEY-----', '')
        .replace(/\s/g, '');
    
    const binaryDer = atob(pemContents);
    const buffer = new Uint8Array(binaryDer.length);
    for (let i = 0; i < binaryDer.length; i++) {
        buffer[i] = binaryDer.charCodeAt(i);
    }
    
    return crypto.subtle.importKey(
        'spki',
        buffer,
        { name: 'RSA-OAEP', hash: 'SHA-256' },
        false,
        ['encrypt']
    );
}

// 登录时使用
async function login(username: string, password: string): Promise<LoginResult> {
    const encryptedPassword = await encryptPassword(password);
    
    return http.post('/api/auth/login', {
        username,
        password: encryptedPassword,
    });
}

6.2 敏感信息处理规范

┌─────────────────────────────────────────────────────────────────────────┐
│                    敏感信息处理规范                                      │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ❌ 不要做:                                                             │
│  ──────────                                                             │
│  • 明文存储密码到 localStorage                                          │
│  • 在 URL 参数中传递敏感信息                                            │
│  • 在 Console 打印敏感数据                                              │
│  • 硬编码 API Key 到前端代码                                            │
│  • 前端生成加密密钥                                                     │
│                                                                         │
│  ✅ 应该做:                                                             │
│  ──────────                                                             │
│  • 敏感操作使用 HTTPS                                                   │
│  • 密码传输前加密 (RSA)                                                 │
│  • Token 存储到 httpOnly Cookie (由后端设置)                            │
│  • 敏感 API 添加二次验证                                                │
│  • 脱敏展示 (手机号: 138****1234)                                       │
│                                                                         │
│  数据脱敏示例:                                                          │
│  ─────────────                                                          │
│  手机号: 13812341234 → 138****1234                                     │
│  邮箱: test@example.com → t***@example.com                             │
│  身份证: 110101199001011234 → 1101**********1234                       │
│  银行卡: 6222020600085692811 → 622202******2811                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

7. 面试高频问题

Q1: JWT 和 Session 的区别?

维度JWTSession
存储客户端服务端
扩展性无状态,易扩展需共享 Session
安全性无法主动失效可随时销毁
大小较大 (Payload)仅 Session ID
跨域天然支持需 CORS 配置

Q2: JWT 如何实现登出?

  1. 客户端删除 Token(无法阻止已签发 Token)
  2. Token 黑名单(服务端维护)
  3. 短过期时间 + Refresh Token
  4. 修改用户密钥(使所有 Token 失效)

Q3: 如何防止 Token 被盗?

  1. HTTPS 传输
  2. httpOnly Cookie 存储
  3. 短过期时间
  4. 绑定设备/IP(可选)
  5. 检测异常登录

Q4: API 签名的作用?

  1. 防止参数篡改
  2. 验证请求来源
  3. 配合 timestamp + nonce 防重放
  4. 审计追踪

Q5: OAuth 2.0 为什么需要 code 换 token?

  1. code 通过前端重定向传递,可能被截获
  2. token 通过后端获取,需要 client_secret
  3. client_secret 不能暴露在前端
  4. 即使 code 被截获,没有 secret 也无法获取 token

前端面试知识库