feat: handle google login

This commit is contained in:
Abdalhamid Alhamad
2025-01-09 15:14:09 +03:00
parent db02a28b4d
commit 756e947c8a
10 changed files with 210 additions and 22 deletions

View File

@ -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)

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,4 +1,6 @@
export enum GrantType {
PASSWORD = 'PASSWORD',
BIOMETRIC = 'BIOMETRIC',
GOOGLE = 'GOOGLE',
APPLE = 'APPLE',
}

View File

@ -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<ILoginResponse> {
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<ILoginResponse> {
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) {

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFlagsToUserEntity1736414850257 implements MigrationInterface {
name = 'AddFlagsToUserEntity1736414850257';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -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';

View File

@ -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;

View File

@ -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 }),