fix(auth): enforce single-session with DB tokenVersion + heartbeat detection
Move single-session invalidation from cache-based token version checks to a database-backed, atomic tokenVersion flow to eliminate concurrent login race conditions. Add a global auth heartbeat (visibility-aware) so kicked sessions are detected within ~10s when the page is visible. Made-with: Cursor
This commit is contained in:
@@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AuthProvider } from './framework/auth'
|
||||
import { AuthProvider, useAuthHeartbeat } from './framework/auth'
|
||||
import { PermissionProvider } from './framework/permission'
|
||||
import { RouteGuard } from './framework/router'
|
||||
import MainLayout from './framework/layout/MainLayout'
|
||||
@@ -63,12 +63,19 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
function AuthHeartbeatBootstrap() {
|
||||
useAuthHeartbeat({ intervalMs: 10_000 })
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* 认证提供者:JWT Token管理 */}
|
||||
<AuthProvider>
|
||||
{/* 会话心跳:可见页面10秒校验,隐藏暂停,回前台立即校验 */}
|
||||
<AuthHeartbeatBootstrap />
|
||||
{/* 权限提供者:模块级权限管理 */}
|
||||
<PermissionProvider>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -6,6 +6,7 @@ export { AuthProvider, useAuth, AuthContext } from './AuthContext';
|
||||
export * from './types';
|
||||
export * from './api';
|
||||
export { handleFetchUnauthorized, showSessionExpiredAndRedirect } from './sessionGuard';
|
||||
export { useAuthHeartbeat } from './useAuthHeartbeat';
|
||||
|
||||
|
||||
|
||||
|
||||
78
frontend-v2/src/framework/auth/useAuthHeartbeat.ts
Normal file
78
frontend-v2/src/framework/auth/useAuthHeartbeat.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import apiClient from '../../common/api/axios';
|
||||
import { getAccessToken } from './api';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
interface UseAuthHeartbeatOptions {
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局会话心跳:
|
||||
* - 页面可见时定时校验登录状态
|
||||
* - 页面隐藏时暂停请求,减少无效负载
|
||||
* - 切回前台时立即校验一次,快速感知“异地登录被踢”
|
||||
*/
|
||||
export function useAuthHeartbeat(options: UseAuthHeartbeatOptions = {}): void {
|
||||
const { intervalMs = 10_000 } = options;
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const checkingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !getAccessToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const checkSession = async () => {
|
||||
if (checkingRef.current) return;
|
||||
if (!getAccessToken()) return;
|
||||
checkingRef.current = true;
|
||||
try {
|
||||
await apiClient.get('/api/v1/auth/me');
|
||||
} catch {
|
||||
// 401 处理由 axios 拦截器统一完成(刷新/友好弹窗/跳转)
|
||||
} finally {
|
||||
checkingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startHeartbeat = () => {
|
||||
stopHeartbeat();
|
||||
if (!getAccessToken()) return;
|
||||
timerRef.current = window.setInterval(() => {
|
||||
void checkSession();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void checkSession();
|
||||
startHeartbeat();
|
||||
} else {
|
||||
stopHeartbeat();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === 'visible') {
|
||||
void checkSession();
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
stopHeartbeat();
|
||||
};
|
||||
}, [intervalMs, isAuthenticated]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user