From 98f2cbb65818d38824ee78cd006b07a0d977392e Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 09:42:04 +0100 Subject: [PATCH 1/9] feat: show deleted user sessions --- frontend/src/component/user/HostedAuth.tsx | 14 +++- frontend/src/component/user/PasswordAuth.tsx | 15 +++- .../api/actions/useAuthApi/useAuthApi.tsx | 12 +++- frontend/src/openapi/models/userSchema.ts | 1 + src/lib/openapi/spec/user-schema.ts | 6 ++ src/lib/services/session-service.ts | 3 +- src/lib/services/user-service.ts | 10 +-- src/lib/types/user.ts | 1 + .../e2e/services/user-service.e2e.test.ts | 72 ++++++++++++++++--- 9 files changed, 116 insertions(+), 18 deletions(-) diff --git a/frontend/src/component/user/HostedAuth.tsx b/frontend/src/component/user/HostedAuth.tsx index d9d9dcb247a9..cd0d4afedd03 100644 --- a/frontend/src/component/user/HostedAuth.tsx +++ b/frontend/src/component/user/HostedAuth.tsx @@ -12,6 +12,7 @@ import { LOGIN_BUTTON, LOGIN_EMAIL_ID, LOGIN_PASSWORD_ID } from 'utils/testIds'; import type { IAuthEndpointDetailsResponse } from 'hooks/api/getters/useAuth/useAuthEndpoint'; import { BadRequestError, NotFoundError } from 'utils/apiUtils'; import { contentSpacingY } from 'themes/themeStyles'; +import useToast from 'hooks/useToast'; interface IHostedAuthProps { authDetails: IAuthEndpointDetailsResponse; @@ -47,6 +48,7 @@ const HostedAuth: VFC = ({ authDetails, redirect }) => { passwordError?: string; apiError?: string; }>({}); + const { setToastData } = useToast(); const handleSubmit: FormEventHandler = async (evt) => { evt.preventDefault(); @@ -69,7 +71,17 @@ const HostedAuth: VFC = ({ authDetails, redirect }) => { } try { - await passwordAuth(authDetails.path, username, password); + const data = await passwordAuth( + authDetails.path, + username, + password, + ); + if (data.deletedSessions) { + setToastData({ + type: 'success', + title: `You have been logged out of ${data.deletedSessions} stale session(s)`, + }); + } refetchUser(); navigate(redirect, { replace: true }); } catch (error: any) { diff --git a/frontend/src/component/user/PasswordAuth.tsx b/frontend/src/component/user/PasswordAuth.tsx index f4b5ea2f013c..0b5020d87b17 100644 --- a/frontend/src/component/user/PasswordAuth.tsx +++ b/frontend/src/component/user/PasswordAuth.tsx @@ -17,6 +17,7 @@ import { NotFoundError, } from 'utils/apiUtils'; import { contentSpacingY } from 'themes/themeStyles'; +import useToast from 'hooks/useToast'; interface IPasswordAuthProps { authDetails: IAuthEndpointDetailsResponse; @@ -46,6 +47,7 @@ const PasswordAuth: VFC = ({ authDetails, redirect }) => { passwordError?: string; apiError?: string; }>({}); + const { setToastData } = useToast(); const handleSubmit: FormEventHandler = async (evt) => { evt.preventDefault(); @@ -68,7 +70,18 @@ const PasswordAuth: VFC = ({ authDetails, redirect }) => { } try { - await passwordAuth(authDetails.path, username, password); + const data = await passwordAuth( + authDetails.path, + username, + password, + ); + if (data.deletedSessions) { + setToastData({ + type: 'success', + title: `You have been logged out of ${data.deletedSessions} stale session(s)`, + }); + } + refetchUser(); navigate(redirect, { replace: true }); } catch (error: any) { diff --git a/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx b/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx index 27d073a6a7c8..bd022009f5e4 100644 --- a/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx +++ b/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx @@ -1,5 +1,6 @@ import { headers } from 'utils/apiUtils'; import useAPI from '../useApi/useApi'; +import type { UserSchema } from 'openapi'; type PasswordLogin = ( path: string, @@ -21,7 +22,11 @@ export const useAuthApi = (): IUseAuthApiOutput => { propagateErrors: true, }); - const passwordAuth = (path: string, username: string, password: string) => { + const passwordAuth = async ( + path: string, + username: string, + password: string, + ): Promise => { const req = { caller: () => { return fetch(path, { @@ -33,7 +38,10 @@ export const useAuthApi = (): IUseAuthApiOutput => { id: 'passwordAuth', }; - return makeRequest(req.caller, req.id); + const res = await makeRequest(req.caller, req.id); + const data = await res.json(); + + return data; }; const emailAuth = (path: string, email: string) => { diff --git a/frontend/src/openapi/models/userSchema.ts b/frontend/src/openapi/models/userSchema.ts index 092e389d5cb1..01a3eb99e8e8 100644 --- a/frontend/src/openapi/models/userSchema.ts +++ b/frontend/src/openapi/models/userSchema.ts @@ -59,4 +59,5 @@ export interface UserSchema { * @nullable */ username?: string | null; + deletedSessions?: number; } diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts index dff7f74afcef..87c7c11a0072 100644 --- a/src/lib/openapi/spec/user-schema.ts +++ b/src/lib/openapi/spec/user-schema.ts @@ -99,6 +99,12 @@ export const userSchema = { nullable: true, example: '01HTMEXAMPLESCIMID7SWWGHN6', }, + deletedSessions: { + description: + 'Experimental. The number of deleted sessions after the last login', + type: 'number', + example: 1, + }, }, components: {}, } as const; diff --git a/src/lib/services/session-service.ts b/src/lib/services/session-service.ts index f4edddae277f..9d03c9da987a 100644 --- a/src/lib/services/session-service.ts +++ b/src/lib/services/session-service.ts @@ -36,7 +36,7 @@ export default class SessionService { async deleteStaleSessionsForUser( userId: number, maxSessions: number, - ): Promise { + ): Promise { let userSessions: ISession[] = []; try { // this method may throw errors when no session @@ -51,6 +51,7 @@ export default class SessionService { this.sessionStore.delete(session.sid), ), ); + return sessionsToDelete.length; } async deleteSession(sid: string): Promise { diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index a83013bef2d5..a12419bacf03 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -416,10 +416,12 @@ class UserService { deleteStaleUserSessions.payload?.value || 30, ); // subtract current user session that will be created - await this.sessionService.deleteStaleSessionsForUser( - user.id, - Math.max(allowedSessions - 1, 0), - ); + const deletedSessionsCount = + await this.sessionService.deleteStaleSessionsForUser( + user.id, + Math.max(allowedSessions - 1, 0), + ); + user.deletedSessions = deletedSessionsCount; } this.eventBus.emit(USER_LOGIN, { loginOrder }); return user; diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts index c07e994cdd65..9d56a85f4428 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.ts @@ -31,6 +31,7 @@ export interface IUser { imageUrl?: string; accountType?: AccountType; scimId?: string; + deletedSessions?: number; } export type MinimalUser = Pick< diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts index 31d878c1bfe9..0299cc91aef2 100644 --- a/src/test/e2e/services/user-service.e2e.test.ts +++ b/src/test/e2e/services/user-service.e2e.test.ts @@ -18,6 +18,7 @@ import PasswordMismatch from '../../../lib/error/password-mismatch'; import type { EventService } from '../../../lib/services'; import { CREATE_ADDON, + type IFlagResolver, type IUnleashStores, type IUserStore, SYSTEM_USER_AUDIT, @@ -45,6 +46,8 @@ let eventService: EventService; let accessService: AccessService; let eventBus: EventEmitter; +const allowedSessions = 2; + beforeAll(async () => { db = await dbInit('user_service_serial', getLogger); stores = db.stores; @@ -63,14 +66,28 @@ beforeAll(async () => { sessionService = new SessionService(stores, config); settingService = new SettingService(stores, config, eventService); - userService = new UserService(stores, config, { - accessService, - resetTokenService, - emailService, - eventService, - sessionService, - settingService, - }); + const flagResolver = { + getVariant() { + return { + feature_enabled: true, + payload: { + value: String(allowedSessions), + }, + }; + }, + } as unknown as IFlagResolver; + userService = new UserService( + stores, + { ...config, flagResolver }, + { + accessService, + resetTokenService, + emailService, + eventService, + sessionService, + settingService, + }, + ); userStore = stores.userStore; const rootRoles = await accessService.getRootRoles(); adminRole = rootRoles.find((r) => r.name === RoleName.ADMIN)!; @@ -95,8 +112,9 @@ afterAll(async () => { await db.destroy(); }); -afterEach(async () => { +beforeEach(async () => { await userStore.deleteAll(); + await settingService.deleteAll(); }); test('should create initial admin user', async () => { @@ -362,6 +380,42 @@ test("deleting a user should delete the user's sessions", async () => { ).rejects.toThrow(NotFoundError); }); +test('user login should remove stale sessions', async () => { + const email = 'some@test.com'; + const user = await userService.createUser( + { + email, + password: 'A very strange P4ssw0rd_', + rootRole: adminRole.id, + }, + TEST_AUDIT_USER, + ); + const userSession = (index: number) => ({ + sid: `sid${index}`, + sess: { + cookie: { + originalMaxAge: minutesToMilliseconds(48), + expires: addDays(Date.now(), 1).toDateString(), + secure: false, + httpOnly: true, + path: '/', + }, + user, + }, + }); + + for (let i = 0; i < allowedSessions; i++) { + await sessionService.insertSession(userSession(i)); + } + + const insertedUser = await userService.loginUser( + email, + 'A very strange P4ssw0rd_', + ); + + expect(insertedUser.deletedSessions).toBe(1); +}); + test('updating a user without an email should not strip the email', async () => { const email = 'some@test.com'; const user = await userService.createUser( From 80c3ba6d56ae9fcc9a46eecd8de78481713bb466 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 09:50:53 +0100 Subject: [PATCH 2/9] feat: show deleted user sessions --- src/test/e2e/services/user-service.e2e.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts index 7724998d0de3..133500b2c6fe 100644 --- a/src/test/e2e/services/user-service.e2e.test.ts +++ b/src/test/e2e/services/user-service.e2e.test.ts @@ -67,6 +67,9 @@ beforeAll(async () => { settingService = new SettingService(stores, config, eventService); const flagResolver = { + isEnabled() { + return true; + }, getVariant() { return { feature_enabled: true, From 9b805017b58aa4837fe2950a8acc1ce43fc3d893 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 09:56:39 +0100 Subject: [PATCH 3/9] feat: show deleted user sessions --- frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx b/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx index bd022009f5e4..7c54ba395b9e 100644 --- a/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx +++ b/frontend/src/hooks/api/actions/useAuthApi/useAuthApi.tsx @@ -6,7 +6,7 @@ type PasswordLogin = ( path: string, username: string, password: string, -) => Promise; +) => Promise; type EmailLogin = (path: string, email: string) => Promise; From 8e80a4f539fc2e1ff1aa826e8f5b4bffcc48cea6 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 10:12:31 +0100 Subject: [PATCH 4/9] feat: show deleted user sessions --- frontend/src/component/user/PasswordAuth.tsx | 5 +++-- frontend/src/openapi/models/userSchema.ts | 1 + src/lib/services/user-service.ts | 1 + src/lib/types/user.ts | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/component/user/PasswordAuth.tsx b/frontend/src/component/user/PasswordAuth.tsx index 0b5020d87b17..6b9d43365010 100644 --- a/frontend/src/component/user/PasswordAuth.tsx +++ b/frontend/src/component/user/PasswordAuth.tsx @@ -75,10 +75,11 @@ const PasswordAuth: VFC = ({ authDetails, redirect }) => { username, password, ); - if (data.deletedSessions) { + if (data.deletedSessions && data.activeSessions) { setToastData({ type: 'success', - title: `You have been logged out of ${data.deletedSessions} stale session(s)`, + title: 'Maximum Session Limit Reached', + text: `You can have up to ${data.activeSessions} active sessions at a time. To allow this login, we’ve logged out ${data.deletedSessions} session(s) from other browsers.`, }); } diff --git a/frontend/src/openapi/models/userSchema.ts b/frontend/src/openapi/models/userSchema.ts index 01a3eb99e8e8..17f2a79c3ff0 100644 --- a/frontend/src/openapi/models/userSchema.ts +++ b/frontend/src/openapi/models/userSchema.ts @@ -60,4 +60,5 @@ export interface UserSchema { */ username?: string | null; deletedSessions?: number; + activeSessions?: number; } diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index eb705bc2997e..518cd48c5142 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -431,6 +431,7 @@ class UserService { Math.max(allowedSessions - 1, 0), ); user.deletedSessions = deletedSessionsCount; + user.activeSessions = allowedSessions; } this.eventBus.emit(USER_LOGIN, { loginOrder }); return user; diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts index 9d56a85f4428..5c2da957995f 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.ts @@ -32,6 +32,7 @@ export interface IUser { accountType?: AccountType; scimId?: string; deletedSessions?: number; + activeSessions?: number; } export type MinimalUser = Pick< From eac10713079db4f25c1a305ce2300afb7bb7335b Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 10:15:35 +0100 Subject: [PATCH 5/9] feat: show deleted user sessions --- src/test/e2e/services/user-service.e2e.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts index 133500b2c6fe..ec10ef31d1b6 100644 --- a/src/test/e2e/services/user-service.e2e.test.ts +++ b/src/test/e2e/services/user-service.e2e.test.ts @@ -410,12 +410,13 @@ test('user login should remove stale sessions', async () => { await sessionService.insertSession(userSession(i)); } - const insertedUser = await userService.loginUser( + const loggedInUser = await userService.loginUser( email, 'A very strange P4ssw0rd_', ); - expect(insertedUser.deletedSessions).toBe(1); + expect(loggedInUser.deletedSessions).toBe(1); + expect(loggedInUser.activeSessions).toBe(allowedSessions); }); test('updating a user without an email should not strip the email', async () => { From 8834f00b79755b3b0c6842ea9f242c6872582094 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 10:20:45 +0100 Subject: [PATCH 6/9] feat: show deleted user sessions --- frontend/src/component/user/HostedAuth.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/component/user/HostedAuth.tsx b/frontend/src/component/user/HostedAuth.tsx index cd0d4afedd03..410a199a71b6 100644 --- a/frontend/src/component/user/HostedAuth.tsx +++ b/frontend/src/component/user/HostedAuth.tsx @@ -76,10 +76,11 @@ const HostedAuth: VFC = ({ authDetails, redirect }) => { username, password, ); - if (data.deletedSessions) { + if (data.deletedSessions && data.activeSessions) { setToastData({ type: 'success', - title: `You have been logged out of ${data.deletedSessions} stale session(s)`, + title: 'Maximum Session Limit Reached', + text: `You can have up to ${data.activeSessions} active sessions at a time. To allow this login, we’ve logged out ${data.deletedSessions} session(s) from other browsers.`, }); } refetchUser(); From 567576a5be15d4760f0bddad4edc578e12ecea31 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 10:24:15 +0100 Subject: [PATCH 7/9] feat: show deleted user sessions --- src/lib/services/session-service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/services/session-service.ts b/src/lib/services/session-service.ts index e17edf814d6f..283796f60a91 100644 --- a/src/lib/services/session-service.ts +++ b/src/lib/services/session-service.ts @@ -37,11 +37,8 @@ export default class SessionService { 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 userSessions: ISession[] = + await this.sessionStore.getSessionsForUser(userId); const newestFirst = userSessions.sort((a, b) => compareDesc(a.createdAt, b.createdAt), ); From 4a7962a490947b7a902e1ed9f16e370fc9e86a38 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 10:25:29 +0100 Subject: [PATCH 8/9] feat: show deleted user sessions --- src/lib/openapi/spec/user-schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts index 551d10c7a29c..6bdeea0943ab 100644 --- a/src/lib/openapi/spec/user-schema.ts +++ b/src/lib/openapi/spec/user-schema.ts @@ -107,7 +107,7 @@ export const userSchema = { }, deletedSessions: { description: - 'Experimental. The number of deleted sessions after the last login', + 'Experimental. The number of deleted sessions after last login', type: 'number', example: 1, }, From 0795bbd9c39ebedb04d5bb8a8b0ce3226c184ed6 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 10:25:40 +0100 Subject: [PATCH 9/9] feat: show deleted user sessions --- src/lib/openapi/spec/user-schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts index 6bdeea0943ab..19bebfa8bb71 100644 --- a/src/lib/openapi/spec/user-schema.ts +++ b/src/lib/openapi/spec/user-schema.ts @@ -107,7 +107,7 @@ export const userSchema = { }, deletedSessions: { description: - 'Experimental. The number of deleted sessions after last login', + 'Experimental. The number of deleted browser sessions after last login', type: 'number', example: 1, },