feat: handle oauth2 login

This commit is contained in:
Abdalhamid Alhamad
2025-06-04 14:52:09 +03:00
parent b44bc5d5cc
commit 6c859a25d2
9 changed files with 147 additions and 52 deletions

View File

@ -6,11 +6,12 @@ import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
LoginRequestDto,
GoogleLoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
@ -57,6 +58,22 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/google')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithGoogle(@Body() data: GoogleLoginRequestDto) {
const [token, user] = await this.authService.loginWithGoogle(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/apple')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithApple(@Body() data: AppleLoginRequestDto) {
const [token, user] = await this.authService.loginWithApple(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('register/set-email')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
@ -128,12 +145,6 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login')
async login(@Body() loginDto: LoginRequestDto) {
const [res, user] = await this.authService.login(loginDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class AppleAdditionalData {
@ApiProperty({ example: 'Ahmad' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
firstName!: string;
@ApiProperty({ example: 'Khan' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AppleAdditionalData } from './apple-additional-data.request.dto';
export class AppleLoginRequestDto {
@ApiProperty({ example: 'apple_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) })
appleToken!: string;
@ApiProperty({ type: AppleAdditionalData })
@ValidateNested({
each: true,
message: i18n('validation.ValidateNested', { path: 'general', property: 'auth.apple.additionalData' }),
})
@IsOptional()
@Type(() => AppleAdditionalData)
additionalData?: AppleAdditionalData;
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class GoogleLoginRequestDto {
@ApiProperty({ example: 'google_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) })
googleToken!: string;
}

View File

@ -1,7 +1,9 @@
export * from './apple-login.request.dto';
export * from './create-unverified-user.request.dto';
export * from './disable-biometric.request.dto';
export * from './enable-biometric.request.dto';
export * from './forget-password.request.dto';
export * from './google-login.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';

View File

@ -12,10 +12,12 @@ import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
GoogleLoginRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
@ -24,7 +26,7 @@ import {
VerifyLoginOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { GrantType, Roles } from '../enums';
import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
import { Oauth2Service } from './oauth2.service';
@ -243,29 +245,6 @@ export class AuthService {
this.logger.log(`Passcode updated successfully for user with email ${email}`);
}
async login(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
let user: User;
let tokens: ILoginResponse;
if (loginDto.grantType === GrantType.PASSWORD || loginDto.grantType === GrantType.BIOMETRIC) {
throw new BadRequestException('AUTH.GRANT_TYPE_NOT_SUPPORTED_YET');
}
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`);
[tokens, user] = await this.loginWithApple(loginDto);
}
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.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
@ -403,65 +382,96 @@ export class AuthService {
return [tokens, user];
}
private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
const [existingUser, isJunior] = await Promise.all([
async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> {
const {
email,
sub,
given_name: firstName,
family_name: lastName,
} = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
const [existingUser, isJunior, existingUserWithEmail] = await Promise.all([
this.userService.findUser({ googleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
this.userService.findUser({ email }),
]);
if (isJunior && email) {
if (isJunior) {
this.logger.error(`User with email ${email} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
if (!existingUser) {
this.logger.debug(`User with google id ${sub} not found, creating new user`);
const user = await this.userService.createGoogleUser(sub, email);
if (!existingUser && existingUserWithEmail) {
this.logger.error(`User with email ${email} already exists adding google id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { googleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
if (!existingUser && !existingUserWithEmail) {
this.logger.debug(`User with google id ${sub} or email ${email} not found, creating new user`);
const user = await this.userService.createGoogleUser(sub, email, firstName, lastName);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
const tokens = await this.generateAuthToken(existingUser);
const tokens = await this.generateAuthToken(existingUser!);
return [tokens, existingUser];
return [tokens, existingUser!];
}
private async loginWithApple(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> {
const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken);
const [existingUser, isJunior] = await Promise.all([
const [existingUserWithSub, isJunior] = await Promise.all([
this.userService.findUser({ appleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
]);
if (isJunior && email) {
this.logger.error(`User with email ${email} is an already registered junior`);
if (isJunior) {
this.logger.error(`User with apple id ${sub} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
if (!existingUser) {
if (email) {
const existingUserWithEmail = await this.userService.findUser({ email });
if (existingUserWithEmail && !existingUserWithSub) {
{
this.logger.error(`User with email ${email} already exists adding apple id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { appleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
}
}
if (!existingUserWithSub) {
// Apple only provides email if user authorized zod for the first time
if (!email) {
if (!email || !loginDto.additionalData) {
this.logger.error(`User authorized zod before but his email is not stored in the database`);
throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED');
}
this.logger.debug(`User with apple id ${sub} not found, creating new user`);
const user = await this.userService.createAppleUser(sub, email);
const user = await this.userService.createAppleUser(
sub,
email,
loginDto.additionalData.firstName,
loginDto.additionalData.lastName,
);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
const tokens = await this.generateAuthToken(existingUser);
const tokens = await this.generateAuthToken(existingUserWithSub);
this.logger.log(`User with apple id ${sub} logged in successfully`);
return [tokens, existingUser];
return [tokens, existingUserWithSub];
}
private async generateAuthToken(user: User) {
@ -499,4 +509,6 @@ export class AuthService {
throw new BadRequestException('AUTH.INVALID_PASSCODE');
}
}
private validateGoogleToken(googleToken: string) {}
}