mirror of
https://github.com/immich-app/immich.git
synced 2025-07-13 20:38:46 +02:00
feat: user pin-code (#18138)
* feat: user pincode * pr feedback * chore: cleanup --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
parent
55af925ab3
commit
3f719bd8d7
28 changed files with 1392 additions and 39 deletions
server/src
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -37,6 +37,9 @@ export class UserTable {
|
|||
@Column({ default: '' })
|
||||
password!: Generated<string>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
pinCode!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue