Compare commits

...

1 Commits

Author SHA1 Message Date
Jason Rasmussen
cc5fdf4f58 feat: password invalidate sessions 2026-07-21 20:33:58 -04:00
6 changed files with 43 additions and 11 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -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.
```

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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', () => {

View File

@@ -58,18 +58,24 @@ export class CliService extends BaseService {
return users.map((user) => mapUserAdmin(user));
}
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
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 };
}