feat: logout sessions on password change (#23188)

* log out ohter sessions on password change

* translations

* update and add tests

* rename event to UserLogoutOtherSessions

* fix typo

* requested changes

* fix tests

* fix medium:test

* use ValidateBoolean

* fix format

* dont delete current session id

* Update server/src/dtos/auth.dto.ts

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* rename event and invalidateOtherSessions

* chore: cleanup

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Jorge Montejo 2025-10-27 14:16:10 +01:00 committed by GitHub
parent 6bb1a9e083
commit 382481735a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 90 additions and 19 deletions

View File

@ -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_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_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_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_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match", "change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password", "change_password_form_reenter_new_password": "Re-enter New Password",

View File

@ -13,30 +13,36 @@ part of openapi.api;
class ChangePasswordDto { class ChangePasswordDto {
/// Returns a new [ChangePasswordDto] instance. /// Returns a new [ChangePasswordDto] instance.
ChangePasswordDto({ ChangePasswordDto({
this.invalidateSessions = false,
required this.newPassword, required this.newPassword,
required this.password, required this.password,
}); });
bool invalidateSessions;
String newPassword; String newPassword;
String password; String password;
@override @override
bool operator ==(Object other) => identical(this, other) || other is ChangePasswordDto && bool operator ==(Object other) => identical(this, other) || other is ChangePasswordDto &&
other.invalidateSessions == invalidateSessions &&
other.newPassword == newPassword && other.newPassword == newPassword &&
other.password == password; other.password == password;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(invalidateSessions.hashCode) +
(newPassword.hashCode) + (newPassword.hashCode) +
(password.hashCode); (password.hashCode);
@override @override
String toString() => 'ChangePasswordDto[newPassword=$newPassword, password=$password]'; String toString() => 'ChangePasswordDto[invalidateSessions=$invalidateSessions, newPassword=$newPassword, password=$password]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'invalidateSessions'] = this.invalidateSessions;
json[r'newPassword'] = this.newPassword; json[r'newPassword'] = this.newPassword;
json[r'password'] = this.password; json[r'password'] = this.password;
return json; return json;
@ -51,6 +57,7 @@ class ChangePasswordDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return ChangePasswordDto( return ChangePasswordDto(
invalidateSessions: mapValueOfType<bool>(json, r'invalidateSessions') ?? false,
newPassword: mapValueOfType<String>(json, r'newPassword')!, newPassword: mapValueOfType<String>(json, r'newPassword')!,
password: mapValueOfType<String>(json, r'password')!, password: mapValueOfType<String>(json, r'password')!,
); );

View File

@ -11477,6 +11477,10 @@
}, },
"ChangePasswordDto": { "ChangePasswordDto": {
"properties": { "properties": {
"invalidateSessions": {
"default": false,
"type": "boolean"
},
"newPassword": { "newPassword": {
"example": "password", "example": "password",
"minLength": 8, "minLength": 8,

View File

@ -561,6 +561,7 @@ export type SignUpDto = {
password: string; password: string;
}; };
export type ChangePasswordDto = { export type ChangePasswordDto = {
invalidateSessions?: boolean;
newPassword: string; newPassword: string;
password: string; password: string;
}; };

View File

@ -183,7 +183,7 @@ describe(AuthController.name, () => {
it('should be an authenticated route', async () => { it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()) await request(ctx.getHttpServer())
.post('/auth/change-password') .post('/auth/change-password')
.send({ password: 'password', newPassword: 'Password1234' }); .send({ password: 'password', newPassword: 'Password1234', invalidateSessions: false });
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });

View File

@ -4,7 +4,7 @@ import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { ImmichCookie, UserMetadataKey } from 'src/enum'; import { ImmichCookie, UserMetadataKey } from 'src/enum';
import { UserMetadataItem } from 'src/types'; import { UserMetadataItem } from 'src/types';
import { Optional, PinCode, toEmail } from 'src/validation'; import { Optional, PinCode, toEmail, ValidateBoolean } from 'src/validation';
export type CookieResponse = { export type CookieResponse = {
isSecure: boolean; isSecure: boolean;
@ -83,6 +83,9 @@ export class ChangePasswordDto {
@MinLength(8) @MinLength(8)
@ApiProperty({ example: 'password' }) @ApiProperty({ example: 'password' })
newPassword!: string; newPassword!: string;
@ValidateBoolean({ optional: true, default: false })
invalidateSessions?: boolean;
} }
export class PinCodeSetupDto { export class PinCodeSetupDto {

View File

@ -74,6 +74,12 @@ delete from "session"
where where
"id" = $1::uuid "id" = $1::uuid
-- SessionRepository.invalidate
delete from "session"
where
"userId" = $1
and "id" != $2
-- SessionRepository.lockAll -- SessionRepository.lockAll
update "session" update "session"
set set

View File

@ -88,6 +88,8 @@ type EventMap = {
UserDelete: [UserEvent]; UserDelete: [UserEvent];
UserRestore: [UserEvent]; UserRestore: [UserEvent];
AuthChangePassword: [{ userId: string; currentSessionId?: string; invalidateSessions?: boolean }];
// websocket events // websocket events
WebsocketConnect: [{ userId: string }]; WebsocketConnect: [{ userId: string }];
}; };

View File

@ -101,6 +101,15 @@ export class SessionRepository {
await this.db.deleteFrom('session').where('id', '=', asUuid(id)).execute(); 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] }) @GenerateSql({ params: [DummyValue.UUID] })
async lockAll(userId: string) { async lockAll(userId: string) {
await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute(); await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();

View File

@ -124,6 +124,11 @@ describe(AuthService.name, () => {
expect(mocks.user.getForChangePassword).toHaveBeenCalledWith(user.id); expect(mocks.user.getForChangePassword).toHaveBeenCalledWith(user.id);
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); 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 () => { 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); 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', () => { describe('logout', () => {

View File

@ -104,6 +104,12 @@ export class AuthService extends BaseService {
const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword }); 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); return mapUserAdmin(updatedUser);
} }

View File

@ -43,17 +43,13 @@ describe('SessionService', () => {
describe('logoutDevices', () => { describe('logoutDevices', () => {
it('should logout all devices', async () => { it('should logout all devices', async () => {
const currentSession = factory.session(); const currentSession = factory.session();
const otherSession = factory.session();
const auth = factory.auth({ session: currentSession }); const auth = factory.auth({ session: currentSession });
mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); mocks.session.invalidate.mockResolvedValue();
mocks.session.delete.mockResolvedValue();
await sut.deleteAll(auth); await sut.deleteAll(auth);
expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id); expect(mocks.session.invalidate).toHaveBeenCalledWith({ userId: auth.user.id, excludeId: currentSession.id });
expect(mocks.session.delete).toHaveBeenCalledWith(otherSession.id);
expect(mocks.session.delete).not.toHaveBeenCalledWith(currentSession.id);
}); });
}); });

View File

@ -1,6 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
SessionCreateDto, SessionCreateDto,
@ -10,6 +10,7 @@ import {
mapSession, mapSession,
} from 'src/dtos/session.dto'; } from 'src/dtos/session.dto';
import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@Injectable() @Injectable()
@ -69,18 +70,19 @@ export class SessionService extends BaseService {
await this.sessionRepository.delete(id); await this.sessionRepository.delete(id);
} }
async deleteAll(auth: AuthDto): Promise<void> {
const userId = auth.user.id;
const currentSessionId = auth.session?.id;
await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId });
}
async lock(auth: AuthDto, id: string): Promise<void> { async lock(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.SessionLock, ids: [id] }); await this.requireAccess({ auth, permission: Permission.SessionLock, ids: [id] });
await this.sessionRepository.update(id, { pinExpiresAt: null }); await this.sessionRepository.update(id, { pinExpiresAt: null });
} }
async deleteAll(auth: AuthDto): Promise<void> { @OnEvent({ name: 'AuthChangePassword' })
const sessions = await this.sessionRepository.getByUserId(auth.user.id); async onAuthChangePassword({ userId, currentSessionId }: ArgOf<'AuthChangePassword'>): Promise<void> {
for (const session of sessions) { await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId });
if (session.id === auth.session?.id) {
continue;
}
await this.sessionRepository.delete(session.id);
}
} }
} }

