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