diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index bd0b6cd..9a75012 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -16,6 +16,7 @@ import { SetEmailRequestDto, setJuniorPasswordRequestDto, SetPasscodeRequestDto, + VerifyOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; @@ -54,6 +55,23 @@ export class AuthController { await this.authService.setPasscode(sub, passcode); } + @Post('register/set-phone/otp') + @UseGuards(AccessTokenGuard) + async setPhoneNumber( + @AuthenticatedUser() { sub }: IJwtPayload, + @Body() setPhoneNumberDto: CreateUnverifiedUserRequestDto, + ) { + const phoneNumber = await this.authService.setPhoneNumber(sub, setPhoneNumberDto); + return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber)); + } + + @Post('register/set-phone/verify') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AccessTokenGuard) + async verifyPhoneNumber(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) { + await this.authService.verifyPhoneNumber(sub, otp); + } + @Post('biometric/enable') @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(AccessTokenGuard) diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index 16fe0e0..579a641 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -8,4 +8,5 @@ export * from './send-forget-password-otp.request.dto'; export * from './set-email.request.dto'; export * from './set-junior-password.request.dto'; export * from './set-passcode.request.dto'; +export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts index d089d56..daf9d85 100644 --- a/src/auth/dtos/request/login.request.dto.ts +++ b/src/auth/dtos/request/login.request.dto.ts @@ -10,6 +10,7 @@ export class LoginRequestDto { @ApiProperty({ example: 'test@test.com' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + @ValidateIf((o) => o.grantType !== GrantType.APPLE && o.grantType !== GrantType.GOOGLE) email!: string; @ApiProperty({ example: '123456' }) @@ -17,14 +18,20 @@ export class LoginRequestDto { @ValidateIf((o) => o.grantType === GrantType.PASSWORD) password!: string; + @ApiProperty({ example: 'Login signature' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) }) + @ValidateIf((o) => o.grantType === GrantType.BIOMETRIC) + signature!: string; + + @ApiProperty({ example: 'google_token' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) }) + @ValidateIf((o) => o.grantType === GrantType.GOOGLE) + googleToken!: string; + @ApiProperty({ example: 'fcm-device-token' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) }) @IsOptional() fcmToken?: string; - - @ApiProperty({ example: 'Login signature' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) }) - @ValidateIf((o) => o.grantType === GrantType.BIOMETRIC) - signature!: string; } diff --git a/src/auth/dtos/request/verify-otp.request.dto.ts b/src/auth/dtos/request/verify-otp.request.dto.ts new file mode 100644 index 0000000..18d09cc --- /dev/null +++ b/src/auth/dtos/request/verify-otp.request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumberString, MaxLength, MinLength } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; + +export class VerifyOtpRequestDto { + @ApiProperty({ example: '111111' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, + ) + @MaxLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + @MinLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + otp!: string; +} diff --git a/src/auth/enums/grant-type.enum.ts b/src/auth/enums/grant-type.enum.ts index 4a16d92..952f851 100644 --- a/src/auth/enums/grant-type.enum.ts +++ b/src/auth/enums/grant-type.enum.ts @@ -1,4 +1,6 @@ export enum GrantType { PASSWORD = 'PASSWORD', BIOMETRIC = 'BIOMETRIC', + GOOGLE = 'GOOGLE', + APPLE = 'APPLE', } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index d59dd19..b7f1b2c 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,6 +3,7 @@ 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'; @@ -30,6 +31,11 @@ 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, @@ -119,6 +125,47 @@ export class AuthService { 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('USERS.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('USERS.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('USERS.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); @@ -201,32 +248,38 @@ export class AuthService { } async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { - this.logger.log(`Logging in user with email ${loginDto.email}`); - const user = await this.userService.findUser({ email: loginDto.email }); - let tokens; + let user: User; + let tokens: ILoginResponse; - if (!user) { - this.logger.error(`User with email ${loginDto.email} not found`); - throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); + 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 = await this.loginWithPassword(loginDto, user); - } else { + [tokens, user] = await this.loginWithPassword(loginDto); + } + + if (loginDto.grantType === GrantType.BIOMETRIC) { this.logger.log(`Logging in user with email ${loginDto.email} using biometric`); - tokens = await this.loginWithBiometric(loginDto, user, deviceId); + [tokens, user] = await this.loginWithBiometric(loginDto, deviceId); } await this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date(), fcmToken: loginDto.fcmToken, - userId: user.id, + userId: user!.id, }); this.logger.log(`User with email ${loginDto.email} logged in successfully`); - return [tokens, user]; + return [tokens!, user!]; } async setJuniorPasscode(body: setJuniorPasswordRequestDto) { @@ -268,7 +321,9 @@ export class AuthService { return this.cacheService.set(accessToken, 'LOGOUT', expiryInTtl); } - private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise { + 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); @@ -279,10 +334,12 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`Password validated successfully for user with email ${loginDto.email}`); - return tokens; + return [tokens, user]; } - private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise { + 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); @@ -311,7 +368,36 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); - return tokens; + 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) { diff --git a/src/db/migrations/1736414850257-add-flags-to-user-entity.ts b/src/db/migrations/1736414850257-add-flags-to-user-entity.ts new file mode 100644 index 0000000..6ab2ab2 --- /dev/null +++ b/src/db/migrations/1736414850257-add-flags-to-user-entity.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFlagsToUserEntity1736414850257 implements MigrationInterface { + name = 'AddFlagsToUserEntity1736414850257'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "is_phone_verified" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "users" ADD "is_email_verified" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "phone_number" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "country_code" DROP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "country_code" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "phone_number" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "is_email_verified"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "is_phone_verified"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 7946f1c..017828a 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -17,3 +17,4 @@ export * from './1734503895302-create-money-request-entity'; export * from './1734601976591-create-allowance-entities'; export * from './1734861516657-create-gift-entities'; export * from './1734944692999-create-notification-entity-and-edit-device'; +export * from './1736414850257-add-flags-to-user-entity'; diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 72edff0..0dbc676 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -22,10 +22,10 @@ export class User extends BaseEntity { @Column('varchar', { length: 255, nullable: true, name: 'email' }) email!: string; - @Column('varchar', { length: 255, name: 'phone_number' }) + @Column('varchar', { length: 255, name: 'phone_number', nullable: true }) phoneNumber!: string; - @Column('varchar', { length: 10, name: 'country_code' }) + @Column('varchar', { length: 10, name: 'country_code', nullable: true }) countryCode!: string; @Column('varchar', { length: 255, name: 'password', nullable: true }) @@ -40,6 +40,12 @@ export class User extends BaseEntity { @Column('varchar', { length: 255, nullable: true, name: 'apple_id' }) appleId!: string; + @Column('boolean', { default: false, name: 'is_phone_verified' }) + isPhoneVerified!: boolean; + + @Column('boolean', { default: false, name: 'is_email_verified' }) + isEmailVerified!: boolean; + @Column('boolean', { default: false, name: 'is_profile_completed' }) isProfileCompleted!: boolean; diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index d52129d..0f5d439 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { Transactional } from 'typeorm-transactional'; import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity'; import { CustomerService } from '~/customer/services'; import { Guardian } from '~/guardian/entities/guradian.entity'; @@ -75,8 +76,36 @@ export class UserService { return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); } + setPhoneNumber(userId: string, phoneNumber: string, countryCode: string) { + this.logger.log(`Setting phone number ${phoneNumber} for user ${userId}`); + return this.userRepository.update(userId, { phoneNumber, countryCode }); + } + + verifyPhoneNumber(userId: string) { + this.logger.log(`Verifying phone number for user ${userId}`); + return this.userRepository.update(userId, { isPhoneVerified: true }); + } + + @Transactional() + async createGoogleUser(googleId: string, email: string) { + this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`); + const user = await this.userRepository.createUser({ googleId, email, roles: [Roles.GUARDIAN] }); + + await this.customerService.createCustomer( + { + guardian: Guardian.create({ id: user.id }), + notificationSettings: new CustomerNotificationSettings(), + }, + user, + ); + + return this.findUserOrThrow({ id: user.id }); + } + + @Transactional() async verifyUserAndCreateCustomer(user: User) { this.logger.log(`Verifying user ${user.id} and creating customer`); + await this.userRepository.update(user.id, { isPhoneVerified: true }); await this.customerService.createCustomer( { guardian: Guardian.create({ id: user.id }),