refactor: auth expiry - sync JWT exp check, remove async validation
Previous async validateAuth() approach caused: recursion loop, wrong /me path (404 -> session cleared), init() fetching auth-required settings without token (cleared session every load). New synchronous approach: - Auth.expired(): decodes JWT payload locally, checks exp timestamp - Auth.logged(): token+user exist AND not expired; auto-clears stale session on expiry (no network, no async, no loops) - route(): simple sync guard - if !Auth.logged() reset + redirect to #/login; home page stays public (clears stale cookie only) - API.req: 401 response -> Auth.reset() + redirect to login unless on public page (fallback for server-side revocation/invalid signature) - Removed validateAuth/_validating/_doRoute entirely
This commit is contained in:
@@ -34,6 +34,12 @@ const API = {
|
|||||||
const ct = res.headers.get('content-type');
|
const ct = res.headers.get('content-type');
|
||||||
if (ct && ct.includes('application/json')) {
|
if (ct && ct.includes('application/json')) {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
if (res.status === 401) {
|
||||||
|
// 服务端判定未登录/过期: 清除本地登录态, 非公开页跳转登录
|
||||||
|
Auth.reset();
|
||||||
|
const page = (location.hash || '#/home').replace('#/', '').split('/')[0];
|
||||||
|
if (!['home', 'login', 'register', 'forgot', 'reset', 'verify', 'install'].includes(page)) location.hash = '#/login';
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(json.error || '请求失败');
|
if (!res.ok) throw new Error(json.error || '请求失败');
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,28 +86,6 @@ const App = {
|
|||||||
closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); },
|
closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); },
|
||||||
navigate(page, param) { location.hash = `#/${page}` + (param ? `/${param}` : ''); },
|
navigate(page, param) { location.hash = `#/${page}` + (param ? `/${param}` : ''); },
|
||||||
|
|
||||||
_validating: false,
|
|
||||||
|
|
||||||
// 验证 token 有效性(轻量级,失败则清除登录态)
|
|
||||||
async validateAuth() {
|
|
||||||
if (this._validating) return null; // 防止递归
|
|
||||||
this._validating = true;
|
|
||||||
if (!Auth.logged()) { this._validating = false; return false; }
|
|
||||||
try {
|
|
||||||
await API.get('/auth/me');
|
|
||||||
this._validating = false;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
this._validating = false;
|
|
||||||
if (e && e.message && e.message.includes('登录')) {
|
|
||||||
Auth.reset();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// 非登录错误(网络/404/500)不视为登录失效,放行路由
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
route() {
|
route() {
|
||||||
const hash = location.hash || '#/home';
|
const hash = location.hash || '#/home';
|
||||||
const parts = hash.replace('#/', '').split('/');
|
const parts = hash.replace('#/', '').split('/');
|
||||||
@@ -123,31 +101,13 @@ const App = {
|
|||||||
if (page === 'home') { this.showPublic('home', param); return; }
|
if (page === 'home') { this.showPublic('home', param); return; }
|
||||||
if (page === 'login' || page === 'register' || page === 'forgot' || page === 'reset') { this.showPublic(page); return; }
|
if (page === 'login' || page === 'register' || page === 'forgot' || page === 'reset') { this.showPublic(page); return; }
|
||||||
|
|
||||||
// 访问受保护页面前先验证 token 有效性
|
// 受保护页面: Auth.logged() 内部解码 JWT exp,过期自动清除登录态(同步,无异步循环)
|
||||||
if (Auth.logged() && !this._validating) {
|
|
||||||
this._validating = true; // 标记正在验证
|
|
||||||
this.validateAuth().then(valid => {
|
|
||||||
this._validating = false;
|
|
||||||
if (!valid) {
|
|
||||||
location.hash = '#/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._doRoute(page, param);
|
|
||||||
}).catch(() => {
|
|
||||||
this._validating = false;
|
|
||||||
Auth.reset();
|
|
||||||
location.hash = '#/login';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Auth.logged()) {
|
if (!Auth.logged()) {
|
||||||
|
Auth.reset();
|
||||||
location.hash = '#/login';
|
location.hash = '#/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 正在验证中,不做任何操作(等待验证完成)
|
|
||||||
},
|
|
||||||
|
|
||||||
_doRoute(page, param) {
|
|
||||||
this.showLayout();
|
this.showLayout();
|
||||||
const sidebarPage = (page === 'tickets' && param.startsWith('create')) ? 'tickets-create' : page;
|
const sidebarPage = (page === 'tickets' && param.startsWith('create')) ? 'tickets-create' : page;
|
||||||
this.renderSidebar(sidebarPage);
|
this.renderSidebar(sidebarPage);
|
||||||
|
|||||||
@@ -23,7 +23,25 @@ const Auth = {
|
|||||||
|
|
||||||
token() { return localStorage.getItem(this.TK); },
|
token() { return localStorage.getItem(this.TK); },
|
||||||
user() { try { return JSON.parse(localStorage.getItem(this.US)); } catch { return null; } },
|
user() { try { return JSON.parse(localStorage.getItem(this.US)); } catch { return null; } },
|
||||||
logged() { return !!(this.token() && this.user()); },
|
|
||||||
|
// JWT payload 里的 exp(秒) 已过期?
|
||||||
|
expired() {
|
||||||
|
const t = this.token();
|
||||||
|
if (!t) return true;
|
||||||
|
try {
|
||||||
|
let b64 = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
b64 = b64.padEnd(b64.length + (4 - b64.length % 4) % 4, '=');
|
||||||
|
const payload = JSON.parse(atob(b64));
|
||||||
|
return payload.exp ? payload.exp * 1000 < Date.now() : false;
|
||||||
|
} catch { return true; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 登录态判断: token+user 存在 且 未过期;过期自动清除(不跳转)
|
||||||
|
logged() {
|
||||||
|
if (!this.token() || !this.user()) return false;
|
||||||
|
if (this.expired()) { this.reset(); return false; }
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
async login(username, password) {
|
async login(username, password) {
|
||||||
const d = await API.post('/auth/login', { username, password });
|
const d = await API.post('/auth/login', { username, password });
|
||||||
|
|||||||
Reference in New Issue
Block a user