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

@@ -0,0 +1,3 @@
-- 单设备登录强一致:将 token_version 下沉到数据库,避免缓存竞态
ALTER TABLE "platform_schema"."users"
ADD COLUMN IF NOT EXISTS "token_version" INTEGER NOT NULL DEFAULT 0;

View File

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

View File

@@ -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. 注入用户信息

View File

@@ -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,
};
});
}

View File

@@ -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('您的账号已在其他设备登录,当前会话已失效');
}

View File

@@ -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 新模块开发检查清单

View File

@@ -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 微服务变更

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