diff --git a/src/auth/constants/index.ts b/src/auth/constants/index.ts index 20d7b30..eb882c8 100644 --- a/src/auth/constants/index.ts +++ b/src/auth/constants/index.ts @@ -1 +1,3 @@ export * from './country-code-regex.constant.'; +export * from './passcode-regext.constant'; +export * from './password-regex.constant'; diff --git a/src/auth/constants/passcode-regext.constant.ts b/src/auth/constants/passcode-regext.constant.ts new file mode 100644 index 0000000..2bc5bf6 --- /dev/null +++ b/src/auth/constants/passcode-regext.constant.ts @@ -0,0 +1 @@ +export const PASSCODE_REGEX = /^\d{6}$/; diff --git a/src/auth/constants/password-regex.constant.ts b/src/auth/constants/password-regex.constant.ts new file mode 100644 index 0000000..814dfc0 --- /dev/null +++ b/src/auth/constants/password-regex.constant.ts @@ -0,0 +1 @@ +export const PASSWORD_REGEX = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&#\-_])[A-Za-z\d@$!%*?&#\-_]{8,}$/; diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index b36a907..6c72d4e 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -8,12 +8,14 @@ import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, + ForgetPasswordRequestDto, LoginRequestDto, + SendForgetPasswordOtpRequestDto, SetEmailRequestDto, SetPasscodeRequestDto, VerifyUserRequestDto, } from '../dtos/request'; -import { SendRegisterOtpResponseDto } from '../dtos/response'; +import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; import { IJwtPayload } from '../interfaces'; import { AuthService } from '../services'; @@ -63,6 +65,18 @@ export class AuthController { return this.authService.disableBiometric(sub, disableBiometricDto); } + @Post('forget-password/otp') + async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) { + const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); + return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email)); + } + + @Post('forget-password/reset') + @HttpCode(HttpStatus.NO_CONTENT) + resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) { + return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); + } + @Post('login') async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) { const [res, user] = await this.authService.login(loginDto, deviceId); diff --git a/src/auth/dtos/request/forget-password.request.dto.ts b/src/auth/dtos/request/forget-password.request.dto.ts new file mode 100644 index 0000000..97f7236 --- /dev/null +++ b/src/auth/dtos/request/forget-password.request.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsNumberString, IsString, MaxLength, MinLength } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +export class ForgetPasswordRequestDto { + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + email!: string; + + @ApiProperty({ example: 'password' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) }) + password!: string; + + @ApiProperty({ example: 'password' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) }) + 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; +} diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index 2016270..10fdc72 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,7 +1,9 @@ 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 './login.request.dto'; +export * from './send-forget-password-otp.request.dto'; export * from './set-email.request.dto'; export * from './set-passcode.request.dto'; export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/send-forget-password-otp.request.dto.ts b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts new file mode 100644 index 0000000..077b14a --- /dev/null +++ b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts @@ -0,0 +1,4 @@ +import { PickType } from '@nestjs/swagger'; +import { LoginRequestDto } from './login.request.dto'; + +export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {} diff --git a/src/auth/dtos/response/index.ts b/src/auth/dtos/response/index.ts index bd26a22..3597177 100644 --- a/src/auth/dtos/response/index.ts +++ b/src/auth/dtos/response/index.ts @@ -1,3 +1,4 @@ +export * from './send-forget-password.response.dto'; export * from './send-register-otp.response.dto'; export * from './user.response.dto'; export * from './verify-user.response.dto'; diff --git a/src/auth/dtos/response/send-forget-password.response.dto.ts b/src/auth/dtos/response/send-forget-password.response.dto.ts new file mode 100644 index 0000000..1bbf6b8 --- /dev/null +++ b/src/auth/dtos/response/send-forget-password.response.dto.ts @@ -0,0 +1,7 @@ +export class SendForgetPasswordOtpResponseDto { + email!: string; + + constructor(email: string) { + this.email = email; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 1e261e6..6b65c28 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -4,16 +4,19 @@ import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; +import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants'; import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, + ForgetPasswordRequestDto, LoginRequestDto, + SendForgetPasswordOtpRequestDto, SetEmailRequestDto, } from '../dtos/request'; import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto'; import { User } from '../entities'; -import { GrantType } from '../enums'; +import { GrantType, Roles } from '../enums'; import { ILoginResponse } from '../interfaces'; import { removePadding, verifySignature } from '../utils'; import { DeviceService } from './device.service'; @@ -35,7 +38,7 @@ export class AuthService { return this.otpService.generateAndSendOtp({ userId: user.id, - phoneNumber: user.phoneNumber, + recipient: user.phoneNumber, scope: OtpScope.VERIFY_PHONE, otpType: OtpType.SMS, }); @@ -126,6 +129,44 @@ export class AuthService { return this.deviceService.updateDevice(deviceId, { publicKey: null }); } + async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) { + const user = await this.userService.findUserOrThrow({ email }); + + if (!user.isProfileCompleted) { + throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED'); + } + + return this.otpService.generateAndSendOtp({ + userId: user.id, + recipient: user.email, + scope: OtpScope.FORGET_PASSWORD, + otpType: OtpType.EMAIL, + }); + } + + async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) { + const user = await this.userService.findUserOrThrow({ email }); + if (!user.isProfileCompleted) { + throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED'); + } + const isOtpValid = await this.otpService.verifyOtp({ + userId: user.id, + scope: OtpScope.FORGET_PASSWORD, + otpType: OtpType.EMAIL, + value: otp, + }); + + if (!isOtpValid) { + throw new BadRequestException('USERS.INVALID_OTP'); + } + + this.validatePassword(password, confirmPassword, user); + + const hashedPassword = bcrypt.hashSync(password, user.salt); + + await this.userService.setPasscode(user.id, hashedPassword, user.salt); + } + async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { const user = await this.userService.findUser({ email: loginDto.email }); let tokens; @@ -205,4 +246,20 @@ export class AuthService { return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) }; } + + private validatePassword(password: string, confirmPassword: string, user: User) { + if (password !== confirmPassword) { + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } + + const roles = user.roles; + + if (roles.includes(Roles.GUARDIAN) && !PASSCODE_REGEX.test(password)) { + throw new BadRequestException('AUTH.INVALID_PASSCODE'); + } + + if (roles.includes(Roles.JUNIOR) && !PASSWORD_REGEX.test(password)) { + throw new BadRequestException('AUTH.INVALID_PASSWORD'); + } + } } diff --git a/src/common/modules/otp/dtos/request/generate-otp-request.request.dto.ts b/src/common/modules/otp/dtos/request/generate-otp-request.request.dto.ts deleted file mode 100644 index 8c61ca1..0000000 --- a/src/common/modules/otp/dtos/request/generate-otp-request.request.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IsNotEmpty } from 'class-validator'; -import { UserLocale } from '~/core/enums'; -import { OtpScope, OtpType } from '../../enums'; - -export class GenerateOtpRequestDto { - @IsNotEmpty() - userId!: string; - - @IsNotEmpty() - phoneNumber!: string; - - @IsNotEmpty() - scope!: OtpScope; - - @IsNotEmpty() - language?: UserLocale = UserLocale.ENGLISH; - - @IsNotEmpty() - otpType!: OtpType; -} diff --git a/src/common/modules/otp/dtos/request/index.ts b/src/common/modules/otp/dtos/request/index.ts index 40e2e02..e69de29 100644 --- a/src/common/modules/otp/dtos/request/index.ts +++ b/src/common/modules/otp/dtos/request/index.ts @@ -1,2 +0,0 @@ -export * from './generate-otp-request.request.dto'; -export * from './verify-otp-request.dto'; diff --git a/src/common/modules/otp/dtos/request/verify-otp-request.dto.ts b/src/common/modules/otp/dtos/request/verify-otp-request.dto.ts deleted file mode 100644 index ac295cf..0000000 --- a/src/common/modules/otp/dtos/request/verify-otp-request.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IsNotEmpty } from 'class-validator'; -import { OtpScope, OtpType } from '../../enums'; - -export class VerifyOtpRequestDto { - @IsNotEmpty() - userId!: string; - - @IsNotEmpty() - scope!: OtpScope; - - @IsNotEmpty() - otpType!: OtpType; - - @IsNotEmpty() - value!: string; -} diff --git a/src/common/modules/otp/enums/otp-scope.enum.ts b/src/common/modules/otp/enums/otp-scope.enum.ts index 9cb827b..01d5468 100644 --- a/src/common/modules/otp/enums/otp-scope.enum.ts +++ b/src/common/modules/otp/enums/otp-scope.enum.ts @@ -1,3 +1,4 @@ export enum OtpScope { VERIFY_PHONE = 'VERIFY_PHONE', + FORGET_PASSWORD = 'FORGET_PASSWORD', } diff --git a/src/common/modules/otp/interfaces/index.ts b/src/common/modules/otp/interfaces/index.ts new file mode 100644 index 0000000..90dae4f --- /dev/null +++ b/src/common/modules/otp/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './send-otp.interface'; +export * from './verify-otp.interface'; diff --git a/src/common/modules/otp/interfaces/send-otp.interface.ts b/src/common/modules/otp/interfaces/send-otp.interface.ts new file mode 100644 index 0000000..a3b9b3d --- /dev/null +++ b/src/common/modules/otp/interfaces/send-otp.interface.ts @@ -0,0 +1,9 @@ +import { OtpScope, OtpType } from '../enums'; + +export interface ISendOtp { + userId: string; + scope: OtpScope; + language?: string; + otpType: OtpType; + recipient: string; +} diff --git a/src/common/modules/otp/interfaces/verify-otp.interface.ts b/src/common/modules/otp/interfaces/verify-otp.interface.ts new file mode 100644 index 0000000..28ee488 --- /dev/null +++ b/src/common/modules/otp/interfaces/verify-otp.interface.ts @@ -0,0 +1,8 @@ +import { OtpScope, OtpType } from '../enums'; + +export interface IVerifyOtp { + userId: string; + scope: OtpScope; + otpType: OtpType; + value: string; +} diff --git a/src/common/modules/otp/repositories/otp.repository.ts b/src/common/modules/otp/repositories/otp.repository.ts index 57dd7b8..be1f4ce 100644 --- a/src/common/modules/otp/repositories/otp.repository.ts +++ b/src/common/modules/otp/repositories/otp.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { MoreThan, Repository } from 'typeorm'; -import { VerifyOtpRequestDto } from '../dtos/request'; import { Otp } from '../entities'; +import { IVerifyOtp } from '../interfaces'; const FIVE = 5; const SIXTY = 60; const ONE_THOUSAND = 1000; @@ -23,7 +23,7 @@ export class OtpRepository { ); } - findOtp(otp: VerifyOtpRequestDto) { + findOtp(otp: IVerifyOtp) { return this.otpRepository.findOne({ where: { userId: otp.userId, diff --git a/src/common/modules/otp/services/otp.service.ts b/src/common/modules/otp/services/otp.service.ts index 5bd42fb..b5c45b4 100644 --- a/src/common/modules/otp/services/otp.service.ts +++ b/src/common/modules/otp/services/otp.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DEFAULT_OTP_DIGIT, DEFAULT_OTP_LENGTH } from '../constants'; -import { GenerateOtpRequestDto, VerifyOtpRequestDto } from '../dtos/request'; +import { OtpType } from '../enums'; +import { ISendOtp, IVerifyOtp } from '../interfaces'; import { OtpRepository } from '../repositories'; import { generateRandomOtp } from '../utils'; @@ -9,23 +10,25 @@ import { generateRandomOtp } from '../utils'; export class OtpService { constructor(private readonly configService: ConfigService, private readonly otpRepository: OtpRepository) {} private useMock = this.configService.get('USE_MOCK', false); - async generateAndSendOtp(sendotpRequest: GenerateOtpRequestDto) { + async generateAndSendOtp(sendotpRequest: ISendOtp): Promise { const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH); await this.otpRepository.createOtp({ ...sendotpRequest, value: otp }); this.sendOtp(sendotpRequest, otp); - return sendotpRequest.phoneNumber.replace(/.(?=.{4})/g, '*'); + return sendotpRequest.otpType == OtpType.EMAIL + ? sendotpRequest.recipient + : sendotpRequest.recipient?.replace(/.(?=.{4})/g, '*'); } - async verifyOtp(verifyOtpRequest: VerifyOtpRequestDto) { + async verifyOtp(verifyOtpRequest: IVerifyOtp) { const otp = await this.otpRepository.findOtp(verifyOtpRequest); return !!otp; } - private sendOtp(sendotpRequest: GenerateOtpRequestDto, otp: string) { + private sendOtp(sendotpRequest: ISendOtp, otp: string) { // TODO: send OTP to the user return; }