diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 9079fa2f..e718b36e 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -94,6 +94,8 @@ export const envValidationSchema = Joi.object({ SESSION_LOCK_TTL_MS: Joi.number().integer().default(5000), SESSION_LOCK_MAX_RETRIES: Joi.number().integer().default(5), SESSION_LOCK_RETRY_DELAY_MS: Joi.number().integer().default(120), + // Maximum concurrent sessions per user (default 5) + MAX_SESSIONS_PER_USER: Joi.number().integer().min(0).default(5), STICKY_SESSIONS_REQUIRED: Joi.boolean().default(true), TRUST_PROXY: Joi.boolean().default(true), diff --git a/src/session/session.service.ts b/src/session/session.service.ts index 6031a503..c28658df 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -25,12 +25,14 @@ export class SessionService implements OnModuleDestroy { private readonly lockTtlMs: number; private readonly lockRetries: number; private readonly lockRetryDelayMs: number; + private readonly maxSessionsPerUser: number; constructor( @Inject(SESSION_REDIS_CLIENT) private readonly redis: Redis, private readonly configService: ConfigService, ) { this.sessionPrefix = this.configService.get('AUTH_SESSION_PREFIX') || 'auth:sess:'; + this.maxSessionsPerUser = parseInt(this.configService.get('MAX_SESSIONS_PER_USER') || '5', 10); this.legacySessionPrefix = this.configService.get('AUTH_SESSION_LEGACY_PREFIX') || 'session:'; this.sessionTtlSeconds = parseInt( @@ -81,7 +83,7 @@ export class SessionService implements OnModuleDestroy { 'EX', this.sessionTtlSeconds, ); - await this.addSessionToUserIndex(userId, sid); +await this.addSessionToUserIndex(userId, sid); return sid; } @@ -134,21 +136,9 @@ export class SessionService implements OnModuleDestroy { async removeSession(sid: string): Promise { const session = await this.getSession(sid); await this.redis.del(this.sessionKey(sid)); - if (session) { - await this.removeSessionFromUserIndex(session.userId, sid); - } - } - - async addSessionToUserIndex(userId: string, sid: string): Promise { - await this.redis.zadd(`user:sessions:${userId}`, Date.now(), sid); - } - - async removeSessionFromUserIndex(userId: string, sid: string): Promise { - await this.redis.zrem(`user:sessions:${userId}`, sid); - } - - async getUserSessionIds(userId: string): Promise { - return this.redis.zrange(`user:sessions:${userId}`, 0, -1); +if (session) { + await this.removeSessionFromUserIndex(session.userId, sid); +} } /** @@ -171,10 +161,18 @@ export class SessionService implements OnModuleDestroy { }; await this.redis - .multi() - .set(this.sessionKey(newSid), JSON.stringify(migrated), 'EX', this.sessionTtlSeconds) - .del(this.sessionKey(oldSid)) - .exec(); + .multi() + .set(this.sessionKey(newSid), JSON.stringify(migrated), 'EX', this.sessionTtlSeconds) + .del(this.sessionKey(oldSid)) + .exec(); + // Update user's session sorted set + if (existing) { + const userKey = this.userSessionKey(existing.userId); + await this.redis.multi() + .zrem(userKey, oldSid) + .zadd(userKey, Date.now(), newSid) + .exec(); + } return newSid; } @@ -259,6 +257,10 @@ export class SessionService implements OnModuleDestroy { await this.redis.eval(releaseScript, 1, lockKey, lockToken); } + private userSessionKey(userId: string): string { + return `user:sessions:${userId}`; + } + private sessionKey(sid: string): string { return `${this.sessionPrefix}${sid}`; }