import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; import { JuniorTokenService } from '~/junior/services'; import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants'; import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, SetEmailRequestDto, setJuniorPasswordRequestDto, } from '../dtos/request'; import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto'; import { User } from '../entities'; import { GrantType, Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; import { removePadding, verifySignature } from '../utils'; import { DeviceService } from './device.service'; import { UserService } from './user.service'; const ONE_THOUSAND = 1000; const SALT_ROUNDS = 10; @Injectable() export class AuthService { constructor( private readonly otpService: OtpService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly userService: UserService, private readonly deviceService: DeviceService, private readonly juniorTokenService: JuniorTokenService, ) {} async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode }); return this.otpService.generateAndSendOtp({ userId: user.id, recipient: user.phoneNumber, scope: OtpScope.VERIFY_PHONE, otpType: OtpType.SMS, }); } async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber }); if (user.isPasswordSet) { throw new BadRequestException('USERS.PHONE_ALREADY_VERIFIED'); } const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.VERIFY_PHONE, otpType: OtpType.SMS, value: verifyUserDto.otp, }); if (!isOtpValid) { throw new BadRequestException('USERS.INVALID_OTP'); } const updatedUser = await this.userService.verifyUserAndCreateCustomer(user); const tokens = await this.generateAuthToken(updatedUser); return [tokens, updatedUser]; } async setEmail(userId: string, { email }: SetEmailRequestDto) { const user = await this.userService.findUserOrThrow({ id: userId }); if (user.email) { throw new BadRequestException('USERS.EMAIL_ALREADY_SET'); } const existingUser = await this.userService.findUser({ email }); if (existingUser) { throw new BadRequestException('USERS.EMAIL_ALREADY_TAKEN'); } return this.userService.setEmail(userId, email); } async setPasscode(userId: string, passcode: string) { const user = await this.userService.findUserOrThrow({ id: userId }); if (user.password) { throw new BadRequestException('USERS.PASSCODE_ALREADY_SET'); } const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(passcode, salt); await this.userService.setPasscode(userId, hashedPasscode, salt); } async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) { const device = await this.deviceService.findUserDeviceById(deviceId, userId); if (!device) { return this.deviceService.createDevice({ deviceId, userId, publicKey, }); } if (device.publicKey) { throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED'); } return this.deviceService.updateDevice(deviceId, { publicKey }); } async disableBiometric(userId: string, { deviceId }: DisableBiometricRequestDto) { const device = await this.deviceService.findUserDeviceById(deviceId, userId); if (!device) { throw new BadRequestException('AUTH.DEVICE_NOT_FOUND'); } if (!device.publicKey) { throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED'); } return this.deviceService.updateDevice(deviceId, { publicKey: null }); } async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) { const user = await this.userService.findUserOrThrow({ email }); if (!user.isProfileCompleted) { throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED'); } return this.otpService.generateAndSendOtp({ userId: user.id, recipient: user.email, scope: OtpScope.FORGET_PASSWORD, otpType: OtpType.EMAIL, }); } async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) { const user = await this.userService.findUserOrThrow({ email }); if (!user.isProfileCompleted) { throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED'); } const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.FORGET_PASSWORD, otpType: OtpType.EMAIL, value: otp, }); if (!isOtpValid) { throw new BadRequestException('USERS.INVALID_OTP'); } this.validatePassword(password, confirmPassword, user); const hashedPassword = bcrypt.hashSync(password, user.salt); await this.userService.setPasscode(user.id, hashedPassword, user.salt); } async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { const user = await this.userService.findUser({ email: loginDto.email }); let tokens; if (!user) { throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } if (loginDto.grantType === GrantType.PASSWORD) { tokens = await this.loginWithPassword(loginDto, user); } else { tokens = await this.loginWithBiometric(loginDto, user, deviceId); } this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() }); return [tokens, user]; } async setJuniorPasscode(body: setJuniorPasswordRequestDto) { const juniorId = await this.juniorTokenService.validateToken(body.qrToken); const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(body.passcode, salt); await this.userService.setPasscode(juniorId, hashedPasscode, salt); await this.juniorTokenService.invalidateToken(body.qrToken); } async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> { try { const isValid = await this.jwtService.verifyAsync(refreshToken, { secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'), }); const user = await this.userService.findUserOrThrow({ id: isValid.sub }); const tokens = await this.generateAuthToken(user); return [tokens, user]; } catch (error) { throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN'); } } private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise { const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); if (!isPasswordValid) { throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } const tokens = await this.generateAuthToken(user); return tokens; } private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise { const device = await this.deviceService.findUserDeviceById(deviceId, user.id); if (!device) { throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); } if (!device.publicKey) { throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); } const cleanToken = removePadding(loginDto.deviceToken); const isValidToken = await verifySignature( device.publicKey, cleanToken, `${user.email} - ${device.deviceId}`, 'SHA1', ); if (!isValidToken) { throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); } const tokens = await this.generateAuthToken(user); return tokens; } private async generateAuthToken(user: User) { const [accessToken, refreshToken] = await Promise.all([ this.jwtService.sign( { sub: user.id, roles: user.roles }, { expiresIn: this.configService.getOrThrow('JWT_ACCESS_TOKEN_EXPIRY'), secret: this.configService.getOrThrow('JWT_ACCESS_TOKEN_SECRET'), }, ), this.jwtService.sign( { sub: user.id, roles: user.roles }, { expiresIn: this.configService.getOrThrow('JWT_REFRESH_TOKEN_EXPIRY'), secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'), }, ), ]); return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) }; } private validatePassword(password: string, confirmPassword: string, user: User) { if (password !== confirmPassword) { throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); } const roles = user.roles; if (roles.includes(Roles.GUARDIAN) && !PASSCODE_REGEX.test(password)) { throw new BadRequestException('AUTH.INVALID_PASSCODE'); } if (roles.includes(Roles.JUNIOR) && !PASSWORD_REGEX.test(password)) { throw new BadRequestException('AUTH.INVALID_PASSWORD'); } } }