/* token.js — 双 Token 认证助手 * * - access token:存 localStorage(短期),每次请求以 `Authorization: Bearer` 发送 * - refresh token:由服务端放在 httpOnly cookie 里,前端不可读;过期时 * POST /api/auth/refresh 换取新 access(服务端同时轮换 refresh cookie) * - 401 处理:并发 401 共享一次刷新(single-flight),刷新成功后重试一次原请求; * 刷新失败则清空本地 token 并跳转登录页 * - SSE(EventSource 无法带自定义 header)通过一次性 ticket 建立,见 display.js */ (function (global) { 'use strict'; var KEY = 'dashboard_access_token'; function getAccess() { try { return localStorage.getItem(KEY) || ''; } catch (e) { return ''; } } function setAccess(t) { try { if (t) { localStorage.setItem(KEY, t); } else { localStorage.removeItem(KEY); } } catch (e) {} } function clear() { setAccess(''); } function _detectView() { var p = location.pathname; if (p.indexOf('/control') === 0 || p.indexOf('/mfa-setup') === 0) return 'control'; return 'display'; } function loginRedirect(view) { clear(); var next = encodeURIComponent(location.pathname + location.search); location.href = '/login?view=' + (view || _detectView()) + '&next=' + next; } // 并发 401 共享同一次刷新(避免同时打多个 refresh 导致 token 轮换竞争) var _refreshing = null; function refresh() { if (!_refreshing) { _refreshing = new Promise(function (resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/auth/refresh'); xhr.onload = function () { if (xhr.status === 200) { try { var d = JSON.parse(xhr.responseText || '{}'); if (d.access_token) { setAccess(d.access_token); resolve(d.access_token); return; } } catch (e) {} } reject(new Error('refresh-failed')); }; xhr.onerror = function () { reject(new Error('network')); }; xhr.send(); }); _refreshing.then(function () { _refreshing = null; }, function () { _refreshing = null; }); } return _refreshing; } /** * 统一请求封装(XHR,回调风格) * request(method, url, data, callback, opts) * - data: 对象或 FormData;null/undefined 表示无 body * - opts.view: 跳转登录页时使用的 view(默认按当前路径推断) * - callback(res, status): 收到响应(含 401 重试后的最终结果) */ function request(method, url, data, callback, opts) { opts = opts || {}; var view = opts.view || _detectView(); function doSend(access, retried) { var xhr = new XMLHttpRequest(); xhr.open(method, url); if (access) xhr.setRequestHeader('Authorization', 'Bearer ' + access); if (data && !(data instanceof FormData)) { xhr.setRequestHeader('Content-Type', 'application/json'); } xhr.onload = function () { var res = {}; try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) {} if (xhr.status === 401) { if (!retried) { var fail = function () { if (opts.noRedirect) { if (callback) callback(res, xhr.status); return; } // 多 tab 竞态:另一个 tab 可能刚轮换过共享的 refresh cookie, // 短暂延迟后用新 cookie 再试一次,仍失败才判定会话失效。 setTimeout(function () { refresh().then(function () { doSend(getAccess(), true); }, function () { loginRedirect(view); }); }, 300); }; refresh().then(function () { doSend(getAccess(), true); }, fail); return; } // 刷新后重试仍 401 → 会话确实失效(如已被踢),跳转登录页 if (!opts.noRedirect) { loginRedirect(view); return; } if (callback) callback(res, xhr.status); return; } if (callback) callback(res, xhr.status); }; xhr.onerror = function () { if (callback) callback({}, 0); }; if (data instanceof FormData) xhr.send(data); else if (data) xhr.send(JSON.stringify(data)); else xhr.send(); } doSend(getAccess(), false); } /** Promise 风格的 fetch 封装(带 Authorization,用于登录页/Passkey 桥等) */ function fetchWithAuth(url, options) { options = options || {}; var access = getAccess(); var headers = Object.assign({}, options.headers || {}); if (access && !headers['Authorization']) headers['Authorization'] = 'Bearer ' + access; return fetch(url, Object.assign({}, options, { headers: headers, credentials: 'same-origin', })); } /** 主动登出:清本地 access + 通知服务端吊销 refresh cookie(同时吊销本 access) */ function logout(cb) { var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/auth/logout'); var access = getAccess(); if (access) xhr.setRequestHeader('Authorization', 'Bearer ' + access); xhr.onload = function () { clear(); if (cb) cb(); }; xhr.onerror = function () { clear(); if (cb) cb(); }; xhr.send(); } global.Auth = { getAccess: getAccess, setAccess: setAccess, clear: clear, refresh: refresh, request: request, fetchWithAuth: fetchWithAuth, logout: logout, loginRedirect: loginRedirect, }; })(window);