View File

@ -131,6 +131,7 @@ describe(AuthService.name, () => {
describe('changePassword', () => { describe('changePassword', () => {
it('should change the password and login with it', async () => { it('should change the password and login with it', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = { password: 'password', newPassword: 'new-password' }; const dto = { password: 'password', newPassword: 'new-password' };
const passwordHashed = await hash(dto.password, 10); const passwordHashed = await hash(dto.password, 10);
const { user } = await ctx.newUser({ password: passwordHashed }); const { user } = await ctx.newUser({ password: passwordHashed });

View File

@ -4,6 +4,7 @@
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; 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 { SettingInputFieldType } from '$lib/constants';
import { changePassword } from '@immich/sdk'; import { changePassword } from '@immich/sdk';
import { Button } from '@immich/ui'; import { Button } from '@immich/ui';
@ -14,10 +15,11 @@
let password = $state(''); let password = $state('');
let newPassword = $state(''); let newPassword = $state('');
let confirmPassword = $state(''); let confirmPassword = $state('');
let invalidateSessions = $state(false);
const handleChangePassword = async () => { const handleChangePassword = async () => {
try { try {
await changePassword({ changePasswordDto: { password, newPassword } }); await changePassword({ changePasswordDto: { password, newPassword, invalidateSessions } });
notificationController.show({ notificationController.show({
message: $t('updated_password'), message: $t('updated_password'),
@ -69,6 +71,12 @@
passwordAutocomplete="new-password" passwordAutocomplete="new-password"
/> />
<SettingSwitch
title={$t('log_out_all_devices')}
subtitle={$t('change_password_form_log_out_description')}
bind:checked={invalidateSessions}
/>
<div class="flex justify-end"> <div class="flex justify-end">
<Button <Button
shape="round" shape="round"