From c6519cd95d29a52622afd2cc0123ccc28586039f Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 13 Nov 2024 15:41:07 +0100 Subject: [PATCH] feat: delete stale user sessions (#8738) --- src/lib/services/session-service.ts | 21 ++++++++++++++++ src/lib/services/user-service.ts | 25 +++++++++++++++++-- src/lib/types/experimental.ts | 3 ++- .../e2e/services/session-service.e2e.test.ts | 15 +++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/lib/services/session-service.ts b/src/lib/services/session-service.ts index 65d819328d..f4edddae27 100644 --- a/src/lib/services/session-service.ts +++ b/src/lib/services/session-service.ts @@ -2,6 +2,7 @@ import type { IUnleashStores } from '../types/stores'; import type { IUnleashConfig } from '../types/option'; import type { Logger } from '../logger'; import type { ISession, ISessionStore } from '../types/stores/session-store'; +import { compareDesc } from 'date-fns'; export default class SessionService { private logger: Logger; @@ -32,6 +33,26 @@ export default class SessionService { return this.sessionStore.deleteSessionsForUser(userId); } + async deleteStaleSessionsForUser( + userId: number, + maxSessions: number, + ): Promise { + let userSessions: ISession[] = []; + try { + // this method may throw errors when no session + userSessions = await this.sessionStore.getSessionsForUser(userId); + } catch (e) {} + const newestFirst = userSessions.sort((a, b) => + compareDesc(a.createdAt, b.createdAt), + ); + const sessionsToDelete = newestFirst.slice(maxSessions); + await Promise.all( + sessionsToDelete.map((session) => + this.sessionStore.delete(session.sid), + ), + ); + } + async deleteSession(sid: string): Promise { return this.sessionStore.delete(sid); } diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 1febd79f16..a83013bef2 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -40,7 +40,7 @@ import type { TokenUserSchema } from '../openapi/spec/token-user-schema'; import PasswordMismatch from '../error/password-mismatch'; import type EventService from '../features/events/event-service'; -import { SYSTEM_USER, SYSTEM_USER_AUDIT } from '../types'; +import { type IFlagResolver, SYSTEM_USER, SYSTEM_USER_AUDIT } from '../types'; import { PasswordPreviouslyUsedError } from '../error/password-previously-used'; import { RateLimitError } from '../error/rate-limit-error'; import type EventEmitter from 'events'; @@ -90,6 +90,8 @@ class UserService { private settingService: SettingService; + private flagResolver: IFlagResolver; + private passwordResetTimeouts: { [key: string]: NodeJS.Timeout } = {}; private baseUriPath: string; @@ -103,9 +105,14 @@ class UserService { getLogger, authentication, eventBus, + flagResolver, }: Pick< IUnleashConfig, - 'getLogger' | 'authentication' | 'server' | 'eventBus' + | 'getLogger' + | 'authentication' + | 'server' + | 'eventBus' + | 'flagResolver' >, services: { accessService: AccessService; @@ -125,6 +132,7 @@ class UserService { this.emailService = services.emailService; this.sessionService = services.sessionService; this.settingService = services.settingService; + this.flagResolver = flagResolver; process.nextTick(() => this.initAdminUser(authentication)); @@ -400,6 +408,19 @@ class UserService { const match = await bcrypt.compare(password, passwordHash); if (match) { const loginOrder = await this.store.successfullyLogin(user); + const deleteStaleUserSessions = this.flagResolver.getVariant( + 'deleteStaleUserSessions', + ); + if (deleteStaleUserSessions.feature_enabled) { + const allowedSessions = Number( + deleteStaleUserSessions.payload?.value || 30, + ); + // subtract current user session that will be created + await this.sessionService.deleteStaleSessionsForUser( + user.id, + Math.max(allowedSessions - 1, 0), + ); + } this.eventBus.emit(USER_LOGIN, { loginOrder }); return user; } diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 08715b2993..4b8c63b85a 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -58,7 +58,8 @@ export type IFlagKey = | 'productivityReportEmail' | 'enterprise-payg' | 'simplifyProjectOverview' - | 'flagOverviewRedesign'; + | 'flagOverviewRedesign' + | 'deleteStaleUserSessions'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; diff --git a/src/test/e2e/services/session-service.e2e.test.ts b/src/test/e2e/services/session-service.e2e.test.ts index cc506fe7cd..c03fd770a4 100644 --- a/src/test/e2e/services/session-service.e2e.test.ts +++ b/src/test/e2e/services/session-service.e2e.test.ts @@ -112,3 +112,18 @@ test('Can delete session by sid', async () => { sessionService.getSession('abc123'), ).rejects.toThrow(NotFoundError); }); + +test('Can delete stale sessions', async () => { + await sessionService.insertSession(newSession); + await sessionService.insertSession({ ...newSession, sid: 'new' }); + + const sessionsToKeep = 1; + await sessionService.deleteStaleSessionsForUser( + newSession.sess.user.id, + sessionsToKeep, + ); + + const sessions = await sessionService.getSessionsForUser(1); + expect(sessions.length).toBe(1); + expect(sessions[0].sid).toBe('new'); +});