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:
@@ -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 类
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export { AuthProvider, useAuth, AuthContext } from './AuthContext';
|
||||
export * from './types';
|
||||
export * from './api';
|
||||
export { handleFetchUnauthorized, showSessionExpiredAndRedirect } from './sessionGuard';
|
||||
|
||||
|
||||
|
||||
|
||||
115
frontend-v2/src/framework/auth/sessionGuard.ts
Normal file
115
frontend-v2/src/framework/auth/sessionGuard.ts
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user