feat: harden sessions and track downstream acknowledgements

This commit is contained in:
hectorzhao
2026-07-14 14:18:43 +08:00
parent 3d37adcc9f
commit 8c03663f24
43 changed files with 1733 additions and 150 deletions
+104 -9
View File
@@ -1,13 +1,22 @@
import { Body, Controller, Get, Post, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from './current-session-user.decorator';
import { AuthService, LoginDto } from './auth.service';
import { SessionService } from './session.service';
import type { SessionRequest } from './session-validation.middleware';
import { UsersService } from '../users/users.service';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
type CookieResponse = {
cookie(name: string, value: string, options: Record<string, unknown>): void;
clearCookie(name: string, options: Record<string, unknown>): void;
};
@ApiTags('auth')
@Controller()
export class AuthController {
constructor(private readonly auth: AuthService, private readonly users: UsersService) {}
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService) {}
@Get('admin/auth/captcha')
adminCaptcha() {
@@ -15,8 +24,8 @@ export class AuthController {
}
@Post('admin/auth/login')
adminLogin(@Body() body: LoginDto) {
return this.auth.login(body, 'admin');
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
}
@Get('client/auth/captcha')
@@ -25,15 +34,101 @@ export class AuthController {
}
@Post('client/auth/login')
clientLogin(@Body() body: LoginDto) {
return this.auth.login(body, 'client');
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
}
@Get('auth/session')
currentSession(@Req() request: SessionRequest) {
this.assertSession(request);
return this.sessions.publicSession(request.authSession!);
}
@Post('auth/session/touch')
async touch(@Req() request: SessionRequest) {
this.assertSession(request);
const result = await this.sessions.touch(request.sessionToken!);
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
return this.sessions.publicSession(result.record);
}
@Post('auth/session/lock')
async lock(@Req() request: SessionRequest) {
this.assertSession(request);
const record = await this.sessions.lock(request.sessionToken!);
if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
return { locked: Boolean(record) };
}
@Post('auth/session/unlock')
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
this.assertSession(request);
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
this.setCookie(response, result.token);
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
return this.sessions.publicSession(result.record);
}
@Post('auth/reauthenticate')
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
this.assertSession(request);
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
await this.writeLog(request, 'auth.session_reauthenticated', { portal: result.record.portal });
return this.sessions.publicSession(result.record);
}
@Post('auth/logout')
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
this.clearCookie(response);
return { success: true };
}
@Post('auth/password')
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
if (!userId) {
throw new UnauthorizedException('登录会话无效,请重新登录');
}
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
}
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
this.setCookie(response, result.sessionToken);
await this.prisma.operationLog.create({
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
});
const { sessionToken: _, ...publicResult } = result;
return publicResult;
}
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
});
}
private assertSession(request: SessionRequest) {
if (!request.sessionToken || !request.sessionUserId || !request.authSession) {
throw new UnauthorizedException({ code: 'SESSION_INVALID', message: '登录会话无效,请重新登录' });
}
}
private setCookie(response: CookieResponse, token: string) {
response.cookie(this.sessions.cookieName, token, {
httpOnly: true,
secure: this.sessions.cookieSecure,
sameSite: 'lax',
path: '/',
});
}
private clearCookie(response: CookieResponse) {
response.clearCookie(this.sessions.cookieName, {
httpOnly: true,
secure: this.sessions.cookieSecure,
sameSite: 'lax',
path: '/',
});
}
}
+10 -1
View File
@@ -1,11 +1,20 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RecentAuthenticationGuard } from './recent-authentication.guard';
import { SessionService } from './session.service';
@Module({
imports: [UsersModule],
controllers: [AuthController],
providers: [AuthService],
providers: [
AuthService,
SessionService,
RecentAuthenticationGuard,
{ provide: APP_GUARD, useExisting: RecentAuthenticationGuard },
],
exports: [SessionService],
})
export class AuthModule {}
+17 -4
View File
@@ -25,6 +25,17 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
};
}
function createSessionsMock() {
const record = {
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
};
return {
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
};
}
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
const captcha = service.createCaptcha();
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
@@ -39,21 +50,23 @@ async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client'
describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin');
const service = new AuthService(users as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', accessToken: 'dev-token:user-1:0' }));
const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
});
it('rejects enterprise admins on admin portal', async () => {
const users = createUsersMock('enterprise_admin');
const service = new AuthService(users as never);
const service = new AuthService(users as never, createSessionsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
});
it('locks user after five failed password attempts', async () => {
const users = createUsersMock('platform_admin');
const service = new AuthService(users as never);
const service = new AuthService(users as never, createSessionsMock() as never);
for (let index = 0; index < 5; index += 1) {
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
}
+15 -3
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { hashPassword, UsersService } from '../users/users.service';
import { SessionService } from './session.service';
export interface LoginDto {
login: string;
@@ -21,7 +22,7 @@ const anonymousFailures = new Map<string, { count: number; lockedUntil?: number
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService) {}
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
createCaptcha() {
const left = Math.floor(10 + Math.random() * 40);
@@ -76,9 +77,9 @@ export class AuthService {
await this.users.recordLoginSuccess(user.id);
anonymousFailures.delete(login);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return {
accessToken: `dev-token:${user.id}:${user.sessionVersion ?? 0}`,
tokenType: 'Bearer',
sessionToken: token,
portal,
user: {
id: user.id,
@@ -90,9 +91,20 @@ export class AuthService {
displayName: user.displayName,
roles: roleCodes,
},
...this.sessions.publicSession(record),
};
}
async unlock(token: string, userId: string, password: string) {
await this.users.verifyCurrentPassword(userId, password);
return this.sessions.unlock(token);
}
async reauthenticate(token: string, userId: string, password: string) {
await this.users.verifyCurrentPassword(userId, password);
return this.sessions.markReauthenticated(token);
}
private verifyCaptcha(captchaId?: string, captchaText?: string) {
const record = captchaId ? captchaStore.get(captchaId) : undefined;
captchaStore.delete(captchaId ?? '');
@@ -0,0 +1,26 @@
import { ForbiddenException } from '@nestjs/common';
import { RecentAuthenticationGuard } from './recent-authentication.guard';
function context(record?: Record<string, unknown>) {
return {
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: () => ({ getRequest: () => ({ authSession: record }) }),
};
}
describe('RecentAuthenticationGuard', () => {
it('requires a fresh password verification on decorated operations', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(true) };
const sessions = { isRecentlyAuthenticated: jest.fn().mockReturnValue(false) };
const guard = new RecentAuthenticationGuard(reflector as never, sessions as never);
expect(() => guard.canActivate(context({}) as never)).toThrow(ForbiddenException);
});
it('allows a decorated operation during the recent authentication window', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(true) };
const sessions = { isRecentlyAuthenticated: jest.fn().mockReturnValue(true) };
const guard = new RecentAuthenticationGuard(reflector as never, sessions as never);
expect(guard.canActivate(context({}) as never)).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { REQUIRE_RECENT_AUTHENTICATION } from './require-recent-authentication.decorator';
import { SessionService } from './session.service';
import type { SessionRequest } from './session-validation.middleware';
@Injectable()
export class RecentAuthenticationGuard implements CanActivate {
constructor(private readonly reflector: Reflector, private readonly sessions: SessionService) {}
canActivate(context: ExecutionContext) {
const required = this.reflector.getAllAndOverride<boolean>(REQUIRE_RECENT_AUTHENTICATION, [
context.getHandler(),
context.getClass(),
]);
if (!required) return true;
const request = context.switchToHttp().getRequest<SessionRequest>();
if (request.authSession && this.sessions.isRecentlyAuthenticated(request.authSession)) return true;
throw new ForbiddenException({ code: 'RECENT_AUTHENTICATION_REQUIRED', message: '该操作需要重新验证当前密码' });
}
}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_RECENT_AUTHENTICATION = 'requireRecentAuthentication';
export const RequireRecentAuthentication = () => SetMetadata(REQUIRE_RECENT_AUTHENTICATION, true);
@@ -1,27 +1,51 @@
import { UnauthorizedException } from '@nestjs/common';
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
function request(authorization?: string): SessionRequest {
return { header: jest.fn().mockReturnValue(authorization) };
const record = {
userId: 'user-1', portal: 'admin' as const, sessionVersion: 3, createdAt: 1,
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
};
function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token'): SessionRequest {
return {
originalUrl: path,
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
};
}
describe('SessionValidationMiddleware', () => {
it('accepts the current user session version and exposes the session user id', async () => {
it('accepts an active Redis session and exposes its user and record', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const middleware = new SessionValidationMiddleware(prisma as never);
const currentRequest = request('Bearer dev-token:user-1:3');
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
const currentRequest = request();
const next = jest.fn();
await middleware.use(currentRequest, {} as never, next);
await middleware.use(currentRequest, {}, next);
expect(next).toHaveBeenCalledTimes(1);
expect(currentRequest.sessionUserId).toBe('user-1');
expect(currentRequest.sessionToken).toBe('opaque-token');
expect(currentRequest.authSession).toEqual(record);
});
it('rejects an old session token after the user session version changes', async () => {
it('rejects a session after the user session version changes', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
const middleware = new SessionValidationMiddleware(prisma as never);
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
await expect(middleware.use(request('Bearer dev-token:user-1:3'), {} as never, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
expect(sessions.remove).toHaveBeenCalledWith('opaque-token');
});
it('only lets a locked session reach unlock and logout endpoints', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
const next = jest.fn();
await middleware.use(request('/api/auth/session/unlock'), {}, next);
expect(next).toHaveBeenCalled();
});
});
+52 -9
View File
@@ -1,33 +1,76 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthSessionRecord, DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionService } from './session.service';
export type SessionRequest = {
header(name: string): string | undefined;
originalUrl?: string;
url?: string;
sessionUserId?: string;
sessionToken?: string;
authSession?: AuthSessionRecord;
};
@Injectable()
export class SessionValidationMiddleware implements NestMiddleware {
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService, private readonly sessions: SessionService) {}
async use(request: SessionRequest, _: unknown, next: () => void) {
const authorization = request.header('authorization');
if (!authorization) {
const path = request.originalUrl ?? request.url ?? '';
if (/\/(admin|client)\/auth\/(captcha|login)(?:\?|$)/.test(path)) {
next();
return;
}
const match = /^Bearer dev-token:([^:]+):(\d+)$/.exec(authorization.trim());
if (!match) {
throw new UnauthorizedException('登录会话无效,请重新登录');
const token = this.readCookie(request.header('cookie'));
if (!token) {
if ((path.includes('/admin/') && !path.includes('/admin/gateway/')) || path.includes('/client/') || path.includes('/auth/')) {
throw this.unauthorized('SESSION_INVALID', '请先登录');
}
next();
return;
}
const result = await this.sessions.validate(token, request.header('x-session-activity') === 'user');
if (result.status === 'expired') {
throw this.unauthorized(result.code, result.code === 'SESSION_ABSOLUTE_TIMEOUT' ? '登录已满 12 小时,请重新登录' : '登录会话已失效,请重新登录');
}
const user = await this.prisma.user.findUnique({
where: { id: match[1] },
where: { id: result.record.userId },
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
});
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== Number(match[2])) {
throw new UnauthorizedException('登录会话已失效,请重新登录');
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
await this.sessions.remove(token);
throw this.unauthorized('SESSION_REVOKED', '登录会话已被撤销,请重新登录');
}
if ((path.includes('/admin/') && result.record.portal !== 'admin') || (path.includes('/client/') && result.record.portal !== 'client')) {
throw this.unauthorized('SESSION_PORTAL_MISMATCH', '登录入口与当前会话不匹配');
}
request.sessionUserId = user.id;
request.sessionToken = token;
request.authSession = result.record;
if (result.status === 'locked' && !path.includes('/auth/session/unlock') && !path.includes('/auth/logout')) {
if (result.newlyLocked) {
await this.prisma.operationLog.create({
data: { userId: user.id, action: 'auth.session_locked', resource: 'auth_session', detail: { portal: result.record.portal, reason: 'idle_timeout' } },
});
}
throw this.unauthorized('SESSION_LOCKED', '由于长时间未操作,会话已安全锁定');
}
next();
}
private readCookie(cookieHeader?: string) {
if (!cookieHeader) return undefined;
for (const part of cookieHeader.split(';')) {
const [name, ...value] = part.trim().split('=');
if (name === SESSION_COOKIE_NAME || name === DEVELOPMENT_SESSION_COOKIE_NAME) return decodeURIComponent(value.join('='));
}
return undefined;
}
private unauthorized(code: string, message: string) {
return new UnauthorizedException({ code, message });
}
}
+73
View File
@@ -0,0 +1,73 @@
const values = new Map<string, string>();
const redis = {
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
set: jest.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve('OK'); }),
del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }),
disconnect: jest.fn(),
};
jest.mock('ioredis', () => jest.fn(() => redis));
import { SessionService } from './session.service';
describe('SessionService', () => {
let now = 1_700_000_000_000;
beforeEach(() => {
values.clear();
jest.clearAllMocks();
now = 1_700_000_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
process.env.ADMIN_SESSION_IDLE_TIMEOUT_MS = '3600000';
process.env.CLIENT_SESSION_IDLE_TIMEOUT_MS = '7200000';
process.env.SESSION_ABSOLUTE_TIMEOUT_MS = '43200000';
process.env.SESSION_LOCK_RECOVERY_MS = '14400000';
});
afterEach(() => {
jest.restoreAllMocks();
delete process.env.ADMIN_SESSION_IDLE_TIMEOUT_MS;
delete process.env.CLIENT_SESSION_IDLE_TIMEOUT_MS;
delete process.env.SESSION_ABSOLUTE_TIMEOUT_MS;
delete process.env.SESSION_LOCK_RECOVERY_MS;
});
it('locks an admin session after 60 minutes without extending the 12 hour absolute deadline', async () => {
const service = new SessionService();
const created = await service.create('user-1', 'admin', 2);
expect(created.record.absoluteExpiresAt).toBe(now + 12 * 60 * 60 * 1000);
now += 60 * 60 * 1000 + 1;
const result = await service.validate(created.token, false);
expect(result.status).toBe('locked');
if (result.status === 'locked') expect(result.record.lockedAt).toBe(now);
});
it('keeps a client session active before its 120 minute inactivity limit', async () => {
const service = new SessionService();
const created = await service.create('user-1', 'client', 2);
now += 90 * 60 * 1000;
await expect(service.validate(created.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
});
it('requires a full login after a session stays locked for four hours', async () => {
const service = new SessionService();
const created = await service.create('user-1', 'admin', 2);
await service.lock(created.token);
now += 4 * 60 * 60 * 1000 + 1;
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_LOCK_TIMEOUT' });
});
it('rotates the opaque token when a password unlock succeeds', async () => {
const service = new SessionService();
const created = await service.create('user-1', 'admin', 2);
await service.lock(created.token);
const result = await service.unlock(created.token);
expect(result.status).toBe('active');
if (result.status === 'active' && 'token' in result) {
expect(result.token).not.toBe(created.token);
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_INVALID' });
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
}
});
});
+202
View File
@@ -0,0 +1,202 @@
import { createHash, randomBytes } from 'node:crypto';
import { Injectable, OnModuleDestroy, ServiceUnavailableException } from '@nestjs/common';
import IORedis from 'ioredis';
export type SessionPortal = 'admin' | 'client';
export type AuthSessionRecord = {
userId: string;
portal: SessionPortal;
sessionVersion: number;
createdAt: number;
lastActivityAt: number;
lastAuthenticatedAt: number;
absoluteExpiresAt: number;
lockedAt?: number;
};
export type SessionValidationResult =
| { status: 'active'; record: AuthSessionRecord }
| { status: 'locked'; record: AuthSessionRecord; newlyLocked?: boolean }
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
const SESSION_PREFIX = 'cmpp:auth:session:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
@Injectable()
export class SessionService implements OnModuleDestroy {
private redis?: IORedis;
onModuleDestroy() {
this.redis?.disconnect();
}
async create(userId: string, portal: SessionPortal, sessionVersion: number) {
const now = Date.now();
const token = randomBytes(32).toString('base64url');
const record: AuthSessionRecord = {
userId,
portal,
sessionVersion,
createdAt: now,
lastActivityAt: now,
lastAuthenticatedAt: now,
absoluteExpiresAt: now + this.absoluteTimeoutMs,
};
await this.write(token, record);
return { token, record };
}
async validate(token: string, markActivity: boolean): Promise<SessionValidationResult> {
const record = await this.read(token);
if (!record) return { status: 'expired', code: 'SESSION_INVALID' };
const now = Date.now();
if (now >= record.absoluteExpiresAt) {
await this.remove(token);
return { status: 'expired', code: 'SESSION_ABSOLUTE_TIMEOUT' };
}
if (record.lockedAt && now - record.lockedAt >= this.lockRecoveryMs) {
await this.remove(token);
return { status: 'expired', code: 'SESSION_LOCK_TIMEOUT' };
}
if (!record.lockedAt && now - record.lastActivityAt >= this.idleTimeoutMs(record.portal)) {
record.lockedAt = now;
await this.write(token, record);
return { status: 'locked', record, newlyLocked: true };
}
if (record.lockedAt) return { status: 'locked', record, newlyLocked: false };
if (markActivity && now - record.lastActivityAt >= 30_000) {
record.lastActivityAt = now;
await this.write(token, record);
}
return { status: 'active', record };
}
async lock(token: string) {
const record = await this.read(token);
if (!record) return null;
record.lockedAt = record.lockedAt ?? Date.now();
await this.write(token, record);
return record;
}
async touch(token: string) {
const result = await this.validate(token, false);
if (result.status !== 'active') return result;
result.record.lastActivityAt = Date.now();
await this.write(token, result.record);
return result;
}
async unlock(token: string) {
const result = await this.validate(token, false);
if (result.status === 'expired') return result;
const now = Date.now();
const nextToken = randomBytes(32).toString('base64url');
const record = {
...result.record,
lockedAt: undefined,
lastActivityAt: now,
lastAuthenticatedAt: now,
};
await this.write(nextToken, record);
await this.remove(token);
return { status: 'active' as const, token: nextToken, record };
}
async markReauthenticated(token: string) {
const result = await this.validate(token, false);
if (result.status !== 'active') return result;
result.record.lastAuthenticatedAt = Date.now();
await this.write(token, result.record);
return result;
}
remove(token: string) {
return this.client.del(this.key(token));
}
isRecentlyAuthenticated(record: AuthSessionRecord) {
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
}
publicSession(record: AuthSessionRecord) {
return {
idleTimeoutSeconds: Math.floor(this.idleTimeoutMs(record.portal) / 1000),
lockRecoverySeconds: Math.floor(this.lockRecoveryMs / 1000),
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
lastActivityAt: new Date(record.lastActivityAt).toISOString(),
recentAuthenticationExpiresAt: new Date(record.lastAuthenticatedAt + this.recentAuthenticationMs).toISOString(),
};
}
get cookieSecure() {
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
}
get cookieName() {
return this.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME;
}
get absoluteTimeoutMs() {
return this.duration('SESSION_ABSOLUTE_TIMEOUT_MS', 12 * 60 * 60 * 1000);
}
get lockRecoveryMs() {
return this.duration('SESSION_LOCK_RECOVERY_MS', 4 * 60 * 60 * 1000);
}
get recentAuthenticationMs() {
return this.duration('SESSION_RECENT_AUTH_MS', 30 * 60 * 1000);
}
idleTimeoutMs(portal: SessionPortal) {
return portal === 'admin'
? this.duration('ADMIN_SESSION_IDLE_TIMEOUT_MS', 60 * 60 * 1000)
: this.duration('CLIENT_SESSION_IDLE_TIMEOUT_MS', 120 * 60 * 1000);
}
private duration(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
private async read(token: string): Promise<AuthSessionRecord | null> {
try {
const value = await this.client.get(this.key(token));
return value ? JSON.parse(value) as AuthSessionRecord : null;
} catch {
throw new ServiceUnavailableException('登录会话服务暂不可用');
}
}
private async write(token: string, record: AuthSessionRecord) {
const ttl = record.absoluteExpiresAt - Date.now();
if (ttl <= 0) {
await this.remove(token);
return;
}
try {
await this.client.set(this.key(token), JSON.stringify(record), 'PX', ttl);
} catch {
throw new ServiceUnavailableException('登录会话服务暂不可用');
}
}
private key(token: string) {
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
}
private get client() {
if (!this.redis) {
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
enableReadyCheck: true,
maxRetriesPerRequest: 1,
});
}
return this.redis;
}
}