feat: user pin-code ()

* feat: user pincode

* pr feedback

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Alex 2025-05-09 16:00:58 -05:00 committed by GitHub
parent 55af925ab3
commit 3f719bd8d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1392 additions and 39 deletions

View file

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

View file

@ -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<AuthStatusResponseDto> {
return this.service.getAuthStatus(auth);
}
@Post('pin-code')
@Authenticated()
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.setupPinCode(auth, dto);
}
@Put('pin-code')
@Authenticated()
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.changePinCode(auth, dto);
}
@Delete('pin-code')
@Authenticated()
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
}

View file

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

View file

@ -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()

View file

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

View file

@ -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();

View file

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db);
}

View file

@ -37,6 +37,9 @@ export class UserTable {
@Column({ default: '' })
password!: Generated<string>;
@Column({ nullable: true })
pinCode!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;

View file

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

View file

@ -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<UserAdminResponseDto> {
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<UserAdminResponseDto> {
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<AuthDto> {
@ -428,4 +483,16 @@ export class AuthService extends BaseService {
}
return url;
}
async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
return {
pinCode: !!user.pinCode,
password: !!user.password,
};
}
}

View file

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

View file

@ -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 */