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:
2026-03-09 13:11:37 +08:00
parent 740ef8b526
commit 50657dd81f
10 changed files with 140 additions and 33 deletions

View File

@@ -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>

View File

@@ -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';

View 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]);
}