From a828e09bc9ae210dab74dfe6779dfc77c4292c35 Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Sun, 28 Jun 2026 05:03:23 -0700 Subject: [PATCH] feat(session): add configurable max concurrent sessions per user with LRU eviction --- src/config/env.validation.ts | 2 ++ src/session/session.service.ts | 42 ++++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index b3451a91..21fc6b86 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 966a17f8..6422c2b7 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,6 +83,22 @@ export class SessionService implements OnModuleDestroy { 'EX', this.sessionTtlSeconds, ); + // Add session ID to user's sorted set for LRU eviction + await this.redis.zadd(this.userSessionKey(userId), now, sid); + // Trim sorted set to max size and evict oldest if necessary + const setSize = await this.redis.zcard(this.userSessionKey(userId)); + if (setSize > this.maxSessionsPerUser) { + const excess = setSize - this.maxSessionsPerUser; + const evicted = await this.redis.zrange(this.userSessionKey(userId), 0, excess - 1); + if (evicted.length) { + const multi = this.redis.multi(); + evicted.forEach(eid => { + multi.del(this.sessionKey(eid)); + multi.zrem(this.userSessionKey(userId), eid); + }); + await multi.exec(); + } + } return sid; } @@ -131,7 +149,11 @@ export class SessionService implements OnModuleDestroy { * @param sid The sid. */ async removeSession(sid: string): Promise { + const session = await this.getSession(sid); await this.redis.del(this.sessionKey(sid)); + if (session?.userId) { + await this.redis.zrem(this.userSessionKey(session.userId), sid); + } } /** @@ -154,10 +176,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; } @@ -242,6 +272,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}`; }