JWT令牌在多端跨域场景下的安全访问校验实践
![]()
跨域认证的挑战
现代前端应用往往需要服务于多个端:Web端、移动端H5、小程序、第三方嵌入等。不同端的域名、运行环境和存储策略各不相同,如何在保证安全的前提下实现统一的身份认证,是每个前端架构师都需要面对的问题。
JWT(JSON Web Token)因其无状态、跨语言、可自包含的特点,成为跨域认证的首选方案。但在实际落地中,令牌的传输、存储和校验涉及诸多安全细节。
JWT结构回顾
| 组成部分 | 内容 | 说明 |
|---|
| Header | {"alg":"HS256","typ":"JWT"} | 签名算法和令牌类型 |
| Payload | {"sub":"123","name":"林蔓","iat":1516239022} | 声明数据 |
| Signature | HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload), secret) | 防篡改签名 |
// JWT 生成示例(服务端) const jwt = require('jsonwebtoken'); const token = jwt.sign( { sub: 'user_123456', name: '林蔓', role: 'admin', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.JWT_SECRET, { algorithm: 'HS256' } );
多端跨域场景分析
| 场景 | 域名 | 存储方式 | 传输方式 | 安全级别 |
|---|
| 主站Web | example.com | httpOnly Cookie | Cookie自动携带 | 高 |
| 子域名 | sub.example.com | httpOnly Cookie(domain设置) | Cookie自动携带 | 高 |
| 完全跨域 | other.com | localStorage | Authorization Header | 中 |
| 移动端App | - | 原生安全存储 | Authorization Header | 中 |
| 第三方嵌入 | embed.com | iframe + postMessage | Authorization Header | 低 |
令牌传输方案对比
Cookie方案(同域/子域)
// 服务端设置httpOnly Cookie const cookieOptions = { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600 * 1000, path: '/' }; // 子域共享Cookie app.use((req, res, next) => { const token = generateToken(req.user); res.cookie('token', token, { ...cookieOptions, domain: '.example.com' }); next(); });
// 前端无需手动处理Cookie,请求自动携带 fetch('/api/user/profile', { credentials: 'include' }).then(res => res.json());
Authorization Header方案(跨域)
// 前端存储并发送Token class TokenManager { static async getToken() { let token = localStorage.getItem('access_token'); if (this.isTokenExpired(token)) { token = await this.refreshToken(); } return token; } static isTokenExpired(token) { if (!token) return true; try { const payload = JSON.parse(atob(token.split('.')[1])); return payload.exp * 1000 < Date.now(); } catch { return true; } } static async refreshToken() { const refreshToken = localStorage.getItem('refresh_token'); const response = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('access_token')}` }, body: JSON.stringify({ refreshToken }) }); if (response.ok) { const { accessToken, refreshToken: newRefreshToken } = await response.json(); localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', newRefreshToken); return accessToken; } this.redirectToLogin(); return null; } static redirectToLogin() { localStorage.clear(); const currentPath = encodeURIComponent(window.location.pathname); window.location.href = `/login?redirect=${currentPath}`; } } // 请求拦截器自动注入Token async function apiRequest(url, options = {}) { const token = await TokenManager.getToken(); const response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}` } }); if (response.status === 401) { const newToken = await TokenManager.refreshToken(); if (newToken) { return apiRequest(url, options); } } return response; }
iframe嵌入场景(postMessage)
// 父页面(主站) const childFrame = document.getElementById('embedded-app'); function sendTokenToChild() { const token = localStorage.getItem('access_token'); childFrame.contentWindow.postMessage({ type: 'AUTH_TOKEN', payload: { token } }, 'https://child.example.com'); } window.addEventListener('message', (event) => { if (event.origin !== 'https://child.example.com') return; if (event.data.type === 'TOKEN_RECEIVED') { console.log('子页面认证成功'); } }); // 子页面(嵌入应用) window.addEventListener('message', async (event) => { if (event.origin !== 'https://parent.example.com') return; if (event.data.type === 'AUTH_TOKEN') { const { token } = event.data.payload; sessionStorage.setItem('access_token', token); event.source.postMessage({ type: 'TOKEN_RECEIVED' }, event.origin); } });
安全存储策略对比
| 存储方式 | XSS防护 | CSRF防护 | 持久性 | 跨域共享 |
|---|
| httpOnly Cookie | 自动防护(JS不可读) | 需SameSite/CSRF Token | 可设置持久 | 子域共享 |
| localStorage | 易受XSS攻击 | 无需CSRF防护(非自动携带) | 持久 | 不可跨域 |
| sessionStorage | 易受XSS攻击 | 无需CSRF防护 | 会话级 | 不可跨域 |
| Memory(变量) | 安全 | 安全 | 页面刷新丢失 | 不可 |
| IndexedDB | 易受XSS攻击 | 无需CSRF防护 | 持久 | 不可跨域 |
增强的Token存储方案:双重令牌机制
class SecureTokenStore { constructor() { this.memoryToken = null; } async init() { const token = await this.loadToken(); if (token) { this.memoryToken = token; this.clearPersistentStore(); } } async loadToken() { const encrypted = sessionStorage.getItem('encrypted_token'); if (!encrypted) return null; try { const token = await this.decrypt(encrypted); return token; } catch { return null; } } async saveToken(token) { this.memoryToken = token; const encrypted = await this.encrypt(token); sessionStorage.setItem('encrypted_token', encrypted); } async encrypt(token) { const encoder = new TextEncoder(); const data = encoder.encode(token); const key = await this.getOrCreateKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, 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 decrypt(encoded) { const combined = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)); const iv = combined.slice(0, 12); const data = combined.slice(12); const key = await this.getOrCreateKey(); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, data ); return new TextDecoder().decode(decrypted); } async getOrCreateKey() { const existing = sessionStorage.getItem('crypto_key'); if (existing) { const keyData = Uint8Array.from(atob(existing), c => c.charCodeAt(0)); return crypto.subtle.importKey('raw', keyData, 'AES-GCM', false, ['encrypt', 'decrypt']); } const key = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); const exported = await crypto.subtle.exportKey('raw', key); sessionStorage.setItem('crypto_key', btoa(String.fromCharCode(...new Uint8Array(exported)))); return key; } getToken() { return this.memoryToken; } clearPersistentStore() { sessionStorage.removeItem('encrypted_token'); sessionStorage.removeItem('crypto_key'); } clear() { this.memoryToken = null; this.clearPersistentStore(); } }
服务端校验中间件
const jwt = require('jsonwebtoken'); function authMiddleware(options = {}) { const { extractors = [ req => req.cookies?.token, req => req.headers.authorization?.replace('Bearer ', ''), req => req.headers['x-access-token'] ], algorithms = ['HS256', 'RS256'] } = options; return async (req, res, next) => { let token = null; for (const extractor of extractors) { token = extractor(req); if (token) break; } if (!token) { return res.status(401).json({ code: 'UNAUTHORIZED', message: '未提供令牌' }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms, issuer: 'csdn-users', clockTolerance: 30 }); req.user = decoded; if (options.allowRefresh && this.shouldRefresh(decoded)) { const newToken = jwt.sign( { ...decoded, iat: Math.floor(Date.now() / 1000) }, process.env.JWT_SECRET, { expiresIn: '1h' } ); res.setHeader('X-New-Token', newToken); } next(); } catch (error) { if (error.name === 'TokenExpiredError') { return res.status(401).json({ code: 'TOKEN_EXPIRED', message: '令牌已过期', expiredAt: error.expiredAt }); } return res.status(401).json({ code: 'INVALID_TOKEN', message: '无效的令牌' }); } }; }
CORS配置与凭证处理
const cors = require('cors'); const allowedOrigins = [ 'https://www.example.com', 'https://sub.example.com', 'https://embed.example.com' ]; app.use(cors({ origin: (origin, callback) => { if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('不允许的跨域来源')); } }, credentials: true, allowedHeaders: ['Content-Type', 'Authorization', 'X-Refresh-Token'], exposedHeaders: ['X-New-Token', 'X-Token-Expired'], maxAge: 86400 }));
刷新令牌轮换机制
class TokenRotationManager { constructor() { this.usedRefreshTokens = new Set(); } async rotate(refreshToken, userId) { if (this.usedRefreshTokens.has(refreshToken)) { await this.revokeAllUserTokens(userId); throw new Error('令牌重用检测,已吊销所有令牌'); } const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET); if (decoded.sub !== userId) { throw new Error('刷新令牌用户不匹配'); } this.usedRefreshTokens.add(refreshToken); const newAccessToken = jwt.sign( { sub: userId }, process.env.JWT_SECRET, { expiresIn: '1h' } ); const newRefreshToken = jwt.sign( { sub: userId, jti: crypto.randomUUID() }, process.env.REFRESH_SECRET, { expiresIn: '7d' } ); return { accessToken: newAccessToken, refreshToken: newRefreshToken }; } async revokeAllUserTokens(userId) { console.log(`吊销用户 ${userId} 的所有令牌`); } }
多端登录状态同步
const ws = new WebSocket('wss://api.example.com/ws'); ws.addEventListener('message', (event) => { const message = JSON.parse(event.data); if (message.type === 'TOKEN_REVOKED') { TokenManager.clearTokens(); showNotification('您的账号在其他设备登录'); setTimeout(() => { window.location.href = '/login'; }, 3000); } });
总结
| 安全实践 | 重要程度 | 说明 |
|---|
| httpOnly Cookie优先 | 高 | 从根源防范XSS窃取 |
| Token有效期控制 | 高 | Access Token 1h内,Refresh Token动态轮换 |
| 刷新令牌轮换 | 高 | 一旦使用旧refresh token,吊销全部令牌 |
| CORS白名单 | 中 | 明确允许的跨域来源 |
| Token加密存储 | 中 | localStorage加密后再存储 |
| 多来源提取Token | 中 | Cookie/Header多种方式兼容 |
| SameSite策略 | 高 | 防止CSRF攻击 |
| 令牌重用检测 | 高 | 发现异常立即吊销 |
跨域认证没有银弹,每种方案都有其适用场景和局限性。关键是理解不同存储和传输方式的安全特性,根据业务需求选择合适的组合方案,并在开发过程中始终将安全放在首位。