diff --git a/backend/prisma/migrations/20260309_add_token_version_to_platform_users/migration.sql b/backend/prisma/migrations/20260309_add_token_version_to_platform_users/migration.sql new file mode 100644 index 00000000..557cd67e --- /dev/null +++ b/backend/prisma/migrations/20260309_add_token_version_to_platform_users/migration.sql @@ -0,0 +1,3 @@ +-- 单设备登录强一致:将 token_version 下沉到数据库,避免缓存竞态 +ALTER TABLE "platform_schema"."users" +ADD COLUMN IF NOT EXISTS "token_version" INTEGER NOT NULL DEFAULT 0; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e782df6a..6462a3b1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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[] diff --git a/backend/src/common/auth/auth.middleware.ts b/backend/src/common/auth/auth.middleware.ts index e4edce4b..e5b71d64 100644 --- a/backend/src/common/auth/auth.middleware.ts +++ b/backend/src/common/auth/auth.middleware.ts @@ -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(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. 注入用户信息 diff --git a/backend/src/common/auth/auth.service.ts b/backend/src/common/auth/auth.service.ts index 5b1ea9d4..6de6f30f 100644 --- a/backend/src/common/auth/auth.service.ts +++ b/backend/src/common/auth/auth.service.ts @@ -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(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(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(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, }; }); } diff --git a/backend/src/common/auth/jwt.service.ts b/backend/src/common/auth/jwt.service.ts index 67fa1a7b..a4b19af4 100644 --- a/backend/src/common/auth/jwt.service.ts +++ b/backend/src/common/auth/jwt.service.ts @@ -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('您的账号已在其他设备登录,当前会话已失效'); } diff --git a/docs/04-开发规范/10-模块认证规范.md b/docs/04-开发规范/10-模块认证规范.md index c19c31ff..09409e11 100644 --- a/docs/04-开发规范/10-模块认证规范.md +++ b/docs/04-开发规范/10-模块认证规范.md @@ -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 新模块开发检查清单 diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index 488eed15..cb02bbc2 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -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 [--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 微服务变更 diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index 25423584..137b4e2d 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -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 ( {/* 认证提供者:JWT Token管理 */} + {/* 会话心跳:可见页面10秒校验,隐藏暂停,回前台立即校验 */} + {/* 权限提供者:模块级权限管理 */} diff --git a/frontend-v2/src/framework/auth/index.ts b/frontend-v2/src/framework/auth/index.ts index 845a59ad..076d4be0 100644 --- a/frontend-v2/src/framework/auth/index.ts +++ b/frontend-v2/src/framework/auth/index.ts @@ -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'; diff --git a/frontend-v2/src/framework/auth/useAuthHeartbeat.ts b/frontend-v2/src/framework/auth/useAuthHeartbeat.ts new file mode 100644 index 00000000..577f589e --- /dev/null +++ b/frontend-v2/src/framework/auth/useAuthHeartbeat.ts @@ -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(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]); +} +