From 98f2cbb65818d38824ee78cd006b07a0d977392e Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 14 Nov 2024 09:42:04 +0100 Subject: [PATCH] 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(