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 { OAuth2Client } from 'google-auth-library'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; import { JuniorTokenService } from '~/junior/services'; import { DeviceService, UserService } from '~/user/services'; import { User } from '../../user/entities'; import { PASSCODE_REGEX } from '../constants'; import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, SetEmailRequestDto, setJuniorPasswordRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { GrantType } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; import { removePadding, verifySignature } from '../utils'; const ONE_THOUSAND = 1000; const SALT_ROUNDS = 10; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly googleWebClientId = this.configService.getOrThrow('GOOGLE_WEB_CLIENT_ID'); private readonly googleAndroidClientId = this.configService.getOrThrow('GOOGLE_ANDROID_CLIENT_ID'); private readonly googleIosClientId = this.configService.getOrThrow('GOOGLE_IOS_CLIENT_ID'); private readonly client = new OAuth2Client(); 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, private readonly cacheService: CacheService, ) {} async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`); const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode }); return this.otpService.generateAndSendOtp({ userId: user.id, recipient: user.countryCode + user.phoneNumber, 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 }); if (user.isProfileCompleted) { this.logger.error( `User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} already verified`, ); throw new BadRequestException('USER.PHONE_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 ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`, ); throw new BadRequestException('OTP.INVALID_OTP'); } if (user.isPhoneVerified) { this.logger.log( `User with phone number ${ verifyUserDto.countryCode + verifyUserDto.phoneNumber } already verified but did not complete registration process`, ); const tokens = await this.generateAuthToken(user); return [tokens, user]; } const updatedUser = await this.userService.verifyUserAndCreateCustomer(user.id); const tokens = await this.generateAuthToken(updatedUser); this.logger.log( `User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} verified successfully`, ); return [tokens, updatedUser]; } async setEmail(userId: string, { email }: SetEmailRequestDto) { this.logger.log(`Setting email for user with id ${userId}`); const user = await this.userService.findUserOrThrow({ id: userId }); if (user.email) { this.logger.error(`Email already set for user with id ${userId}`); throw new BadRequestException('USER.EMAIL_ALREADY_SET'); } const existingUser = await this.userService.findUser({ email }); if (existingUser) { this.logger.error(`Email ${email} already taken`); throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); } return this.userService.setEmail(userId, email); } async setPasscode(userId: string, passcode: string) { this.logger.log(`Setting passcode for user with id ${userId}`); const user = await this.userService.findUserOrThrow({ id: userId }); if (user.password) { this.logger.error(`Passcode already set for user with id ${userId}`); throw new BadRequestException('AUTH.PASSCODE_ALREADY_SET'); } const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(passcode, salt); await this.userService.setPasscode(userId, hashedPasscode, salt); this.logger.log(`Passcode set successfully for user with id ${userId}`); } async setPhoneNumber(userId: string, { phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { const user = await this.userService.findUserOrThrow({ id: userId }); if (user.phoneNumber || user.countryCode) { this.logger.error(`Phone number already set for user with id ${userId}`); throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_SET'); } const existingUser = await this.userService.findUser({ phoneNumber, countryCode }); if (existingUser) { this.logger.error(`Phone number ${countryCode + phoneNumber} already taken`); throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN'); } await this.userService.setPhoneNumber(userId, phoneNumber, countryCode); return this.otpService.generateAndSendOtp({ userId, recipient: countryCode + phoneNumber, scope: OtpScope.VERIFY_PHONE, otpType: OtpType.SMS, }); } async verifyPhoneNumber(userId: string, otp: string) { const isOtpValid = await this.otpService.verifyOtp({ otpType: OtpType.SMS, scope: OtpScope.VERIFY_PHONE, userId, value: otp, }); if (!isOtpValid) { this.logger.error(`Invalid OTP for user with id ${userId}`); throw new BadRequestException('OTP.INVALID_OTP'); } return this.userService.verifyPhoneNumber(userId); } 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({ email }: SendForgetPasswordOtpRequestDto) { this.logger.log(`Sending forget password OTP to ${email}`); const user = await this.userService.findUserOrThrow({ email }); if (!user.isProfileCompleted) { this.logger.error(`Profile not completed for user with email ${email}`); throw new BadRequestException('USER.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) { this.logger.log(`Verifying forget password OTP for ${email}`); const user = await this.userService.findUserOrThrow({ email }); if (!user.isProfileCompleted) { this.logger.error(`Profile not completed for user with email ${email}`); throw new BadRequestException('USER.PROFILE_NOT_COMPLETED'); } const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.FORGET_PASSWORD, otpType: OtpType.EMAIL, value: otp, }); if (!isOtpValid) { this.logger.error(`Invalid OTP for user with email ${email}`); throw new BadRequestException('OTP.INVALID_OTP'); } this.validatePassword(password, confirmPassword, user); const hashedPassword = bcrypt.hashSync(password, user.salt); await this.userService.setPasscode(user.id, hashedPassword, user.salt); this.logger.log(`Passcode updated successfully for user with email ${email}`); } async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { let user: User; let tokens: ILoginResponse; if (loginDto.grantType === GrantType.GOOGLE) { this.logger.log(`Logging in user with email ${loginDto.email} using google`); [tokens, user] = await this.loginWithGoogle(loginDto); } if (loginDto.grantType === GrantType.APPLE) { this.logger.log(`Logging in user with email ${loginDto.email} using apple`); throw new BadRequestException('AUTH.APPLE_LOGIN_NOT_IMPLEMENTED'); } if (loginDto.grantType === GrantType.PASSWORD) { this.logger.log(`Logging in user with email ${loginDto.email} using password`); [tokens, user] = await this.loginWithPassword(loginDto); } if (loginDto.grantType === GrantType.BIOMETRIC) { this.logger.log(`Logging in user with email ${loginDto.email} using biometric`); [tokens, user] = await this.loginWithBiometric(loginDto, deviceId); } await this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date(), fcmToken: loginDto.fcmToken, userId: user!.id, }); this.logger.log(`User with email ${loginDto.email} logged in successfully`); return [tokens!, user!]; } async setJuniorPasscode(body: setJuniorPasswordRequestDto) { this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`); 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); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); } async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> { this.logger.log('Refreshing 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(`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, 'LOGOUT', expiryInTtl); } private async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { const user = await this.userService.findUserOrThrow({ email: loginDto.email }); this.logger.log(`validating password for user with email ${loginDto.email}`); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); if (!isPasswordValid) { this.logger.error(`Invalid password for user with email ${loginDto.email}`); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } const tokens = await this.generateAuthToken(user); this.logger.log(`Password validated successfully for user with email ${loginDto.email}`); 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 loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { try { const ticket = await this.client.verifyIdToken({ idToken: loginDto.googleToken, audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId], }); const payload = ticket.getPayload(); const existingUser = await this.userService.findUser({ googleId: payload?.sub }); if (!existingUser) { this.logger.debug(`User with google id ${payload?.sub} not found, creating new user`); const user = await this.userService.createGoogleUser(payload!.sub, payload!.email!); const tokens = await this.generateAuthToken(user); return [tokens, user]; } const tokens = await this.generateAuthToken(existingUser); return [tokens, existingUser]; } catch (error) { this.logger.error(`Invalid google token`, error); throw new UnauthorizedException(); } } 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) }; } private validatePassword(password: string, confirmPassword: string, user: User) { this.logger.log(`Validating password for user with id ${user.id}`); if (password !== confirmPassword) { this.logger.error(`Password mismatch for user with id ${user.id}`); throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); } if (!PASSCODE_REGEX.test(password)) { this.logger.error(`Invalid password for user with id ${user.id}`); throw new BadRequestException('AUTH.INVALID_PASSCODE'); } } }