import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; import moment from 'moment'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; import { UserType } from '~/user/enums'; import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, setJuniorPasswordRequestDto, VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; import { Oauth2Service } from './oauth2.service'; const ONE_THOUSAND = 1000; const SALT_ROUNDS = 10; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private readonly otpService: OtpService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly userService: UserService, private readonly deviceService: DeviceService, private readonly userTokenService: UserTokenService, private readonly cacheService: CacheService, private readonly oauth2Service: Oauth2Service, ) {} async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { if (body.email) { const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); if (isEmailUsed) { this.logger.error(`Email ${body.email} is already used`); throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); } } if (body.password !== body.confirmPassword) { this.logger.error('Password and confirm password do not match'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); } this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`); const user = await this.userService.findOrCreateUser(body); return this.otpService.generateAndSendOtp({ userId: user.id, recipient: user.fullPhoneNumber, scope: OtpScope.VERIFY_PHONE, otpType: OtpType.SMS, }); } async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`); const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber, countryCode: verifyUserDto.countryCode, }); if (user.isPhoneVerified) { this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`); throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED'); } const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.VERIFY_PHONE, otpType: OtpType.SMS, value: verifyUserDto.otp, }); if (!isOtpValid) { this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); throw new BadRequestException('OTP.INVALID_OTP'); } await this.userService.verifyUser(user.id, verifyUserDto); await user.reload(); const tokens = await this.generateAuthToken(user); this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); return [tokens, user]; } async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) { this.logger.log(`Enabling biometric for user with id ${userId}`); const device = await this.deviceService.findUserDeviceById(deviceId, userId); if (!device) { this.logger.log(`Device not found, creating new device for user with id ${userId}`); return this.deviceService.createDevice({ deviceId, userId, publicKey, }); } if (device.publicKey) { this.logger.error(`Biometric already enabled for user with id ${userId}`); 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) { this.logger.error(`Device not found for user with id ${userId} and device id ${deviceId}`); throw new BadRequestException('AUTH.DEVICE_NOT_FOUND'); } if (!device.publicKey) { this.logger.error(`Biometric already disabled for user with id ${userId}`); throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED'); } return this.deviceService.updateDevice(deviceId, { publicKey: null }); } async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) { this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`); const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); return this.otpService.generateAndSendOtp({ userId: user.id, recipient: user.fullPhoneNumber, scope: OtpScope.FORGET_PASSWORD, otpType: OtpType.SMS, }); } async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) { const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.FORGET_PASSWORD, otpType: OtpType.SMS, value: otp, }); if (!isOtpValid) { this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); throw new BadRequestException('OTP.INVALID_OTP'); } // generate a token for the user to reset password const token = await this.userTokenService.generateToken( user.id, user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, moment().add(5, 'minutes').toDate(), ); return { token, user }; } async resetPassword({ countryCode, phoneNumber, resetPasswordToken, password, confirmPassword, }: ForgetPasswordRequestDto) { this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); await this.userTokenService.validateToken( resetPasswordToken, user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, ); if (password !== confirmPassword) { this.logger.error('Password and confirm password do not match'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); } const hashedPassword = bcrypt.hashSync(password, user.salt); await this.userService.setPassword(user.id, hashedPassword, user.salt); await this.userTokenService.invalidateToken(resetPasswordToken); this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`); } async setJuniorPasscode(body: setJuniorPasswordRequestDto) { this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`); const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(body.passcode, salt); await this.userService.setPassword(juniorId!, hashedPasscode, salt); await this.userTokenService.invalidateToken(body.qrToken); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); } async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> { this.logger.log('Refreshing token'); const isBlackListed = await this.cacheService.get(refreshToken); if (isBlackListed) { this.logger.error('Refresh token is blacklisted'); throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN'); } try { const isValid = await this.jwtService.verifyAsync(refreshToken, { secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'), }); this.logger.log(`Refreshing token for user with id ${isValid.sub}`); const user = await this.userService.findUserOrThrow({ id: isValid.sub }); const tokens = await this.generateAuthToken(user); this.logger.log(`Blacklisting old tokens for user with id ${isValid.sub}`); const refreshTokenExpiry = this.jwtService.decode(refreshToken).exp - Date.now() / ONE_THOUSAND; await this.cacheService.set(refreshToken, 'BLACKLISTED', refreshTokenExpiry); this.logger.log(`Token refreshed successfully for user with id ${isValid.sub}`); return [tokens, user]; } catch (error) { this.logger.error('Invalid refresh token'); throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN'); } } logout(req: Request) { this.logger.log('Logging out'); const accessToken = req.headers.authorization?.split(' ')[1] as string; const expiryInTtl = this.jwtService.decode(accessToken).exp - Date.now() / ONE_THOUSAND; return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl); } async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { const user = await this.userService.findUser({ countryCode: loginDto.countryCode, phoneNumber: loginDto.phoneNumber, }); if (!user) { this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } if (!user.password) { this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED'); } this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); if (!isPasswordValid) { this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } const tokens = await this.generateAuthToken(user); this.logger.log(`Password validated successfully for user`); return [tokens, user]; } // private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { // const user = await this.userService.findUserOrThrow({ email: loginDto.email }); // this.logger.log(`validating biometric for user with email ${loginDto.email}`); // const device = await this.deviceService.findUserDeviceById(deviceId, user.id); // if (!device) { // this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); // throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); // } // if (!device.publicKey) { // this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); // throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); // } // const cleanToken = removePadding(loginDto.signature); // const isValidToken = await verifySignature( // device.publicKey, // cleanToken, // `${user.email} - ${device.deviceId}`, // 'SHA1', // ); // if (!isValidToken) { // this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); // throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); // } // const tokens = await this.generateAuthToken(user); // this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); // return [tokens, user]; // } private async generateAuthToken(user: User) { this.logger.log(`Generating auth token for user with id ${user.id}`); 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'), }, ), ]); this.logger.log(`Auth token generated successfully for user with id ${user.id}`); return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) }; } }