fix(auth): Extend JWT expiry to 24h + add friendly session expiration UX

Backend:
- Extend Access Token expiry from 2h to 24h (long operations like
  review/deep-research need sufficient time)
- Refresh Token remains 7 days

Frontend:
- Add sessionGuard.ts: centralized session expiration handler with
  auto token refresh and friendly modal prompt
- ASL fetch client: intercept 401, try refresh, retry on success,
  show friendly modal on failure (was: raw "Unauthorized" red error)
- Axios apiClient: replace alert() + bare redirect with friendly
  session expired modal (covers RVW, IIT, SSA, Admin, DC, PKB)

Tested: Token expiration flow verified, friendly modal displays correctly
Made-with: Cursor
This commit is contained in:
2026-03-08 22:24:33 +08:00
parent a666649fd4
commit b4c293788d
5 changed files with 139 additions and 14 deletions

View File

@@ -51,9 +51,9 @@ export interface DecodedToken extends JWTPayload {
}
// Token 配置
const ACCESS_TOKEN_EXPIRES_IN = '2h'; // Access Token 2小时过期
const ACCESS_TOKEN_EXPIRES_IN = '24h'; // Access Token 24小时过期(长操作如审稿/文献检索需要充足时间)
const REFRESH_TOKEN_EXPIRES_IN = '7d'; // Refresh Token 7天过期
const ACCESS_TOKEN_EXPIRES_SECONDS = 2 * 60 * 60; // 7200秒
const ACCESS_TOKEN_EXPIRES_SECONDS = 24 * 60 * 60; // 86400秒
/**
* JWT Service 类

View File

@@ -11,8 +11,8 @@ import {
getAccessToken,
getRefreshToken,
refreshAccessToken,
clearTokens,
} from '../../framework/auth/api';
import { showSessionExpiredAndRedirect } from '../../framework/auth/sessionGuard';
const apiClient = axios.create({
timeout: 60000,
@@ -68,11 +68,7 @@ apiClient.interceptors.response.use(
if (!is401 || !hasRefreshToken || alreadyRetried || isKicked) {
if (is401) {
clearTokens();
if (isKicked) {
alert('您的账号已在其他设备登录,当前会话已失效,请重新登录');
}
window.location.href = '/login';
showSessionExpiredAndRedirect(isKicked ? 'kicked' : undefined);
}
return Promise.reject(error);
}
@@ -101,8 +97,7 @@ apiClient.interceptors.response.use(
return apiClient(originalRequest);
} catch (refreshError) {
processPendingQueue(refreshError, null);
clearTokens();
window.location.href = '/login';
showSessionExpiredAndRedirect();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;

View File

@@ -5,6 +5,7 @@
export { AuthProvider, useAuth, AuthContext } from './AuthContext';
export * from './types';
export * from './api';
export { handleFetchUnauthorized, showSessionExpiredAndRedirect } from './sessionGuard';

View File

@@ -0,0 +1,115 @@
/**
* Session Guard - 全局会话过期处理
*
* 提供统一的 401 处理逻辑,供 fetch 和 axios 客户端共用:
* 1. 尝试用 Refresh Token 自动续期
* 2. 续期失败时弹出友好提示并跳转登录页
*/
import { getRefreshToken, refreshAccessToken, clearTokens } from './api';
let isRefreshing = false;
let pendingQueue: {
resolve: (token: string) => void;
reject: (err: unknown) => void;
}[] = [];
function processPendingQueue(error: unknown, token: string | null) {
pendingQueue.forEach(({ resolve, reject }) => {
if (token) resolve(token);
else reject(error);
});
pendingQueue = [];
}
/**
* 显示友好的会话过期提示并跳转登录页
*/
function showSessionExpiredAndRedirect(reason?: string) {
clearTokens();
const message = reason === 'kicked'
? '您的账号已在其他设备登录,当前会话已失效'
: '登录已过期,请重新登录';
const overlay = document.createElement('div');
overlay.id = 'session-expired-overlay';
overlay.innerHTML = `
<div style="
position:fixed; inset:0; z-index:99999;
background:rgba(0,0,0,0.45);
display:flex; align-items:center; justify-content:center;
">
<div style="
background:#fff; border-radius:12px; padding:32px 40px;
max-width:380px; text-align:center; box-shadow:0 8px 30px rgba(0,0,0,0.18);
">
<div style="font-size:40px; margin-bottom:12px;">🔒</div>
<h3 style="margin:0 0 8px; font-size:18px; color:#1a1a2e;">${message}</h3>
<p style="margin:0 0 24px; font-size:14px; color:#666;">点击下方按钮返回登录页面</p>
<button id="session-expired-btn" style="
background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff;
border:none; border-radius:8px; padding:10px 32px;
font-size:15px; cursor:pointer; font-weight:500;
">重新登录</button>
</div>
</div>
`;
if (!document.getElementById('session-expired-overlay')) {
document.body.appendChild(overlay);
document.getElementById('session-expired-btn')?.addEventListener('click', () => {
window.location.href = '/login';
});
}
}
/**
* 处理 401 响应fetch 客户端专用)
*
* @returns 新的 accessToken可用于重试或 null已跳转登录
*/
export async function handleFetchUnauthorized(
responseMessage?: string,
): Promise<string | null> {
const isKicked = responseMessage?.includes('其他设备');
if (isKicked) {
showSessionExpiredAndRedirect('kicked');
return null;
}
const hasRefresh = !!getRefreshToken();
if (!hasRefresh) {
showSessionExpiredAndRedirect();
return null;
}
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
pendingQueue.push({ resolve, reject });
}).catch(() => {
showSessionExpiredAndRedirect();
return null;
});
}
isRefreshing = true;
try {
const tokens = await refreshAccessToken();
const newToken = tokens.accessToken;
processPendingQueue(null, newToken);
return newToken;
} catch (refreshError) {
processPendingQueue(refreshError, null);
showSessionExpiredAndRedirect();
return null;
} finally {
isRefreshing = false;
}
}
/**
* 供 axios apiClient 调用的友好跳转(替代 alert + window.location.href
*/
export { showSessionExpiredAndRedirect };

View File

@@ -15,6 +15,7 @@ import type {
ProjectStatistics
} from '../types';
import { getAccessToken } from '../../../framework/auth/api';
import { handleFetchUnauthorized } from '../../../framework/auth/sessionGuard';
// API基础URL
const API_BASE_URL = '/api/v1/asl';
@@ -33,10 +34,11 @@ function getAuthHeaders(): HeadersInit {
return headers;
}
// 通用请求函数
// 通用请求函数(含 401 自动刷新 + 友好提示)
async function request<T = any>(
url: string,
options?: RequestInit
options?: RequestInit,
_retried = false,
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
@@ -46,14 +48,26 @@ async function request<T = any>(
},
});
if (response.status === 401 && !_retried) {
let serverMsg = '';
try {
const body = await response.clone().json();
serverMsg = body?.message || '';
} catch { /* ignore */ }
const newToken = await handleFetchUnauthorized(serverMsg);
if (newToken) {
return request<T>(url, options, true);
}
throw new Error('登录已过期,请重新登录');
}
if (!response.ok) {
// 尝试解析错误响应
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
} catch (e) {
// 如果响应体不是JSON使用状态文本
const text = await response.text().catch(() => '');
if (text) {
errorMessage = text;