Flask Session -> Double Token
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/* 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);
|
||||
Reference in New Issue
Block a user