From 4b7a9078fee8c55176a53adef37554b50703cf88 Mon Sep 17 00:00:00 2001 From: MontejoJorge Date: Thu, 23 Oct 2025 18:21:27 +0200 Subject: [PATCH 01/14] log out ohter sessions on password change --- .../lib/model/change_password_dto.dart | 19 ++++++++++++++++++- open-api/immich-openapi-specs.json | 4 ++++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/auth.dto.ts | 7 ++++++- server/src/repositories/event.repository.ts | 1 + server/src/services/auth.service.ts | 7 +++++++ server/src/services/session.service.ts | 16 +++++++++++++--- .../change-password-settings.svelte | 10 +++++++++- 8 files changed, 59 insertions(+), 6 deletions(-) diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 33b7f4a607390..043fc63ed451e 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -13,30 +13,46 @@ part of openapi.api; class ChangePasswordDto { /// Returns a new [ChangePasswordDto] instance. ChangePasswordDto({ + this.logOutOhterSessions, required this.newPassword, required this.password, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? logOutOhterSessions; + String newPassword; String password; @override bool operator ==(Object other) => identical(this, other) || other is ChangePasswordDto && + other.logOutOhterSessions == logOutOhterSessions && other.newPassword == newPassword && other.password == password; @override int get hashCode => // ignore: unnecessary_parenthesis + (logOutOhterSessions == null ? 0 : logOutOhterSessions!.hashCode) + (newPassword.hashCode) + (password.hashCode); @override - String toString() => 'ChangePasswordDto[newPassword=$newPassword, password=$password]'; + String toString() => 'ChangePasswordDto[logOutOhterSessions=$logOutOhterSessions, newPassword=$newPassword, password=$password]'; Map toJson() { final json = {}; + if (this.logOutOhterSessions != null) { + json[r'logOutOhterSessions'] = this.logOutOhterSessions; + } else { + // json[r'logOutOhterSessions'] = null; + } json[r'newPassword'] = this.newPassword; json[r'password'] = this.password; return json; @@ -51,6 +67,7 @@ class ChangePasswordDto { final json = value.cast(); return ChangePasswordDto( + logOutOhterSessions: mapValueOfType(json, r'logOutOhterSessions'), 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 3b258d505fb4d..9a6cc346c7764 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11469,6 +11469,10 @@ }, "ChangePasswordDto": { "properties": { + "logOutOhterSessions": { + "example": true, + "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 cdd004770144a..22b07fc3b132f 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 = { + logOutOhterSessions?: boolean; newPassword: string; password: string; }; diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 2bb98b34a5076..3a8c31a9d9da8 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { IsBoolean, 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'; @@ -83,6 +83,11 @@ export class ChangePasswordDto { @MinLength(8) @ApiProperty({ example: 'password' }) newPassword!: string; + + @IsBoolean() + @Optional() + @ApiProperty({ example: true }) + logOutOhterSessions?: boolean; } export class PinCodeSetupDto { diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 420be0e1b4585..717952db067cd 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -99,6 +99,7 @@ type EventMap = { /** user is permanently deleted */ UserDelete: [UserEvent]; UserRestore: [UserEvent]; + UserChangePassword: [{ userId: string; currentSessionId?: string }]; // websocket events WebsocketConnect: [{ userId: string }]; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index d118f1809a9c0..397759186254a 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -104,6 +104,13 @@ export class AuthService extends BaseService { const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword }); + if (dto.logOutOhterSessions) { + await this.eventRepository.emit('UserChangePassword', { + userId: auth.user.id, + currentSessionId: auth.session?.id, + }); + } + return mapUserAdmin(updatedUser); } diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index a9c7e92fcbbaf..bed3a75477249 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() @@ -74,10 +75,19 @@ export class SessionService extends BaseService { await this.sessionRepository.update(id, { pinExpiresAt: null }); } + @OnEvent({ name: 'UserChangePassword' }) + async handleUserChangePassword({ userId, currentSessionId }: ArgOf<'UserChangePassword'>): Promise { + await this.deleteAllSessionsForUser(userId, currentSessionId); + } + async deleteAll(auth: AuthDto): Promise { - const sessions = await this.sessionRepository.getByUserId(auth.user.id); + await this.deleteAllSessionsForUser(auth.user.id, auth.session?.id); + } + + private async deleteAllSessionsForUser(userId: string, excludeSessionId?: string): Promise { + const sessions = await this.sessionRepository.getByUserId(userId); for (const session of sessions) { - if (session.id === auth.session?.id) { + if (session.id === excludeSessionId) { continue; } await this.sessionRepository.delete(session.id); 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 2735c4f13e0a7..a42fa6088e8f0 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 logOutOhterSessions = $state(false); const handleChangePassword = async () => { try { - await changePassword({ changePasswordDto: { password, newPassword } }); + await changePassword({ changePasswordDto: { password, newPassword, logOutOhterSessions } }); notificationController.show({ message: $t('updated_password'), @@ -69,6 +71,12 @@ passwordAutocomplete="new-password" /> + +