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×tamp=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 的区别?
| 维度 | JWT | Session |
|---|---|---|
| 存储 | 客户端 | 服务端 |
| 扩展性 | 无状态,易扩展 | 需共享 Session |
| 安全性 | 无法主动失效 | 可随时销毁 |
| 大小 | 较大 (Payload) | 仅 Session ID |
| 跨域 | 天然支持 | 需 CORS 配置 |
Q2: JWT 如何实现登出?
- 客户端删除 Token(无法阻止已签发 Token)
- Token 黑名单(服务端维护)
- 短过期时间 + Refresh Token
- 修改用户密钥(使所有 Token 失效)
Q3: 如何防止 Token 被盗?
- HTTPS 传输
- httpOnly Cookie 存储
- 短过期时间
- 绑定设备/IP(可选)
- 检测异常登录
Q4: API 签名的作用?
- 防止参数篡改
- 验证请求来源
- 配合 timestamp + nonce 防重放
- 审计追踪
Q5: OAuth 2.0 为什么需要 code 换 token?
- code 通过前端重定向传递,可能被截获
- token 通过后端获取,需要 client_secret
- client_secret 不能暴露在前端
- 即使 code 被截获,没有 secret 也无法获取 token