From 7461af20dd70b5428cb544760155499532fba824 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 14:48:14 +0300 Subject: [PATCH] feat: edit forget password flow --- src/auth/controllers/auth.controller.ts | 12 +++++- .../request/forget-password.request.dto.ts | 20 +++------- src/auth/dtos/request/index.ts | 1 + .../verify-forget-password-otp.request.dto.ts | 23 +++++++++++ ...verify-forget-password-otp.response.dto.ts | 19 ++++++++++ src/auth/services/auth.service.ts | 38 ++++++++++++++----- src/i18n/ar/app.json | 4 +- src/i18n/en/app.json | 4 +- src/user/enums/user-type.enum.ts | 1 + .../repositories/user-token-repository.ts | 2 +- src/user/services/user-token.service.ts | 4 +- 11 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 src/auth/dtos/request/verify-forget-password-otp.request.dto.ts create mode 100644 src/auth/dtos/response/verify-forget-password-otp.response.dto.ts diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index f651e69..e3bafcf 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -11,10 +11,12 @@ import { LoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; +import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto'; import { AuthService } from '../services'; @Controller('auth') @@ -47,10 +49,18 @@ export class AuthController { 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') @HttpCode(HttpStatus.NO_CONTENT) resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) { - return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); + return this.authService.resetPassword(forgetPasswordDto); } @Post('refresh-token') diff --git a/src/auth/dtos/request/forget-password.request.dto.ts b/src/auth/dtos/request/forget-password.request.dto.ts index 4153a96..4680c71 100644 --- a/src/auth/dtos/request/forget-password.request.dto.ts +++ b/src/auth/dtos/request/forget-password.request.dto.ts @@ -1,8 +1,7 @@ -import { ApiProperty, PickType } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsNumberString, IsString, Matches, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class ForgetPasswordRequestDto { @ApiProperty({ example: '+962' }) @@ -29,16 +28,7 @@ export class ForgetPasswordRequestDto { }) confirmPassword!: string; - @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; + @ApiProperty({ example: 'reset-token-32423123' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) }) + resetPasswordToken!: string; } diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index a329e46..09274d4 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -10,5 +10,6 @@ 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-forget-password-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts new file mode 100644 index 0000000..03f2100 --- /dev/null +++ b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts @@ -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; +} diff --git a/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts new file mode 100644 index 0000000..8fc6010 --- /dev/null +++ b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts @@ -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; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index ca5e4dd..e096196 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,13 +3,13 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; +import moment from 'moment'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; import { UserType } from '~/user/enums'; import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; -import { PASSCODE_REGEX } from '../constants'; import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, @@ -18,10 +18,11 @@ import { LoginRequestDto, SendForgetPasswordOtpRequestDto, setJuniorPasswordRequestDto, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; +import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; -import { removePadding, verifySignature } from '../utils'; import { Oauth2Service } from './oauth2.service'; const ONE_THOUSAND = 1000; @@ -147,14 +148,7 @@ export class AuthService { }); } - async verifyForgetPasswordOtp({ - countryCode, - phoneNumber, - otp, - password, - confirmPassword, - }: ForgetPasswordRequestDto) { - this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) { const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); const isOtpValid = await this.otpService.verifyOtp({ @@ -169,6 +163,29 @@ export class AuthService { 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) { this.logger.error('Password and confirm password do not match'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); @@ -177,6 +194,7 @@ export class AuthService { const hashedPassword = bcrypt.hashSync(password, 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}`); } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 455ddaf..797784d 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -11,7 +11,9 @@ "PASSWORD_MISMATCH": "كلمات المرور التي أدخلتها غير متطابقة. يرجى إدخال كلمات المرور مرة أخرى.", "INVALID_PASSCODE": "رمز المرور الذي أدخلته غير صحيح. يرجى المحاولة مرة أخرى.", "PASSCODE_ALREADY_SET": "تم تعيين رمز المرور بالفعل.", - "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى." + "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى.", + "TOKEN_INVALID": "رمز المستخدم غير صالح.", + "TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية." }, "USER": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 105afc9..668b1e8 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -11,7 +11,9 @@ "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.", "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": { diff --git a/src/user/enums/user-type.enum.ts b/src/user/enums/user-type.enum.ts index e00c99c..b3a705f 100644 --- a/src/user/enums/user-type.enum.ts +++ b/src/user/enums/user-type.enum.ts @@ -1,4 +1,5 @@ export enum UserType { CHECKER = 'CHECKER', JUNIOR = 'JUNIOR', + GUARDIAN = 'GUARDIAN', } diff --git a/src/user/repositories/user-token-repository.ts b/src/user/repositories/user-token-repository.ts index 3e130db..9eda1bf 100644 --- a/src/user/repositories/user-token-repository.ts +++ b/src/user/repositories/user-token-repository.ts @@ -17,7 +17,7 @@ export class UserTokenRepository { generateToken(userId: string, userType: UserType, expiryDate?: Date) { return this.userTokenRepository.save( this.userTokenRepository.create({ - userId: userType === UserType.CHECKER ? userId : null, + userId, juniorId: userType === UserType.JUNIOR ? userId : null, expiryDate: expiryDate ?? moment().add('15', 'minutes').toDate(), userType, diff --git a/src/user/services/user-token.service.ts b/src/user/services/user-token.service.ts index 3a3ee1b..efcbe00 100644 --- a/src/user/services/user-token.service.ts +++ b/src/user/services/user-token.service.ts @@ -20,12 +20,12 @@ export class UserTokenService { if (!tokenEntity) { this.logger.error(`Token ${token} not found`); - throw new BadRequestException('TOKEN.INVALID'); + throw new BadRequestException('AUTH.TOKEN_INVALID'); } if (tokenEntity.expiryDate < new Date()) { this.logger.error(`Token ${token} expired`); - throw new BadRequestException('TOKEN.EXPIRED'); + throw new BadRequestException('AUTH.TOKEN_EXPIRED'); } this.logger.log(`Token validated successfully`);