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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user