diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 0d8d48af..a7c977ca 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 aaa195ed..2ba9620d 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( @@ -119,7 +121,7 @@ export class SessionService implements OnModuleDestroy { 'EX', this.sessionTtlSeconds, ); - await this.addSessionToUserIndex(userId, sid); +await this.addSessionToUserIndex(userId, sid); return sid; } @@ -183,7 +185,13 @@ export class SessionService implements OnModuleDestroy { let deletedCount = 0; do { - const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + const [nextCursor, keys] = await this.redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + 100, + ); cursor = nextCursor; for (const key of keys) { @@ -240,10 +248,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; } @@ -328,6 +344,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}`; }