diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 68bbb60..120f54c 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -9,6 +9,9 @@ import { ConfigModule } from '@nestjs/config' import { JwtRefreshStrategy } from '@auth/strategies/jwt-refresh.strategy' import { StaffModule } from '@staff/staff.module' import { AuthProviderController } from '@auth/controllers/provider.controller' +import { OtpRepository } from './repositories/otp.repository' +import { MongooseModule } from '@nestjs/mongoose' +import { Otp, OtpSchema } from './schema/otp.schema' @Global() @Module({ @@ -17,10 +20,11 @@ import { AuthProviderController } from '@auth/controllers/provider.controller' CustomerModule, StaffModule, PassportModule, - JwtModule + JwtModule, + MongooseModule.forFeature([{ name: Otp.name, schema: OtpSchema }]) ], controllers: [AuthCustomerController, AuthProviderController], - providers: [AuthService, JwtAccessStrategy, JwtRefreshStrategy], + providers: [AuthService, JwtAccessStrategy, JwtRefreshStrategy, OtpRepository], exports: [AuthService] }) export class AuthModule {} diff --git a/src/auth/controllers/customer.controller.ts b/src/auth/controllers/customer.controller.ts index 1fcde49..185a31f 100644 --- a/src/auth/controllers/customer.controller.ts +++ b/src/auth/controllers/customer.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common' import { ApiBadRequestResponse, ApiBearerAuth, ApiBody, ApiOkResponse, ApiTags } from '@nestjs/swagger' -import { ErrorResponse, SuccessDataResponse } from '@common/contracts/dto' -import { GoogleLoginReqDto, LoginReqDto } from '@auth/dto/login.dto' +import { ErrorResponse, SuccessDataResponse, SuccessResponse } from '@common/contracts/dto' +import { GoogleLoginReqDto, LoginReqDto, VerifyOtpReqDto } from '@auth/dto/login.dto' import { AuthService } from '@auth/services/auth.service' import { TokenResDto } from '@auth/dto/token.dto' import { UserSide } from '@common/contracts/constant' @@ -29,6 +29,22 @@ export class AuthCustomerController { return this.authService.googleLogin(googleLoginReqDto) } + @Post('login-otp') + @ApiBody({ type: LoginReqDto }) + @ApiOkResponse({ type: DataResponse(SuccessResponse) }) + @ApiBadRequestResponse({ type: ErrorResponse }) + loginOtp(@Body() loginReqDto: LoginReqDto): Promise { + return this.authService.loginOtp(loginReqDto, UserSide.CUSTOMER) + } + + @Post('verify-otp') + @ApiBody({ type: VerifyOtpReqDto }) + @ApiOkResponse({ type: DataResponse(TokenResDto) }) + @ApiBadRequestResponse({ type: ErrorResponse }) + verifyOtp(@Body() verifyOtpReqDto: VerifyOtpReqDto): Promise { + return this.authService.verifyOtp(verifyOtpReqDto, UserSide.CUSTOMER) + } + @Post('register') @ApiBody({ type: RegisterReqDto }) @ApiOkResponse({ type: SuccessDataResponse }) diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index d96c538..978e33d 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -11,6 +11,16 @@ export class LoginReqDto { password: string; } +export class VerifyOtpReqDto { + @ApiProperty() + @IsNotEmpty() + email: string; + + @ApiProperty() + @IsNotEmpty() + otp: string; +} + export class GoogleLoginReqDto { @ApiProperty() @IsNotEmpty() diff --git a/src/auth/repositories/otp.repository.ts b/src/auth/repositories/otp.repository.ts new file mode 100644 index 0000000..27b2bd2 --- /dev/null +++ b/src/auth/repositories/otp.repository.ts @@ -0,0 +1,13 @@ +import { PaginateModel } from 'mongoose' +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' + +import { AbstractRepository } from '@common/repositories' +import { Otp, OtpDocument } from '@auth/schema/otp.schema' + +@Injectable() +export class OtpRepository extends AbstractRepository { + constructor(@InjectModel(Otp.name) model: PaginateModel) { + super(model) + } +} diff --git a/src/auth/schema/otp.schema.ts b/src/auth/schema/otp.schema.ts new file mode 100644 index 0000000..d066e24 --- /dev/null +++ b/src/auth/schema/otp.schema.ts @@ -0,0 +1,39 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument } from 'mongoose' +import * as paginate from 'mongoose-paginate-v2' +import { Transform } from 'class-transformer' + +export type OtpDocument = HydratedDocument + +@Schema({ + collection: 'otps', + timestamps: { + createdAt: true, + updatedAt: true + }, + toJSON: { + transform(doc, ret) { + delete ret.__v + } + } +}) +export class Otp { + constructor(id?: string) { + this._id = id + } + @Transform(({ value }) => value?.toString()) + _id: string + + @Prop({ type: String, required: true }) + otp: string + + @Prop({ type: String, required: true, index: true }) + customerId: string + + @Prop({ type: Date, required: true }) + expiredAt: Date +} + +export const OtpSchema = SchemaFactory.createForClass(Otp) + +OtpSchema.plugin(paginate) diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 62d730c..b224d4c 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,6 +1,7 @@ +import { OtpRepository } from './../repositories/otp.repository' import { JwtService } from '@nestjs/jwt' import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common' -import { GoogleLoginReqDto, LoginReqDto } from '@auth/dto/login.dto' +import { GoogleLoginReqDto, LoginReqDto, VerifyOtpReqDto } from '@auth/dto/login.dto' import { CustomerRepository } from '@customer/repositories/customer.repository' import { Errors } from '@common/contracts/error' import { Customer } from '@customer/schemas/customer.schema' @@ -15,14 +16,17 @@ import { StaffRepository } from '@staff/repositories/staff.repository' import { Staff } from '@staff/schemas/staff.schema' import { SuccessResponse } from '@common/contracts/dto' import { OAuth2Client } from 'google-auth-library' +import { MailerService } from '@nestjs-modules/mailer' @Injectable() export class AuthService { constructor( private readonly customerRepository: CustomerRepository, private readonly staffRepository: StaffRepository, + private readonly otpRepository: OtpRepository, private readonly jwtService: JwtService, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly mailerService: MailerService ) {} public async login(loginReqDto: LoginReqDto, side: UserSide): Promise { @@ -71,6 +75,108 @@ export class AuthService { } } + public async loginOtp(loginReqDto: LoginReqDto, side: UserSide): Promise { + let user: Customer | Staff + let userRole: UserRole + let providerId: string + + if (side === UserSide.CUSTOMER) { + user = await this.customerRepository.findOne({ + conditions: { + email: loginReqDto.email + } + }) + + userRole = UserRole.CUSTOMER + } + + if (side === UserSide.PROVIDER) { + user = await this.staffRepository.findOne({ + conditions: { + email: loginReqDto.email + } + }) + + userRole = user?.role + providerId = user?.providerId.toString() + } + + if (!user) throw new BadRequestException(Errors.WRONG_EMAIL_OR_PASSWORD.message) + + if (user.status === Status.INACTIVE) throw new BadRequestException(Errors.INACTIVE_ACCOUNT.message) + + const isPasswordMatch = await this.comparePassword(loginReqDto.password, user.password) + + if (!isPasswordMatch) throw new BadRequestException(Errors.WRONG_EMAIL_OR_PASSWORD.message) + + const otp = Math.floor(1000 + Math.random() * 9000).toString() + this.otpRepository.create({ otp, customerId: user._id, expiredAt: new Date(Date.now() + 5 * 60000) }) + + // Send email contain OTP to customer + await this.mailerService.sendMail({ + to: loginReqDto.email, + subject: `[Furnique] Mã OTP đăng nhập`, + template: 'login-otp', + context: { + otp + } + }) + + return new SuccessResponse(true) + } + + public async verifyOtp(verifyOtpReqDto: VerifyOtpReqDto, side: UserSide): Promise { + let user: Customer | Staff + let userRole: UserRole + let providerId: string + + if (side === UserSide.CUSTOMER) { + user = await this.customerRepository.findOne({ + conditions: { + email: verifyOtpReqDto.email + } + }) + + userRole = UserRole.CUSTOMER + } + + if (side === UserSide.PROVIDER) { + user = await this.staffRepository.findOne({ + conditions: { + email: verifyOtpReqDto.email + } + }) + + userRole = user?.role + providerId = user?.providerId.toString() + } + + if (!user) throw new BadRequestException(Errors.WRONG_EMAIL_OR_PASSWORD.message) + + if (user.status === Status.INACTIVE) throw new BadRequestException(Errors.INACTIVE_ACCOUNT.message) + + const otp = await this.otpRepository.findOne({ + conditions: { + otp: verifyOtpReqDto.otp, + customerId: user._id, + expiredAt: { $gt: new Date() } + } + }) + + if (!otp) throw new BadRequestException(Errors.WRONG_OTP.message) + + const accessTokenPayload: AccessTokenPayload = { name: user.firstName, sub: user._id, role: userRole, providerId } + + const refreshTokenPayload: RefreshTokenPayload = { sub: user._id, role: userRole } + + const tokens = this.generateTokens(accessTokenPayload, refreshTokenPayload) + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + } + } + public async googleLogin(googleLoginReqDto: GoogleLoginReqDto): Promise { const client = new OAuth2Client({ clientId: this.configService.get('GOOGLE_CLIENT_ID'), diff --git a/src/common/contracts/error.ts b/src/common/contracts/error.ts index a9f35c4..68634e5 100644 --- a/src/common/contracts/error.ts +++ b/src/common/contracts/error.ts @@ -21,6 +21,11 @@ export const Errors: Record = { message: 'Tài khoản của bạn đã bị vô hiệu hóa. Vui lòng liên lạc với admin.', httpStatus: HttpStatus.BAD_REQUEST }, + WRONG_OTP: { + error: 'WRONG_OTP', + message: 'Mã OTP không đúng', + httpStatus: HttpStatus.BAD_REQUEST + }, EMAIL_ALREADY_EXIST: { error: 'EMAIL_ALREADY_EXIST', message: 'Email đã được sử dụng', diff --git a/src/templates/login-otp.ejs b/src/templates/login-otp.ejs new file mode 100644 index 0000000..ae1a508 --- /dev/null +++ b/src/templates/login-otp.ejs @@ -0,0 +1,8 @@ +
+    

Email OTP

+

Xin chào,

+

Mã OTP của bạn là: <%= otp %>

+

Vui lòng sử dụng mã OTP này để xác minh tài khoản của bạn.

+

Cảm ơn bạn!

+
+<%- include('web-footer') -%>