feat: edit forget password flow

This commit is contained in:
Abdalhamid Alhamad
2025-08-03 14:48:14 +03:00
parent f65a7d2933
commit 7461af20dd
11 changed files with 97 additions and 31 deletions

View File

@ -11,10 +11,12 @@ import {
LoginRequestDto, LoginRequestDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto, VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
import { LoginResponseDto } from '../dtos/response/login.response.dto'; import { LoginResponseDto } from '../dtos/response/login.response.dto';
import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto';
import { AuthService } from '../services'; import { AuthService } from '../services';
@Controller('auth') @Controller('auth')
@ -47,10 +49,18 @@ export class AuthController {
return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber)); return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber));
} }
@Post('forget-password/verify')
@HttpCode(HttpStatus.OK)
async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) {
const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
return ResponseFactory.data(new VerifyForgetPasswordOtpResponseDto(token, user));
}
@Post('forget-password/reset') @Post('forget-password/reset')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) { resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) {
return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); return this.authService.resetPassword(forgetPasswordDto);
} }
@Post('refresh-token') @Post('refresh-token')

View File

@ -1,8 +1,7 @@
import { ApiProperty, PickType } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsNumberString, IsString, Matches, MaxLength, MinLength } from 'class-validator'; import { IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { IsValidPhoneNumber } from '~/core/decorators/validations'; import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class ForgetPasswordRequestDto { export class ForgetPasswordRequestDto {
@ApiProperty({ example: '+962' }) @ApiProperty({ example: '+962' })
@ -29,16 +28,7 @@ export class ForgetPasswordRequestDto {
}) })
confirmPassword!: string; confirmPassword!: string;
@ApiProperty({ example: '111111' }) @ApiProperty({ example: 'reset-token-32423123' })
@IsNumberString( @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) })
{ no_symbols: true }, resetPasswordToken!: string;
{ 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

@ -10,5 +10,6 @@ export * from './send-forget-password-otp.request.dto';
export * from './set-email.request.dto'; export * from './set-email.request.dto';
export * from './set-junior-password.request.dto'; export * from './set-junior-password.request.dto';
export * from './set-passcode.request.dto'; export * from './set-passcode.request.dto';
export * from './verify-forget-password-otp.request.dto';
export * from './verify-otp.request.dto'; export * from './verify-otp.request.dto';
export * from './verify-user.request.dto'; export * from './verify-user.request.dto';

View File

@ -0,0 +1,23 @@
import { ApiProperty, PickType } 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';
import { ForgetPasswordRequestDto } from './forget-password.request.dto';
export class VerifyForgetPasswordOtpRequestDto extends PickType(ForgetPasswordRequestDto, [
'countryCode',
'phoneNumber',
]) {
@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

@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '~/user/entities';
export class VerifyForgetPasswordOtpResponseDto {
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
countryCode!: string;
@ApiProperty()
resetPasswordToken!: string;
constructor(token: string, user: User) {
this.phoneNumber = user.phoneNumber;
this.countryCode = user.countryCode;
this.resetPasswordToken = token;
}
}

View File

@ -3,13 +3,13 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { Request } from 'express'; import { Request } from 'express';
import moment from 'moment';
import { CacheService } from '~/common/modules/cache/services'; import { CacheService } from '~/common/modules/cache/services';
import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services'; import { OtpService } from '~/common/modules/otp/services';
import { UserType } from '~/user/enums'; import { UserType } from '~/user/enums';
import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities'; import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import { import {
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto, DisableBiometricRequestDto,
@ -18,10 +18,11 @@ import {
LoginRequestDto, LoginRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
setJuniorPasswordRequestDto, setJuniorPasswordRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto, VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces'; import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
import { Oauth2Service } from './oauth2.service'; import { Oauth2Service } from './oauth2.service';
const ONE_THOUSAND = 1000; const ONE_THOUSAND = 1000;
@ -147,14 +148,7 @@ export class AuthService {
}); });
} }
async verifyForgetPasswordOtp({ async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) {
countryCode,
phoneNumber,
otp,
password,
confirmPassword,
}: ForgetPasswordRequestDto) {
this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`);
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
const isOtpValid = await this.otpService.verifyOtp({ const isOtpValid = await this.otpService.verifyOtp({
@ -169,6 +163,29 @@ export class AuthService {
throw new BadRequestException('OTP.INVALID_OTP'); 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) { if (password !== confirmPassword) {
this.logger.error('Password and confirm password do not match'); this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
@ -177,6 +194,7 @@ export class AuthService {
const hashedPassword = bcrypt.hashSync(password, user.salt); const hashedPassword = bcrypt.hashSync(password, user.salt);
await this.userService.setPassword(user.id, hashedPassword, 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}`); this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`);
} }

View File

@ -11,7 +11,9 @@
"PASSWORD_MISMATCH": "كلمات المرور التي أدخلتها غير متطابقة. يرجى إدخال كلمات المرور مرة أخرى.", "PASSWORD_MISMATCH": "كلمات المرور التي أدخلتها غير متطابقة. يرجى إدخال كلمات المرور مرة أخرى.",
"INVALID_PASSCODE": "رمز المرور الذي أدخلته غير صحيح. يرجى المحاولة مرة أخرى.", "INVALID_PASSCODE": "رمز المرور الذي أدخلته غير صحيح. يرجى المحاولة مرة أخرى.",
"PASSCODE_ALREADY_SET": "تم تعيين رمز المرور بالفعل.", "PASSCODE_ALREADY_SET": "تم تعيين رمز المرور بالفعل.",
"APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى." "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى.",
"TOKEN_INVALID": "رمز المستخدم غير صالح.",
"TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية."
}, },
"USER": { "USER": {

View File

@ -11,7 +11,9 @@
"PASSWORD_MISMATCH": "The passwords you entered do not match. Please re-enter the passwords.", "PASSWORD_MISMATCH": "The passwords you entered do not match. Please re-enter the passwords.",
"INVALID_PASSCODE": "The passcode you entered is incorrect. Please try again.", "INVALID_PASSCODE": "The passcode you entered is incorrect. Please try again.",
"PASSCODE_ALREADY_SET": "The pass code has already been set.", "PASSCODE_ALREADY_SET": "The pass code has already been set.",
"APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again." "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again.",
"TOKEN_INVALID": "The user token is invalid.",
"TOKEN_EXPIRED": "The user token has expired."
}, },
"USER": { "USER": {

View File

@ -1,4 +1,5 @@
export enum UserType { export enum UserType {
CHECKER = 'CHECKER', CHECKER = 'CHECKER',
JUNIOR = 'JUNIOR', JUNIOR = 'JUNIOR',
GUARDIAN = 'GUARDIAN',
} }

View File

@ -17,7 +17,7 @@ export class UserTokenRepository {
generateToken(userId: string, userType: UserType, expiryDate?: Date) { generateToken(userId: string, userType: UserType, expiryDate?: Date) {
return this.userTokenRepository.save( return this.userTokenRepository.save(
this.userTokenRepository.create({ this.userTokenRepository.create({
userId: userType === UserType.CHECKER ? userId : null, userId,
juniorId: userType === UserType.JUNIOR ? userId : null, juniorId: userType === UserType.JUNIOR ? userId : null,
expiryDate: expiryDate ?? moment().add('15', 'minutes').toDate(), expiryDate: expiryDate ?? moment().add('15', 'minutes').toDate(),
userType, userType,

View File

@ -20,12 +20,12 @@ export class UserTokenService {
if (!tokenEntity) { if (!tokenEntity) {
this.logger.error(`Token ${token} not found`); this.logger.error(`Token ${token} not found`);
throw new BadRequestException('TOKEN.INVALID'); throw new BadRequestException('AUTH.TOKEN_INVALID');
} }
if (tokenEntity.expiryDate < new Date()) { if (tokenEntity.expiryDate < new Date()) {
this.logger.error(`Token ${token} expired`); this.logger.error(`Token ${token} expired`);
throw new BadRequestException('TOKEN.EXPIRED'); throw new BadRequestException('AUTH.TOKEN_EXPIRED');
} }
this.logger.log(`Token validated successfully`); this.logger.log(`Token validated successfully`);