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