diff --git a/i18n/en.json b/i18n/en.json index dcfa0dc17f..080cd6a273 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -669,6 +669,8 @@ "change_password_description": "This is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_confirm_password": "Confirm Password", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_log_out": "Log out all other devices", + "change_password_form_log_out_description": "It is recommended to log out of all other devices", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 33b7f4a607..4a897f4079 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -13,30 +13,36 @@ part of openapi.api; class ChangePasswordDto { /// Returns a new [ChangePasswordDto] instance. ChangePasswordDto({ + this.invalidateSessions = false, required this.newPassword, required this.password, }); + bool invalidateSessions; + String newPassword; String password; @override bool operator ==(Object other) => identical(this, other) || other is ChangePasswordDto && + other.invalidateSessions == invalidateSessions && other.newPassword == newPassword && other.password == password; @override int get hashCode => // ignore: unnecessary_parenthesis + (invalidateSessions.hashCode) + (newPassword.hashCode) + (password.hashCode); @override - String toString() => 'ChangePasswordDto[newPassword=$newPassword, password=$password]'; + String toString() => 'ChangePasswordDto[invalidateSessions=$invalidateSessions, newPassword=$newPassword, password=$password]'; Map toJson() { final json = {}; + json[r'invalidateSessions'] = this.invalidateSessions; json[r'newPassword'] = this.newPassword; json[r'password'] = this.password; return json; @@ -51,6 +57,7 @@ class ChangePasswordDto { final json = value.cast(); return ChangePasswordDto( + invalidateSessions: mapValueOfType(json, r'invalidateSessions') ?? false, newPassword: mapValueOfType(json, r'newPassword')!, password: mapValueOfType(json, r'password')!, ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 17aa121718..0c2c3cc585 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11477,6 +11477,10 @@ }, "ChangePasswordDto": { "properties": { + "invalidateSessions": { + "default": false, + "type": "boolean" + }, "newPassword": { "example": "password", "minLength": 8, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1ef73f22a6..98562fa092 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -561,6 +561,7 @@ export type SignUpDto = { password: string; }; export type ChangePasswordDto = { + invalidateSessions?: boolean; newPassword: string; password: string; }; diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index 031ef460c2..7dd145ff5c 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -183,7 +183,7 @@ describe(AuthController.name, () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()) .post('/auth/change-password') - .send({ password: 'password', newPassword: 'Password1234' }); + .send({ password: 'password', newPassword: 'Password1234', invalidateSessions: false }); expect(ctx.authenticate).toHaveBeenCalled(); }); }); diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 2bb98b34a5..d700fc2ab8 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -4,7 +4,7 @@ import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { ImmichCookie, UserMetadataKey } from 'src/enum'; import { UserMetadataItem } from 'src/types'; -import { Optional, PinCode, toEmail } from 'src/validation'; +import { Optional, PinCode, toEmail, ValidateBoolean } from 'src/validation'; export type CookieResponse = { isSecure: boolean; @@ -83,6 +83,9 @@ export class ChangePasswordDto { @MinLength(8) @ApiProperty({ example: 'password' }) newPassword!: string; + + @ValidateBoolean({ optional: true, default: false }) + invalidateSessions?: boolean; } export class PinCodeSetupDto { diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 831a16342a..b399646409 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -74,6 +74,12 @@ delete from "session" where "id" = $1::uuid +-- SessionRepository.invalidate +delete from "session" +where + "userId" = $1 + and "id" != $2 + -- SessionRepository.lockAll update "session" set diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 92479a26dc..c3e6cd20cf 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -88,6 +88,8 @@ type EventMap = { UserDelete: [UserEvent]; UserRestore: [UserEvent]; + AuthChangePassword: [{ userId: string; currentSessionId?: string; invalidateSessions?: boolean }]; + // websocket events WebsocketConnect: [{ userId: string }]; }; diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index cdc0ab12db..52292b8e4a 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -101,6 +101,15 @@ export class SessionRepository { await this.db.deleteFrom('session').where('id', '=', asUuid(id)).execute(); } + @GenerateSql({ params: [{ userId: DummyValue.UUID, excludeId: DummyValue.UUID }] }) + async invalidate({ userId, excludeId }: { userId: string; excludeId?: string }) { + await this.db + .deleteFrom('session') + .where('userId', '=', userId) + .$if(!!excludeId, (qb) => qb.where('id', '!=', excludeId!)) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID] }) async lockAll(userId: string) { await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute(); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index d8d7598593..a34efedfb0 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -124,6 +124,11 @@ describe(AuthService.name, () => { expect(mocks.user.getForChangePassword).toHaveBeenCalledWith(user.id); expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); + expect(mocks.event.emit).toHaveBeenCalledWith('AuthChangePassword', { + userId: user.id, + currentSessionId: auth.session?.id, + shouldLogoutSessions: undefined, + }); }); it('should throw when password does not match existing password', async () => { @@ -147,6 +152,25 @@ describe(AuthService.name, () => { await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); + + it('should change the password and logout other sessions', async () => { + const user = factory.userAdmin(); + const auth = factory.auth({ user }); + const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true }; + + mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); + mocks.user.update.mockResolvedValue(user); + + await sut.changePassword(auth, dto); + + expect(mocks.user.getForChangePassword).toHaveBeenCalledWith(user.id); + expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); + expect(mocks.event.emit).toHaveBeenCalledWith('AuthChangePassword', { + userId: user.id, + invalidateSessions: true, + currentSessionId: auth.session?.id, + }); + }); }); describe('logout', () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index d118f1809a..1a68bbfce7 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -104,6 +104,12 @@ export class AuthService extends BaseService { const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword }); + await this.eventRepository.emit('AuthChangePassword', { + userId: user.id, + currentSessionId: auth.session?.id, + invalidateSessions: dto.invalidateSessions, + }); + return mapUserAdmin(updatedUser); } diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 3cbad28389..7eacd148ad 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -43,17 +43,13 @@ describe('SessionService', () => { describe('logoutDevices', () => { it('should logout all devices', async () => { const currentSession = factory.session(); - const otherSession = factory.session(); const auth = factory.auth({ session: currentSession }); - mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); - mocks.session.delete.mockResolvedValue(); + mocks.session.invalidate.mockResolvedValue(); await sut.deleteAll(auth); - expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id); - expect(mocks.session.delete).toHaveBeenCalledWith(otherSession.id); - expect(mocks.session.delete).not.toHaveBeenCalledWith(currentSession.id); + expect(mocks.session.invalidate).toHaveBeenCalledWith({ userId: auth.user.id, excludeId: currentSession.id }); }); }); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index a9c7e92fcb..2f477c0d6a 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { OnJob } from 'src/decorators'; +import { OnEvent, OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionCreateDto, @@ -10,6 +10,7 @@ import { mapSession, } from 'src/dtos/session.dto'; import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @Injectable() @@ -69,18 +70,19 @@ export class SessionService extends BaseService { await this.sessionRepository.delete(id); } + async deleteAll(auth: AuthDto): Promise { + const userId = auth.user.id; + const currentSessionId = auth.session?.id; + await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId }); + } + async lock(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.SessionLock, ids: [id] }); await this.sessionRepository.update(id, { pinExpiresAt: null }); } - async deleteAll(auth: AuthDto): Promise { - const sessions = await this.sessionRepository.getByUserId(auth.user.id); - for (const session of sessions) { - if (session.id === auth.session?.id) { - continue; - } - await this.sessionRepository.delete(session.id); - } + @OnEvent({ name: 'AuthChangePassword' }) + async onAuthChangePassword({ userId, currentSessionId }: ArgOf<'AuthChangePassword'>): Promise { + await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId }); } } diff --git a/server/test/medium/specs/services/auth.service.spec.ts b/server/test/medium/specs/services/auth.service.spec.ts index f8bc3f1259..1fc306f790 100644 --- a/server/test/medium/specs/services/auth.service.spec.ts +++ b/server/test/medium/specs/services/auth.service.spec.ts @@ -131,6 +131,7 @@ describe(AuthService.name, () => { describe('changePassword', () => { it('should change the password and login with it', async () => { const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); const dto = { password: 'password', newPassword: 'new-password' }; const passwordHashed = await hash(dto.password, 10); const { user } = await ctx.newUser({ password: passwordHashed }); diff --git a/web/src/lib/components/user-settings-page/change-password-settings.svelte b/web/src/lib/components/user-settings-page/change-password-settings.svelte index 2735c4f13e..9a476ee5d6 100644 --- a/web/src/lib/components/user-settings-page/change-password-settings.svelte +++ b/web/src/lib/components/user-settings-page/change-password-settings.svelte @@ -4,6 +4,7 @@ NotificationType, } from '$lib/components/shared-components/notification/notification'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { SettingInputFieldType } from '$lib/constants'; import { changePassword } from '@immich/sdk'; import { Button } from '@immich/ui'; @@ -14,10 +15,11 @@ let password = $state(''); let newPassword = $state(''); let confirmPassword = $state(''); + let invalidateSessions = $state(false); const handleChangePassword = async () => { try { - await changePassword({ changePasswordDto: { password, newPassword } }); + await changePassword({ changePasswordDto: { password, newPassword, invalidateSessions } }); notificationController.show({ message: $t('updated_password'), @@ -69,6 +71,12 @@ passwordAutocomplete="new-password" /> + +