From 3f719bd8d70702d11ea3a09a0033e838a1bef04c Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 May 2025 16:00:58 -0500 Subject: [PATCH] feat: user pin-code (#18138) * feat: user pincode * pr feedback * chore: cleanup --------- Co-authored-by: Jason Rasmussen --- i18n/en.json | 14 ++ mobile/openapi/README.md | Bin 34282 -> 34876 bytes mobile/openapi/lib/api.dart | Bin 12433 -> 12554 bytes .../openapi/lib/api/authentication_api.dart | Bin 8201 -> 13091 bytes mobile/openapi/lib/api_client.dart | Bin 31666 -> 31926 bytes .../lib/model/auth_status_response_dto.dart | Bin 0 -> 3178 bytes .../lib/model/pin_code_change_dto.dart | Bin 0 -> 4228 bytes .../openapi/lib/model/pin_code_setup_dto.dart | Bin 0 -> 2825 bytes .../lib/model/user_admin_update_dto.dart | Bin 6671 -> 6989 bytes open-api/immich-openapi-specs.json | 184 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 48 +++++ .../src/controllers/auth.controller.spec.ts | 46 +++++ server/src/controllers/auth.controller.ts | 29 ++- server/src/dtos/auth.dto.ts | 27 ++- server/src/dtos/user.dto.ts | 5 +- server/src/queries/user.repository.sql | 10 + server/src/repositories/user.repository.ts | 14 +- .../1746768490606-AddUserPincode.ts | 9 + server/src/schema/tables/user.table.ts | 3 + server/src/services/auth.service.spec.ts | 76 +++++++- server/src/services/auth.service.ts | 81 +++++++- server/src/services/user-admin.service.ts | 4 + server/src/validation.ts | 17 ++ .../user-settings-page/PinCodeInput.svelte | 114 +++++++++++ .../user-settings-page/PinCodeSettings.svelte | 116 +++++++++++ .../user-settings-list.svelte | 42 ++-- web/src/lib/modals/UserEditModal.svelte | 58 +++++- .../routes/admin/user-management/+page.svelte | 8 +- 28 files changed, 867 insertions(+), 38 deletions(-) create mode 100644 mobile/openapi/lib/model/auth_status_response_dto.dart create mode 100644 mobile/openapi/lib/model/pin_code_change_dto.dart create mode 100644 mobile/openapi/lib/model/pin_code_setup_dto.dart create mode 100644 server/src/schema/migrations/1746768490606-AddUserPincode.ts create mode 100644 web/src/lib/components/user-settings-page/PinCodeInput.svelte create mode 100644 web/src/lib/components/user-settings-page/PinCodeSettings.svelte diff --git a/i18n/en.json b/i18n/en.json index 80381dcff..6d683dfc5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,16 @@ { + "user_pin_code_settings": "PIN Code", + "user_pin_code_settings_description": "Manage your PIN code", + "current_pin_code": "Current PIN code", + "new_pin_code": "New PIN code", + "setup_pin_code": "Setup a PIN code", + "confirm_new_pin_code": "Confirm new PIN code", + "unable_to_change_pin_code": "Unable to change PIN code", + "unable_to_setup_pin_code": "Unable to setup PIN code", + "pin_code_changed_successfully": "Successfully changed PIN code", + "pin_code_setup_successfully": "Successfully setup a PIN code", + "pin_code_reset_successfully": "Successfully reset PIN code", + "reset_pin_code": "Reset PIN code", "about": "About", "account": "Account", "account_settings": "Account Settings", @@ -53,6 +65,7 @@ "confirm_email_below": "To confirm, type \"{email}\" below", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", + "confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?", "create_job": "Create job", "cron_expression": "Cron expression", "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. Crontab Guru", @@ -922,6 +935,7 @@ "unable_to_remove_reaction": "Unable to remove reaction", "unable_to_repair_items": "Unable to repair items", "unable_to_reset_password": "Unable to reset password", + "unable_to_reset_pin_code": "Unable to reset PIN code", "unable_to_resolve_duplicate": "Unable to resolve duplicate", "unable_to_restore_assets": "Unable to restore assets", "unable_to_restore_trash": "Unable to restore trash", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5395f46801013b66ca506cef4eb4230b1e624125..a141d465d126884479e8dd5631c79b7659f5fa2e 100644 GIT binary patch delta 381 zcmaFW&9r9%(}pB*-sFtLy!6z7%sl7(l+?*u5_0Mgk%G*;WT1$qLXCo!RzPTomX?Bk zVrfZ+K2SmzBs2Mpm@IF4YKbFIFt{YKq_h~MTNNk*QdSHWf#`O3Md*fzgY-^L65q>H zlv{rBqP4KB(bD)@-)rv8d!>BE07V%G9{>OV diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index bf987f441e5d31b1cfe2bd22f98c9a474ab84760..f850bdf4037679f49c63d6807f2be334330aa58e 100644 GIT binary patch delta 1043 zcmeBlSe&+@kV7yuL_t5Xv?N2nATv)lIX@+Jas!8_d_ZQNGm!0^k(igB>Qa&)s{oWn zh)fP((lUeyglCpyc$AbBc;=<$YoKdG)3uH%m;-K&0J9x0s-Y7_zcYqTe#o_n2=cs0E`-`k$bK#!&B+^B*%h$39MvTc#Ai&7k&+g0cZG#caY61Sy kNpJ@s1SVgMRONy4gHuaN3nm|w6-O0tlvCcE?&@s@0811m^8f$< delta 19 bcmdn?lX25`#tp$*n>T8SN^j0_^D+YfUHAwq diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..203923164f42d38eb37985a8cf58177fab19d6e3 GIT binary patch literal 3178 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 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index 951ee8ce845f3090cff9d41518d25dbbd80f49b5..ee5c006840f488a6975426a827ffc02d97604622 100644 GIT binary patch delta 207 zcmeA-Icv7zIwN~QW}b6?O6uk_jAvN*gG-7s^V01VV1kp`_Am)SISRJ6aB)Y@<&3gO z;(4VxISTd)1`1X%C6g1mg*U(8a$ppIDzjCA8LGy!i3w)y("/auth/status", { + ...opts + })); +} export function validateAccessToken(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index 0937fc532..4129b2412 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -142,4 +142,50 @@ describe(AuthController.name, () => { expect(ctx.authenticate).toHaveBeenCalled(); }); }); + + describe('POST /auth/pin-code', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should reject 5 digits', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' }); + expect(status).toEqual(400); + expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string'])); + }); + + it('should reject 7 digits', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' }); + expect(status).toEqual(400); + expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string'])); + }); + + it('should reject non-numbers', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' }); + expect(status).toEqual(400); + expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string'])); + }); + }); + + describe('PUT /auth/pin-code', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('DELETE /auth/pin-code', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /auth/status', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/auth/status'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 4ee3c2690..56acaa5c6 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,12 +1,15 @@ -import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthDto, + AuthStatusResponseDto, ChangePasswordDto, LoginCredentialDto, LoginResponseDto, LogoutResponseDto, + PinCodeChangeDto, + PinCodeSetupDto, SignUpDto, ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; @@ -74,4 +77,28 @@ export class AuthController { ImmichCookie.IS_AUTHENTICATED, ]); } + + @Get('status') + @Authenticated() + getAuthStatus(@Auth() auth: AuthDto): Promise { + return this.service.getAuthStatus(auth); + } + + @Post('pin-code') + @Authenticated() + setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { + return this.service.setupPinCode(auth, dto); + } + + @Put('pin-code') + @Authenticated() + async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { + return this.service.changePinCode(auth, dto); + } + + @Delete('pin-code') + @Authenticated() + async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { + return this.service.resetPinCode(auth, dto); + } } diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index a1978d39d..cc05d2d86 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -3,7 +3,7 @@ import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { ImmichCookie } from 'src/enum'; -import { Optional, toEmail } from 'src/validation'; +import { Optional, PinCode, toEmail } from 'src/validation'; export type CookieResponse = { isSecure: boolean; @@ -78,6 +78,26 @@ export class ChangePasswordDto { newPassword!: string; } +export class PinCodeSetupDto { + @PinCode() + pinCode!: string; +} + +export class PinCodeResetDto { + @PinCode({ optional: true }) + pinCode?: string; + + @Optional() + @IsString() + @IsNotEmpty() + password?: string; +} + +export class PinCodeChangeDto extends PinCodeResetDto { + @PinCode() + newPinCode!: string; +} + export class ValidateAccessTokenResponseDto { authStatus!: boolean; } @@ -114,3 +134,8 @@ export class OAuthConfigDto { export class OAuthAuthorizeResponseDto { url!: string; } + +export class AuthStatusResponseDto { + pinCode!: boolean; + password!: boolean; +} diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 31275f9c2..9efb531bc 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; -import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; +import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; export class UserUpdateMeDto { @Optional() @@ -116,6 +116,9 @@ export class UserAdminUpdateDto { @IsString() password?: string; + @PinCode({ optional: true, nullable: true, emptyToNull: true }) + pinCode?: string | null; + @Optional() @IsString() @IsNotEmpty() diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 72881feea..33f296026 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -87,6 +87,16 @@ where "users"."isAdmin" = $1 and "users"."deletedAt" is null +-- UserRepository.getForPinCode +select + "users"."pinCode", + "users"."password" +from + "users" +where + "users"."id" = $1 + and "users"."deletedAt" is null + -- UserRepository.getByEmail select "id", diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 4d7671ca9..f8710746a 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -89,13 +89,23 @@ export class UserRepository { return !!admin; } + @GenerateSql({ params: [DummyValue.UUID] }) + getForPinCode(id: string) { + return this.db + .selectFrom('users') + .select(['users.pinCode', 'users.password']) + .where('users.id', '=', id) + .where('users.deletedAt', 'is', null) + .executeTakeFirstOrThrow(); + } + @GenerateSql({ params: [DummyValue.EMAIL] }) - getByEmail(email: string, withPassword?: boolean) { + getByEmail(email: string, options?: { withPassword?: boolean }) { return this.db .selectFrom('users') .select(columns.userAdmin) .select(withMetadata) - .$if(!!withPassword, (eb) => eb.select('password')) + .$if(!!options?.withPassword, (eb) => eb.select('password')) .where('email', '=', email) .where('users.deletedAt', 'is', null) .executeTakeFirst(); diff --git a/server/src/schema/migrations/1746768490606-AddUserPincode.ts b/server/src/schema/migrations/1746768490606-AddUserPincode.ts new file mode 100644 index 000000000..12dc3c2d1 --- /dev/null +++ b/server/src/schema/migrations/1746768490606-AddUserPincode.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db); +} diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 7525a739a..c806d6e3f 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -37,6 +37,9 @@ export class UserTable { @Column({ default: '' }) password!: Generated; + @Column({ nullable: true }) + pinCode!: string | null; + @CreateDateColumn() createdAt!: Generated; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 75f5b8a52..82172d6b9 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { SALT_ROUNDS } from 'src/constants'; import { UserAdmin } from 'src/database'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthType, Permission } from 'src/enum'; @@ -118,7 +119,7 @@ describe(AuthService.name, () => { await sut.changePassword(auth, dto); - expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true }); expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); }); @@ -859,4 +860,77 @@ describe(AuthService.name, () => { expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); }); }); + + describe('setupPinCode', () => { + it('should setup a PIN code', async () => { + const user = factory.userAdmin(); + const auth = factory.auth({ user }); + const dto = { pinCode: '123456' }; + + mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' }); + mocks.user.update.mockResolvedValue(user); + + await sut.setupPinCode(auth, dto); + + expect(mocks.user.getForPinCode).toHaveBeenCalledWith(user.id); + expect(mocks.crypto.hashBcrypt).toHaveBeenCalledWith('123456', SALT_ROUNDS); + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: expect.any(String) }); + }); + + it('should fail if the user already has a PIN code', async () => { + const user = factory.userAdmin(); + const auth = factory.auth({ user }); + + mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); + + await expect(sut.setupPinCode(auth, { pinCode: '123456' })).rejects.toThrow('User already has a PIN code'); + }); + }); + + describe('changePinCode', () => { + it('should change the PIN code', async () => { + const user = factory.userAdmin(); + const auth = factory.auth({ user }); + const dto = { pinCode: '123456', newPinCode: '012345' }; + + mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); + mocks.user.update.mockResolvedValue(user); + mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); + + await sut.changePinCode(auth, dto); + + expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('123456', '123456 (hashed)'); + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: '012345 (hashed)' }); + }); + + it('should fail if the PIN code does not match', async () => { + const user = factory.userAdmin(); + mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); + mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); + + await expect( + sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }), + ).rejects.toThrow('Wrong PIN code'); + }); + }); + + describe('resetPinCode', () => { + it('should reset the PIN code', async () => { + const user = factory.userAdmin(); + mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); + mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); + + await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); + + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); + }); + + it('should throw if the PIN code does not match', async () => { + const user = factory.userAdmin(); + mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); + mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); + + await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); + }); + }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index b250b63a5..65dd84693 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -9,11 +9,15 @@ import { StorageCore } from 'src/cores/storage.core'; import { UserAdmin } from 'src/database'; import { AuthDto, + AuthStatusResponseDto, ChangePasswordDto, LoginCredentialDto, LogoutResponseDto, OAuthCallbackDto, OAuthConfigDto, + PinCodeChangeDto, + PinCodeResetDto, + PinCodeSetupDto, SignUpDto, mapLoginResponse, } from 'src/dtos/auth.dto'; @@ -56,9 +60,9 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Password login has been disabled'); } - let user = await this.userRepository.getByEmail(dto.email, true); + let user = await this.userRepository.getByEmail(dto.email, { withPassword: true }); if (user) { - const isAuthenticated = this.validatePassword(dto.password, user); + const isAuthenticated = this.validateSecret(dto.password, user.password); if (!isAuthenticated) { user = undefined; } @@ -86,12 +90,12 @@ export class AuthService extends BaseService { async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise { const { password, newPassword } = dto; - const user = await this.userRepository.getByEmail(auth.user.email, true); + const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true }); if (!user) { throw new UnauthorizedException(); } - const valid = this.validatePassword(password, user); + const valid = this.validateSecret(password, user.password); if (!valid) { throw new BadRequestException('Wrong password'); } @@ -103,6 +107,56 @@ export class AuthService extends BaseService { return mapUserAdmin(updatedUser); } + async setupPinCode(auth: AuthDto, { pinCode }: PinCodeSetupDto) { + const user = await this.userRepository.getForPinCode(auth.user.id); + if (!user) { + throw new UnauthorizedException(); + } + + if (user.pinCode) { + throw new BadRequestException('User already has a PIN code'); + } + + const hashed = await this.cryptoRepository.hashBcrypt(pinCode, SALT_ROUNDS); + await this.userRepository.update(auth.user.id, { pinCode: hashed }); + } + + async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) { + const user = await this.userRepository.getForPinCode(auth.user.id); + this.resetPinChecks(user, dto); + + await this.userRepository.update(auth.user.id, { pinCode: null }); + } + + async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { + const user = await this.userRepository.getForPinCode(auth.user.id); + this.resetPinChecks(user, dto); + + const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS); + await this.userRepository.update(auth.user.id, { pinCode: hashed }); + } + + private resetPinChecks( + user: { pinCode: string | null; password: string | null }, + dto: { pinCode?: string; password?: string }, + ) { + if (!user.pinCode) { + throw new BadRequestException('User does not have a PIN code'); + } + + if (dto.password) { + if (!this.validateSecret(dto.password, user.password)) { + throw new BadRequestException('Wrong password'); + } + } else if (dto.pinCode) { + if (!this.validateSecret(dto.pinCode, user.pinCode)) { + throw new BadRequestException('Wrong PIN code'); + } + } else { + throw new BadRequestException('Either password or pinCode is required'); + } + } + async adminSignUp(dto: SignUpDto): Promise { const adminUser = await this.userRepository.getAdmin(); if (adminUser) { @@ -371,11 +425,12 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid API key'); } - private validatePassword(inputPassword: string, user: { password?: string }): boolean { - if (!user || !user.password) { + private validateSecret(inputSecret: string, existingHash?: string | null): boolean { + if (!existingHash) { return false; } - return this.cryptoRepository.compareBcrypt(inputPassword, user.password); + + return this.cryptoRepository.compareBcrypt(inputSecret, existingHash); } private async validateSession(tokenValue: string): Promise { @@ -428,4 +483,16 @@ export class AuthService extends BaseService { } return url; } + + async getAuthStatus(auth: AuthDto): Promise { + const user = await this.userRepository.getForPinCode(auth.user.id); + if (!user) { + throw new UnauthorizedException(); + } + + return { + pinCode: !!user.pinCode, + password: !!user.password, + }; + } } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index c1c6cc49e..38c0106f4 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -70,6 +70,10 @@ export class UserAdminService extends BaseService { dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); } + if (dto.pinCode) { + dto.pinCode = await this.cryptoRepository.hashBcrypt(dto.pinCode, SALT_ROUNDS); + } + if (dto.storageLabel === '') { dto.storageLabel = null; } diff --git a/server/src/validation.ts b/server/src/validation.ts index 26367aeff..2d160f43c 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -18,6 +18,7 @@ import { IsOptional, IsString, IsUUID, + Matches, Validate, ValidateBy, ValidateIf, @@ -70,6 +71,22 @@ export class UUIDParamDto { id!: string; } +type PinCodeOptions = { optional?: boolean } & OptionalOptions; +export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => { + const decorators = [ + IsString(), + IsNotEmpty(), + Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }), + ApiProperty({ example: '123456' }), + ]; + + if (optional) { + decorators.push(Optional(options)); + } + + return applyDecorators(...decorators); +}; + export interface OptionalOptions extends ValidationOptions { nullable?: boolean; /** convert empty strings to null */ diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte new file mode 100644 index 000000000..e149f2685 --- /dev/null +++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte @@ -0,0 +1,114 @@ + + +
+ {#if label} + + {/if} +
+ {#each { length: pinLength } as _, index (index)} + handleInput(event, index)} + aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`} + /> + {/each} +
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte new file mode 100644 index 000000000..ef122b14e --- /dev/null +++ b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte @@ -0,0 +1,116 @@ + + +
+
+
+
+ {#if hasPinCode} +

Change PIN code

+ + + + + + {:else} +

{$t('setup_pin_code')}

+ + + + {/if} +
+ +
+ + +
+
+
+
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 934fa5708..32747f5ba 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -1,24 +1,16 @@