mirror of
https://github.com/immich-app/immich.git
synced 2025-11-02 18:47:07 -05:00
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:
parent
6bb1a9e083
commit
382481735a
@ -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",
|
||||||
|
|||||||
@ -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')!,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11477,6 +11477,10 @@
|
|||||||
},
|
},
|
||||||
"ChangePasswordDto": {
|
"ChangePasswordDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"invalidateSessions": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"newPassword": {
|
"newPassword": {
|
||||||
"example": "password",
|
"example": "password",
|
||||||
"minLength": 8,
|
"minLength": 8,
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 }];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user