JWT令牌在多端跨域下实现WebSocket安全重连机制
JWT令牌在多端跨域下实现WebSocket安全重连机制一、跨域WebSocket的重连难题WebSocket 在跨域场景下建立连接已经比同源复杂再加上 JWT 令牌的过期机制重连时的认证变得更具挑战。典型的问题是当Token过期时WebSocket连接被强制断开客户端需要获取新Token后重新建立连接——但获取新Token本身可能也需要登录态。在多端跨域场景下Web端、移动端H5、小程序每个端的Token存储和刷新策略各不相同WebSocket 重连时的认证方案需要同时兼顾安全性和无缝体验。二、多端环境分析端类型Token存储WebSocket API跨域限制重连方案Web桌面端httpOnly Cookie / localStorage原生WebSocketCORS配置Cookie自动携带 / Header手动Web移动端localStorage原生WebSocketCORS配置Header手动微信小程序小程序Storagewx.connectSocket白名单参数传递移动端App原生安全存储原生WebSocket无拦截器自动处理iframe嵌入postMessage传递受限严格消息通道三、服务端架构设计3.1 统一的Token颁发与验证const jwt require(jsonwebtoken); const WebSocket require(ws); const express require(express); const app express(); // Token配置 const TOKEN_CONFIG { accessTokenSecret: process.env.ACCESS_TOKEN_SECRET, refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET, wsTokenSecret: process.env.WS_TOKEN_SECRET, accessTokenExpiry: 15m, refreshTokenExpiry: 7d, wsTokenExpiry: 5m }; // 颁发多用途Token function issueTokens(user) { const accessToken jwt.sign( { sub: user.id, type: access, role: user.role }, TOKEN_CONFIG.accessTokenSecret, { expiresIn: TOKEN_CONFIG.accessTokenExpiry } ); const refreshToken jwt.sign( { sub: user.id, type: refresh }, TOKEN_CONFIG.refreshTokenSecret, { expiresIn: TOKEN_CONFIG.refreshTokenExpiry } ); const wsToken jwt.sign( { sub: user.id, type: websocket, origin: cross-domain }, TOKEN_CONFIG.wsTokenSecret, { expiresIn: TOKEN_CONFIG.wsTokenExpiry } ); return { accessToken, refreshToken, wsToken }; } // 登录接口 app.post(/api/auth/login, async (req, res) { const user await authenticateUser(req.body); const tokens issueTokens(user); res.json({ ...tokens, user: { id: user.id, name: user.name, role: user.role } }); });3.2 WebSocket连接管理器const WebSocket require(ws); const url require(url); class CrossDomainWSServer { constructor(port) { this.wss new WebSocket.Server({ port }); this.connections new Map(); this.setup(); } setup() { this.wss.on(connection, (ws, req) { const origin req.headers.origin || unknown; const parsedUrl url.parse(req.url, true); const token parsedUrl.query.token; if (!token) { ws.close(4001, 缺少令牌); return; } try { const decoded jwt.verify(token, TOKEN_CONFIG.wsTokenSecret); if (decoded.type ! websocket) { ws.close(4001, 无效的令牌类型); return; } ws.userId decoded.sub; ws.connectedAt Date.now(); ws.origin origin; this.connections.set(ws.userId, ws); ws.send(JSON.stringify({ type: auth_success, userId: ws.userId, serverTime: Date.now() })); ws.on(message, (data) this.handleMessage(ws, data)); ws.on(close, () this.handleDisconnect(ws)); console.log(用户 ${ws.userId} 从 ${origin} 连接); } catch (error) { if (error.name TokenExpiredError) { ws.close(4001, WS_TOKEN_EXPIRED); } else { ws.close(4001, 无效的令牌); } } }); } handleMessage(ws, data) { try { const message JSON.parse(data); switch (message.type) { case token_renew: this.handleTokenRenew(ws, message); break; case ping: ws.send(JSON.stringify({ type: pong })); break; default: this.handleBusinessMessage(ws, message); } } catch { ws.send(JSON.stringify({ type: error, message: 消息格式错误 })); } } handleTokenRenew(ws, message) { try { const decoded jwt.verify( message.refreshToken, TOKEN_CONFIG.refreshTokenSecret ); if (decoded.sub ! ws.userId) { ws.close(4001, 令牌用户不匹配); return; } const newWsToken jwt.sign( { sub: ws.userId, type: websocket, origin: ws.origin }, TOKEN_CONFIG.wsTokenSecret, { expiresIn: TOKEN_CONFIG.wsTokenExpiry } ); ws.send(JSON.stringify({ type: token_renewed, token: newWsToken, expiresIn: 300 })); } catch { ws.close(4001, REFRESH_TOKEN_INVALID); } } handleDisconnect(ws) { this.connections.delete(ws.userId); console.log(用户 ${ws.userId} 断开连接); } }四、客户端重连引擎4.1 Web端完整实现class CrossPlatformWSClient { constructor(options) { this.options { wsUrl: options.wsUrl, getAccessToken: options.getAccessToken, getRefreshToken: options.getRefreshToken, setTokens: options.setTokens, onMessage: options.onMessage || (() {}), platform: options.platform || web, ...options }; this.reconnectAttempts 0; this.connectionId null; this.connect(); } async connect() { const wsToken await this.fetchWsToken(); if (!wsToken) { this.emitError(无法获取WebSocket令牌); return; } const wsUrl ${this.options.wsUrl}?token${wsToken}; if (this.ws) { this.ws.close(); } this.ws new WebSocket(wsUrl); this.ws.onopen () { this.reconnectAttempts 0; this.startTokenMonitor(); this.emit(connected); }; this.ws.onmessage (event) { const message JSON.parse(event.data); this.handleMessage(message); }; this.ws.onclose (event) { this.stopTokenMonitor(); if (event.code 4001) { this.handleAuthClose(event.reason); } else { this.scheduleReconnect(); } }; this.ws.onerror () { this.scheduleReconnect(); }; } async fetchWsToken() { const accessToken await this.options.getAccessToken(); if (!accessToken) { return null; } try { const response await fetch(/api/ws/token, { headers: { Authorization: Bearer ${accessToken}, X-Platform: this.options.platform } }); if (!response.ok) { return null; } const data await response.json(); return data.wsToken; } catch { return null; } } handleAuthClose(reason) { switch (reason) { case WS_TOKEN_EXPIRED: this.refreshAndReconnect(); break; case REFRESH_TOKEN_INVALID: this.handleSessionExpired(); break; default: this.scheduleReconnect(); } } async refreshAndReconnect() { const refreshToken await this.options.getRefreshToken(); if (refreshToken this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: token_renew, refreshToken })); return; } const newWsToken await this.fetchWsToken(); if (newWsToken) { this.connect(); } else { this.handleSessionExpired(); } } handleMessage(message) { switch (message.type) { case auth_success: this.connectionId message.connectionId; this.emit(authenticated, message); break; case token_renewed: this.emit(token_renewed, message); break; case pong: this.lastPongTime Date.now(); break; default: this.options.onMessage(message); } } startTokenMonitor() { this.tokenCheckInterval setInterval(async () { const token await this.options.getAccessToken(); if (token this.isTokenExpiring(token)) { this.refreshWsToken(); } }, 60000); } isTokenExpiring(token) { try { const payload JSON.parse(atob(token.split(.)[1])); return payload.exp - Math.floor(Date.now() / 1000) 60; } catch { return true; } } async refreshWsToken() { const newWsToken await this.fetchWsToken(); if (newWsToken this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: ws_token_refresh, token: newWsToken })); } } scheduleReconnect() { const delay Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts; setTimeout(() { this.connect(); }, delay); } handleSessionExpired() { this.emit(session_expired); this.disconnect(); } disconnect() { this.stopTokenMonitor(); if (this.ws) { this.ws.close(1000, 用户断开); } } emit(event, data) { if (this.options[on${event.charAt(0).toUpperCase() event.slice(1)}]) { this.options[on${event.charAt(0).toUpperCase() event.slice(1)}](data); } } emitError(message) { if (this.options.onError) { this.options.onError(new Error(message)); } } }4.2 各端适配器// Web端适配器 class WebAdapter { constructor() { this.tokenManager new TokenManager(); } async getAccessToken() { return localStorage.getItem(access_token); } async getRefreshToken() { return localStorage.getItem(refresh_token); } async setTokens(accessToken, refreshToken) { localStorage.setItem(access_token, accessToken); localStorage.setItem(refresh_token, refreshToken); } } // 小程序端适配器 class MiniProgramAdapter { constructor() { this.tokenManager new TokenManager(); } async getAccessToken() { return new Promise((resolve) { wx.getStorage({ key: access_token, success: (res) resolve(res.data), fail: () resolve(null) }); }); } async getRefreshToken() { return new Promise((resolve) { wx.getStorage({ key: refresh_token, success: (res) resolve(res.data), fail: () resolve(null) }); }); } createWebSocket(url) { const task wx.connectSocket({ url }); return task; } } // iframe嵌入端适配器 class IframeAdapter { constructor() { this.pendingRequests new Map(); this.setupMessageListener(); } setupMessageListener() { window.addEventListener(message, (event) { if (event.data.type ws_token_response) { const resolver this.pendingRequests.get(event.data.requestId); if (resolver) { resolver(event.data.token); this.pendingRequests.delete(event.data.requestId); } } }); } async getAccessToken() { return new Promise((resolve) { const requestId crypto.randomUUID(); this.pendingRequests.set(requestId, resolve); window.parent.postMessage({ type: ws_token_request, requestId }, *); setTimeout(() resolve(null), 5000); }); } async getRefreshToken() { return null; } async setTokens() { // iframe 无权操作父页面Token } }五、统一的重连状态机class ReconnectStateMachine { constructor() { this.states { DISCONNECTED: disconnected, CONNECTING: connecting, AUTHENTICATING: authenticating, CONNECTED: connected, TOKEN_EXPIRING: token_expiring, RENEWING_TOKEN: renewing_token, RECONNECTING: reconnecting }; this.currentState this.states.DISCONNECTED; this.transitions []; this.listeners new Set(); } transition(newState) { const from this.currentState; this.currentState newState; this.transitions.push({ from, to: newState, timestamp: Date.now() }); for (const listener of this.listeners) { listener({ from, to: newState, state: newState }); } } canTransition(to) { const valid { [this.states.DISCONNECTED]: [this.states.CONNECTING], [this.states.CONNECTING]: [this.states.AUTHENTICATING, this.states.DISCONNECTED], [this.states.AUTHENTICATING]: [this.states.CONNECTED, this.states.DISCONNECTED], [this.states.CONNECTED]: [this.states.TOKEN_EXPIRING, this.states.DISCONNECTED], [this.states.TOKEN_EXPIRING]: [this.states.RENEWING_TOKEN, this.states.DISCONNECTED], [this.states.RENEWING_TOKEN]: [this.states.CONNECTED, this.states.DISCONNECTED], [this.states.RECONNECTING]: [this.states.CONNECTING, this.states.DISCONNECTED] }; return valid[this.currentState]?.includes(to) || false; } onStateChange(callback) { this.listeners.add(callback); return () this.listeners.delete(callback); } getState() { return this.currentState; } getTransitionHistory(limit 10) { return this.transitions.slice(-limit); } }六、最佳实践总结实践要点推荐方案说明Token分离API Token WS TokenWS使用短期独立Token减少泄露风险主动刷新Token过期前60s预刷新避免连接强制断开重连认证先HTTP刷新再WS重连不在WS帧中传递长期Token状态同步重连后增量同步避免全量数据重新传输端适配抽象适配器层统一接口各端独立实现安全审计记录连接和Token事件便于排查安全问题和调试多端跨域 WebSocket 的安全重连需要在认证架构上做分层设计。核心思路是将API认证Token与WebSocket连接Token分离通过独立的HTTP端点获取短期WS Token在WS连接生命周期内通过心跳层完成Token的静默刷新连接断开时先刷新API Token再获取新的WS Token重建连接。这套方案能够同时满足安全性最小化Token暴露窗口和体验用户无感知重连。