From b9f6c4aaf22fd032cf1cc4357db06fe9ca4e3abc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 Jul 2026 12:09:05 -0400 Subject: [PATCH] feat: password invalidate sessions (#30125) --- .../img/reset-admin-password.webp | Bin 9066 -> 0 bytes docs/docs/administration/server-commands.md | 1 + .../immich-admin/immich-admin.e2e-spec.ts | 4 ++++ server/src/commands/index.ts | 4 ++-- .../commands/reset-admin-password.command.ts | 20 +++++++++++++----- server/src/services/cli.service.spec.ts | 19 +++++++++++++++-- server/src/services/cli.service.ts | 10 +++++++-- 7 files changed, 47 insertions(+), 11 deletions(-) delete mode 100644 docs/docs/administration/img/reset-admin-password.webp diff --git a/docs/docs/administration/img/reset-admin-password.webp b/docs/docs/administration/img/reset-admin-password.webp deleted file mode 100644 index 5062d24a6b79f8515e8b78a8fd743965a171c34d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9066 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

YEkRBU8M`o~BezqD4f6Q~J^zvjGDAVO=oXDr!V) z27_txasRk759K3>Sft;0OiXC0^>s5sew*sT=+pW;XkWE20b}nz-{p2TprLXd8S9$| zTDYhJXbAyNI}zOJ_SUIb{V;WKH@9Er)wz$AQtXx+nT*(&>4(%DVd(@)!J#mr22+(XAJ;S@K4L95G8q)Yq_q!oig>64N&UbYKin;c%`}*%;asf zTL?~~3AJs|Wrf4Y)HtMb6?hs|N{!``48Q0|Uf~P%#^oc}>KuODSKm7+nz%4u7u7=U zmfRIl1QJb7x9_nljgF4|4V7EXn(dPEVyAVc`54lCJfUk6zA{f~*OaB7`lQupJM{%Sr{7sp+%E3u0Nr%_vfujT5d| z8R=7`!T3EN{_1JSRl@}I+2Ust1Q{{;N!Us}V&m2Tqj=4e4kXe_X>hFYAyD@}GF~`% z{19p3ew(?jVKFEBX2@{;WrwM(J@SHzB@zRvovAqL?;`j$xJug)4=?8J|IgLVis4wh crc{x#J5U5KxuQ_*Qb|g9NTmP(000000D7XF4FCWD diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 104c16c0a2..49bcf17700 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -35,6 +35,7 @@ Found Admin: - Email=admin@example.com - Name=Immich Admin ? Please choose a new password (optional) immich-is-cool +? Invalidate existing sessions? Yes The admin password has been updated. ``` diff --git a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts index 24699cda30..ad0e43ff14 100644 --- a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts +++ b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts @@ -65,6 +65,10 @@ describe(`immich-admin`, () => { child.stdout.on('data', (chunk) => { data += chunk; if (data.includes('Please choose a new password (optional)')) { + child.stdin.write('\n'); + } + + if (data.includes('Invalidate existing sessions?')) { child.stdin.end('\n'); } }); diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 2a2dd1857d..0d03e4cd95 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -8,13 +8,13 @@ import { } from 'src/commands/media-location.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; -import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { SchemaCheck } from 'src/commands/schema-check'; import { VersionCommand } from 'src/commands/version.command'; export const commandsAndQuestions = [ ResetAdminPasswordCommand, - PromptPasswordQuestions, + PromptPasswordResetQuestions, PromptEmailQuestion, EnablePasswordLoginCommand, DisablePasswordLoginCommand, diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index e5dee49837..703f693326 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { CliService } from 'src/services/cli.service'; const prompt = (inquirer: InquirerService) => { - return function ask(admin: UserAdminResponseDto) { + return (admin: UserAdminResponseDto) => { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} @@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => { - Email=${email} - Name=${name}`); - return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); + return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {}); }; }; @@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner { } } -@QuestionSet({ name: 'prompt-password' }) -export class PromptPasswordQuestions { +@QuestionSet({ name: 'prompt-password-reset' }) +export class PromptPasswordResetQuestions { @Question({ message: 'Please choose a new password (optional)', - name: 'password', + name: 'newPassword', }) parsePassword(value: string) { return value; } + + @Question({ + type: 'confirm', + message: 'Invalidate existing sessions?', + default: true, + name: 'invalidateSessions', + }) + parseInvalidate(value: boolean): boolean { + return value; + } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 347d9eef00..29c62d258f 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -37,7 +37,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); - const ask = vitest.fn().mockImplementation(() => {}); + const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -47,6 +47,7 @@ describe(CliService.name, () => { expect(ask).toHaveBeenCalled(); expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); + expect(mocks.session.invalidateAll).not.toHaveBeenCalled(); }); it('should use the supplied password', async () => { @@ -55,7 +56,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); - const ask = vitest.fn().mockResolvedValue('new-password'); + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -66,6 +67,20 @@ describe(CliService.name, () => { expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); + + it('should invalidate existing sessions when requested', async () => { + const admin = UserFactory.create({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(admin); + mocks.session.invalidateAll.mockResolvedValue(void 0); + + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true }); + + await sut.resetAdminPassword(ask); + + expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id }); + }); }); describe('disablePasswordLogin', () => { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 23d70b6860..f37353d7a1 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -58,18 +58,24 @@ export class CliService extends BaseService { return users.map((user) => mapUserAdmin(user)); } - async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { + async resetAdminPassword( + ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>, + ) { const admin = await this.userRepository.getAdmin(); if (!admin) { throw new Error('Admin account does not exist'); } - const providedPassword = await ask(mapUserAdmin(admin)); + const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin)); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); + if (invalidateSessions) { + await this.sessionRepository.invalidateAll({ userId: admin.id }); + } + return { admin, password, provided: !!providedPassword }; }