JWT鉴权机制与安全存储方案深度解析
JWT鉴权的安全边界
JWT(JSON Web Token)已经成为现代Web应用中最主流的身份认证方案之一。它的无状态特性让服务端无需维护会话信息,非常适合分布式架构和微服务场景。然而,JWT的安全边界在哪里?当令牌被窃取时,没有服务端会话可以销毁,攻击者可以一直使用令牌直到过期。
理解JWT的安全边界,意味着我们需要在令牌的设计、传输、存储和生命周期管理四个环节都做到位。
JWT核心安全机制
签名算法选型
| 算法 | 类型 | 密钥 | 性能 | 安全性 | 适用场景 |
|---|---|---|---|---|---|
| HS256 | 对称 | 共享密钥 | 快 | 中 | 单服务/内网 |
| RS256 | 非对称 | 公私钥对 | 慢(验签) | 高 | 微服务/开放API |
| ES256 | 非对称 | 椭圆曲线 | 快 | 高 | 移动端/性能敏感 |
| EdDSA | 非对称 | Ed25519 | 极快 | 极高 | 新一代推荐 |
// RS256 非对称签名示例 const crypto = require('crypto'); const jwt = require('jsonwebtoken'); // 生成公私钥对(仅一次) const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }); // 私钥签名(认证服务) function issueToken(payload) { return jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '1h', issuer: 'auth.example.com', jwtid: crypto.randomUUID() }); } // 公钥验签(资源服务) function verifyToken(token) { return jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer: 'auth.example.com' }); }Token结构设计
// 最小化Payload设计原则 // 不要在Payload中包含敏感信息 function createToken(user) { const payload = { sub: user.id, name: user.name, role: user.role, permissions: user.permissions, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600, jti: crypto.randomUUID(), iss: 'auth.example.com', aud: 'api.example.com' }; return jwt.sign(payload, process.env.PRIVATE_KEY, { algorithm: 'RS256' }); } // 好的Payload设计 const goodPayload = { sub: 'user_123456', name: '林蔓', role: 'editor', iat: 1680000000, exp: 1680003600, jti: '550e8400-e29b-41d4-a716-446655440000' }; // 错误的Payload设计:包含敏感信息 const badPayload = { sub: 'user_123456', name: '林蔓', email: 'linman@example.com', ssn: '123-45-6789', internal_note: '高风险用户,流水可疑', db_connection: 'postgres://admin:password@db:5432/prod' };Token存储安全方案
分层存储策略
class SecureTokenManager { constructor() { // 内存Token(仅当前会话有效) this.sessionToken = null; this.tokenExpiry = null; } async setTokens(accessToken, refreshToken) { this.sessionToken = accessToken; this.tokenExpiry = this.decodeToken(accessToken).exp * 1000; // Refresh Token使用加密存储 await this.storeRefreshToken(refreshToken); } async storeRefreshToken(token) { const encrypted = await this.encryptWithServerKey(token); sessionStorage.setItem('refresh_token', encrypted); } async getRefreshToken() { const encrypted = sessionStorage.getItem('refresh_token'); if (!encrypted) return null; try { return await this.decryptWithServerKey(encrypted); } catch { return null; } } async encryptWithServerKey(data) { const encoder = new TextEncoder(); const keyData = await this.getServerPublicKey(); const key = await crypto.subtle.importKey( 'raw', keyData, { name: 'AES-GCM' }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, encoder.encode(data) ); const combined = new Uint8Array(iv.length + encrypted.byteLength); combined.set(iv); combined.set(new Uint8Array(encrypted), iv.length); return btoa(String.fromCharCode(...combined)); } async getServerPublicKey() { const response = await fetch('/api/auth/public-key'); const { key } = await response.json(); return Uint8Array.from(atob(key), c => c.charCodeAt(0)); } decodeToken(token) { try { return JSON.parse(atob(token.split('.')[1])); } catch { return null; } } getAccessToken() { if (!this.sessionToken) return null; if (Date.now() >= this.tokenExpiry) { return null; } return this.sessionToken; } clearTokens() { this.sessionToken = null; this.tokenExpiry = null; sessionStorage.removeItem('refresh_token'); } }Token注入防护
class XSSProtectedApp { constructor() { this.tokenManager = new SecureTokenManager(); this.setupTrustedTypes(); } setupTrustedTypes() { if (window.trustedTypes && window.trustedTypes.createPolicy) { window.trustedTypes.createPolicy('token-policy', { createHTML: (input) => { return input.replace(/[<>&"']/g, ''); }, createScriptURL: (input) => { const allowed = ['/api/', '/static/']; if (allowed.some(prefix => input.startsWith(prefix))) { return input; } throw new Error('不允许的脚本URL'); } }); } } sanitizeInput(input) { const div = document.createElement('div'); div.textContent = input; return div.innerHTML; } async makeAuthenticatedRequest(url, options = {}) { const token = this.tokenManager.getAccessToken(); if (!token) { await this.refreshAccessToken(); } const response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${this.tokenManager.getAccessToken()}` }, // Content Security Policy 头已在服务端设置 }); if (response.status === 401) { await this.handleUnauthorized(); } return response; } async refreshAccessToken() { const refreshToken = await this.tokenManager.getRefreshToken(); if (!refreshToken) { this.redirectToLogin(); return; } const response = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }) }); if (response.ok) { const { accessToken, refreshToken: newRefreshToken } = await response.json(); await this.tokenManager.setTokens(accessToken, newRefreshToken); } else { this.redirectToLogin(); } } redirectToLogin() { this.tokenManager.clearTokens(); const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); window.location.href = `/login?return_url=${returnUrl}`; } }Refresh Token轮换机制
class RefreshTokenRotation { constructor() { this.usedTokens = new Set(); this.redis = null; } async init() { const Redis = require('ioredis'); this.redis = new Redis({ host: process.env.REDIS_HOST, port: 6379, enableOfflineQueue: false }); } async issueTokens(userId, deviceInfo) { const familyId = crypto.randomUUID(); const accessToken = jwt.sign( { sub: userId, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '15m' } ); const refreshToken = jwt.sign( { sub: userId, type: 'refresh', family: familyId, tokenId: crypto.randomUUID() }, process.env.REFRESH_SECRET, { expiresIn: '7d' } ); await this.redis.set( `refresh_family:${familyId}`, JSON.stringify({ userId, deviceInfo, active: true }), 'EX', 7 * 24 * 3600 ); return { accessToken, refreshToken }; } async rotate(refreshToken) { const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET); if (decoded.type !== 'refresh') { throw new Error('无效的令牌类型'); } const familyKey = `refresh_family:${decoded.family}`; const familyData = await this.redis.get(familyKey); if (!familyData) { throw new Error('刷新令牌已过期'); } const family = JSON.parse(familyData); if (!family.active) { await this.revokeFamily(decoded.family, decoded.sub); throw new Error('令牌轮换冲突,检测到安全威胁'); } const usedKey = `used_refresh:${decoded.tokenId}`; const isUsed = await this.redis.setnx(usedKey, '1'); if (isUsed === 0) { await this.revokeFamily(decoded.family, decoded.sub); throw new Error('令牌重用检测,已吊销令牌家族'); } await this.redis.expire(usedKey, 7 * 24 * 3600); await this.redis.set(familyKey, JSON.stringify({ ...family, active: false }), 'EX', 60); return this.issueTokens(decoded.sub, family.deviceInfo); } async revokeFamily(familyId, userId) { const key = `refresh_family:${familyId}`; const familyData = await this.redis.get(key); if (familyData) { const family = JSON.parse(familyData); family.active = false; await this.redis.set(key, JSON.stringify(family), 'EX', 3600); } await this.blacklistUserTokens(userId); } async blacklistUserTokens(userId) { const blacklistKey = `token_blacklist:${userId}`; const currentBlacklist = await this.redis.get(blacklistKey) || '[]'; const blacklist = JSON.parse(currentBlacklist); const entry = { timestamp: Date.now(), reason: 'refresh_token_reuse' }; blacklist.push(entry); if (blacklist.length > 100) { blacklist.shift(); } await this.redis.set(blacklistKey, JSON.stringify(blacklist), 'EX', 86400); } }服务端Token黑名单
class TokenBlacklist { constructor() { this.cache = new Map(); this.cleanupInterval = setInterval(() => this.cleanup(), 60000); } async blacklist(jti, expiresIn) { const entry = { jti, blacklistedAt: Date.now(), expiresAt: Date.now() + expiresIn }; this.cache.set(jti, entry); } async isBlacklisted(jti) { const entry = this.cache.get(jti); if (!entry) return false; if (Date.now() > entry.expiresAt) { this.cache.delete(jti); return false; } return true; } cleanup() { const now = Date.now(); for (const [jti, entry] of this.cache) { if (now > entry.expiresAt) { this.cache.delete(jti); } } } destroy() { clearInterval(this.cleanupInterval); this.cache.clear(); } }安全审计与监控
class SecurityAudit { constructor() { this.events = []; this.anomalyThresholds = { maxLoginAttempts: 5, maxTokenRefreshes: 20, suspiciousWindow: 300000 }; } logEvent(event) { this.events.push({ ...event, timestamp: Date.now() }); if (this.events.length > 10000) { this.events.shift(); } if (this.detectAnomaly(event)) { this.triggerAlert(event); } } detectAnomaly(event) { const window = Date.now() - this.anomalyThresholds.suspiciousWindow; const recentEvents = this.events.filter(e => e.timestamp > window); switch (event.type) { case 'login_failure': const loginFailures = recentEvents.filter( e => e.type === 'login_failure' && e.ip === event.ip ); return loginFailures.length >= this.anomalyThresholds.maxLoginAttempts; case 'token_refresh': const refreshes = recentEvents.filter( e => e.type === 'token_refresh' && e.userId === event.userId ); return refreshes.length >= this.anomalyThresholds.maxTokenRefreshes; case 'different_geo': return event.distance > 1000; default: return false; } } triggerAlert(event) { console.warn('安全告警:', { type: event.type, userId: event.userId, ip: event.ip, timestamp: new Date(event.timestamp).toISOString(), detail: event.detail }); } }安全最佳实践总结
| 安全措施 | 实施级别 | 说明 |
|---|---|---|
| 非对称签名(RS256/ES256) | 高 | 避免共享密钥泄露风险 |
| 短生命周期Access Token | 高 | 15-30分钟,减少被盗窗口 |
| Refresh Token轮换 | 高 | 检测并阻止令牌重放 |
| Token绑定设备指纹 | 中 | 绑定User-Agent/IP范围 |
| 加密存储Refresh Token | 中 | 使用Web Crypto API |
| 内容安全策略(CSP) | 高 | 阻止XSS攻击窃取令牌 |
| Token黑名单机制 | 中 | 支持服务端主动吊销 |
| 异常行为检测 | 高 | 实时监控令牌使用模式 |
JWT的安全不在于算法本身,而在于整个认证链路的防护。从Token的签发、传输、存储到吊销,每个环节都需要精心设计。合理的令牌生命周期管理结合设备指纹、异常检测等辅助手段,可以在无状态认证的便利性和安全性之间找到最佳平衡点。