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:
@@ -0,0 +1,3 @@
|
||||
-- 单设备登录强一致:将 token_version 下沉到数据库,避免缓存竞态
|
||||
ALTER TABLE "platform_schema"."users"
|
||||
ADD COLUMN IF NOT EXISTS "token_version" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -42,6 +42,7 @@ model User {
|
||||
trialEndsAt DateTime? @map("trial_ends_at")
|
||||
isTrial Boolean @default(true) @map("is_trial")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
tokenVersion Int @default(0) @map("token_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
tenant_members tenant_members[]
|
||||
|
||||
@@ -14,7 +14,7 @@ import { jwtService } from './jwt.service.js';
|
||||
import type { DecodedToken } from './jwt.service.js';
|
||||
import { logger } from '../logging/index.js';
|
||||
import { moduleService } from './module.service.js';
|
||||
import { cache } from '../cache/index.js';
|
||||
import { prisma } from '../../config/database.js';
|
||||
|
||||
/**
|
||||
* 扩展 Fastify Request 类型
|
||||
@@ -72,13 +72,19 @@ export const authenticate: preHandlerHookHandler = async (
|
||||
// 2. 验证 Token
|
||||
const decoded = jwtService.verifyToken(token);
|
||||
|
||||
// 2.5 验证 token 版本号(单设备登录:新登录会踢掉旧会话)
|
||||
if (decoded.tokenVersion !== undefined) {
|
||||
const tokenVersionKey = `token_version:${decoded.userId}`;
|
||||
const currentVersion = await cache.get<number>(tokenVersionKey);
|
||||
if (currentVersion !== null && decoded.tokenVersion < currentVersion) {
|
||||
throw new AuthenticationError('您的账号已在其他设备登录,当前会话已失效');
|
||||
}
|
||||
// 2.5 验证 token 版本号(数据库强一致:新登录会踢掉旧会话)
|
||||
const userVersion = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { tokenVersion: true, status: true },
|
||||
});
|
||||
if (!userVersion) {
|
||||
throw new AuthenticationError('用户不存在或已被删除');
|
||||
}
|
||||
if (userVersion.status !== 'active') {
|
||||
throw new AuthenticationError('账号已被禁用,请联系管理员');
|
||||
}
|
||||
if (decoded.tokenVersion === undefined || decoded.tokenVersion !== userVersion.tokenVersion) {
|
||||
throw new AuthenticationError('您的账号已在其他设备登录,当前会话已失效');
|
||||
}
|
||||
|
||||
// 3. 注入用户信息
|
||||
|
||||
@@ -13,7 +13,6 @@ import { prisma } from '../../config/database.js';
|
||||
import { jwtService } from './jwt.service.js';
|
||||
import type { JWTPayload, TokenResponse } from './jwt.service.js';
|
||||
import { logger } from '../logging/index.js';
|
||||
import { cache } from '../cache/index.js';
|
||||
|
||||
/**
|
||||
* 登录请求 - 密码方式
|
||||
@@ -116,11 +115,16 @@ export class AuthService {
|
||||
const permissions = await this.getUserPermissions(user.role);
|
||||
const modules = await this.getUserModules(user.id);
|
||||
|
||||
// 4.5 递增 token 版本号(实现单设备登录,踢掉旧会话)
|
||||
const tokenVersionKey = `token_version:${user.id}`;
|
||||
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
|
||||
const newVersion = currentVersion + 1;
|
||||
await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60); // 30天有效
|
||||
// 4.5 原子递增 token 版本号(数据库强一致,避免并发登录竞态)
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
tokenVersion: { increment: 1 },
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
select: { tokenVersion: true },
|
||||
});
|
||||
const newVersion = updatedUser.tokenVersion;
|
||||
|
||||
// 5. 生成 JWT(包含 token 版本号)
|
||||
const jwtPayload: JWTPayload = {
|
||||
@@ -135,12 +139,6 @@ export class AuthService {
|
||||
|
||||
const tokens = jwtService.generateTokens(jwtPayload);
|
||||
|
||||
// 6. 更新最后登录时间
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
logger.info('用户登录成功(密码方式)', {
|
||||
userId: user.id,
|
||||
phone: user.phone,
|
||||
@@ -223,11 +221,16 @@ export class AuthService {
|
||||
const permissions = await this.getUserPermissions(user.role);
|
||||
const modules = await this.getUserModules(user.id);
|
||||
|
||||
// 5.5 递增 token 版本号(实现单设备登录,踢掉旧会话)
|
||||
const tokenVersionKey = `token_version:${user.id}`;
|
||||
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
|
||||
const newVersion = currentVersion + 1;
|
||||
await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60);
|
||||
// 5.5 原子递增 token 版本号(数据库强一致,避免并发登录竞态)
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
tokenVersion: { increment: 1 },
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
select: { tokenVersion: true },
|
||||
});
|
||||
const newVersion = updatedUser.tokenVersion;
|
||||
|
||||
// 6. 生成 JWT(包含 token 版本号)
|
||||
const jwtPayload: JWTPayload = {
|
||||
@@ -448,10 +451,6 @@ export class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取当前 token 版本号(单设备登录校验)
|
||||
const tokenVersionKey = `token_version:${user.id}`;
|
||||
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
phone: user.phone,
|
||||
@@ -459,7 +458,7 @@ export class AuthService {
|
||||
tenantId: user.tenant_id,
|
||||
tenantCode: user.tenants?.code,
|
||||
isDefaultPassword: user.is_default_password,
|
||||
tokenVersion: currentVersion,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,8 +154,8 @@ export class JWTService {
|
||||
|
||||
// 验证 token 版本号(踢人检查)
|
||||
const refreshTokenVersion = (decoded as any).tokenVersion;
|
||||
if (refreshTokenVersion !== undefined && user.tokenVersion !== undefined
|
||||
&& refreshTokenVersion < user.tokenVersion) {
|
||||
if (refreshTokenVersion === undefined || user.tokenVersion === undefined
|
||||
|| refreshTokenVersion !== user.tokenVersion) {
|
||||
throw new Error('您的账号已在其他设备登录,当前会话已失效');
|
||||
}
|
||||
|
||||
|
||||
@@ -121,11 +121,20 @@ interface DecodedToken {
|
||||
role: string; // 角色
|
||||
tenantId: string; // 租户ID
|
||||
tenantCode?: string; // 租户Code
|
||||
tokenVersion: number; // 会话版本号(单设备登录互踢)
|
||||
iat: number; // 签发时间
|
||||
exp: number; // 过期时间
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 单账号互踢(强一致)
|
||||
|
||||
- 后端使用 `platform_schema.users.token_version` 作为会话版本号(数据库强一致)
|
||||
- 每次登录都会原子执行 `token_version = token_version + 1`
|
||||
- Access/Refresh Token 均携带 `tokenVersion`
|
||||
- 鉴权时要求 `tokenVersion === users.token_version`,不一致即判定“已在其他设备登录”
|
||||
- 禁止依赖进程内缓存实现互踢(多实例/并发场景会失效)
|
||||
|
||||
## 4. 检查清单
|
||||
|
||||
### 4.1 新模块开发检查清单
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| DB-4 | SSA execution_mode 默认值改为 `agent` + 已有 session 全部更新 | `prisma/migrations/20260308_default_agent_mode/migration.sql` | 高 | ALTER DEFAULT + UPDATE 旧数据;QPER UI 入口已移除 |
|
||||
| DB-5 | SSA Agent Prompt 种子数据(SSA_AGENT_PLANNER / SSA_AGENT_CODER) | `prisma/seed-ssa-agent-prompts.ts` | 高 | 部署后执行 `npx tsx prisma/seed-ssa-agent-prompts.ts`;幂等可重复执行 |
|
||||
| DB-6 | IIT eQuery open 集合去重护栏(历史收敛 + open 唯一索引) | `prisma/migrations/20260308_add_iit_equery_open_dedupe_guard/migration.sql` | 高 | 先自动将历史重复 open eQuery 收敛为 `auto_closed`,再建立部分唯一索引防止未来重复 |
|
||||
| DB-7 | users 表新增 `token_version`(单账号互踢强一致) | `prisma/migrations/20260309_add_token_version_to_platform_users/migration.sql` | 高 | 登录原子递增版本号,Access/Refresh Token 带版本,旧会话立即失效 |
|
||||
|
||||
### 后端变更 (Node.js)
|
||||
|
||||
@@ -38,6 +39,7 @@
|
||||
| BE-10 | SSA Agent 核心 Prompt 接入运营管理端(PlannerAgent + CoderAgent) | `AgentPlannerService.ts`, `AgentCoderService.ts`, `prompt.fallbacks.ts` | 重新构建镜像 | 硬编码 → `PromptService.get()` 动态加载;三级容灾:DB → 缓存 → fallback;需先完成 DB-5 |
|
||||
| BE-11 | IIT eQuery 幂等写入 + 安全去重工具脚本 | `iitEqueryService.ts`, `scripts/dedupe_open_equeries.ts`, `package.json` | 重新构建镜像 | `createBatch` 改为 `ON CONFLICT DO NOTHING`(open 集合),新增 `npx tsx scripts/dedupe_open_equeries.ts <projectId> [--apply]` |
|
||||
| BE-12 | IIT 实时工作流事件名称友好化兜底 + AI 对话证据块强制补齐 | `iitQcCockpitController.ts`, `ChatOrchestrator.ts` | 重新构建镜像 | 时间线事件名采用 event_label/cachedRules/fallback 三层映射;回答含“证据:”时若无明细则自动补齐,避免空证据块 |
|
||||
| BE-13 | 认证链路改造为数据库强一致互踢(去缓存版 tokenVersion) | `auth.service.ts`, `auth.middleware.ts`, `jwt.service.ts` | 重新构建镜像 | 修复并发登录竞态导致多端同时在线:鉴权改为 `tokenVersion === users.token_version` |
|
||||
|
||||
### 前端变更
|
||||
|
||||
@@ -52,6 +54,7 @@
|
||||
| FE-7 | SSA Agent 通道体验优化(方案 B + 动态 UI) | `AgentCodePanel.tsx`, `SSAChatPane.tsx`, `SSAWorkspacePane.tsx`, `SSACodeModal.tsx`, `useSSAChat.ts`, `ssaStore.ts`, `ssa.css` | 重新构建镜像 | 左右职责分离 + JWT 刷新 + 重试代码展示 + 错误信息展示 + 进度条同步 + 导出/查看代码按钮恢复 + ExecutingProgress 组件 |
|
||||
| FE-8 | SSA 默认 Agent 模式 + 查看代码修复 + 分析历史卡片 | `SSAChatPane.tsx`, `SSAWorkspacePane.tsx`, `useSSAChat.ts`, `ssaStore.ts` | 重新构建镜像 | 移除 ModeToggle + 默认 agent + 查看代码走 Modal + 分析完成后对话插入可点击结果卡片 + ChatIntentType 扩展 system |
|
||||
| FE-9 | IIT D1 筛选入选表“不合规条目”规则名称友好显示 | `EligibilityTable.tsx` | 重新构建镜像 | 不合规条目由 ruleId 显示改为 ruleName 优先,减少技术标识符暴露 |
|
||||
| FE-10 | 全局会话心跳(10s)提升异地登录互踢感知时效 | `framework/auth/useAuthHeartbeat.ts`, `App.tsx`, `framework/auth/index.ts` | 重新构建镜像 | 页面可见时心跳、隐藏时暂停、切回前台立即校验;旧端通常 10 秒内感知被踢 |
|
||||
|
||||
### Python 微服务变更
|
||||
|
||||
|
||||
@@ -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