Skip to content

Commit 53ba6a6

Browse files
committed
feat(auth): ✨ add forgot and reset password
1 parent a058c53 commit 53ba6a6

7 files changed

Lines changed: 229 additions & 75 deletions

File tree

src/modules/auth/auth.controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
RefreshTokenResponseDto,
2121
ResendConfirmEmail,
2222
AuthConfirmEmailDto,
23+
AuthForgotPasswordDto,
24+
AuthResetPasswordDto,
2325
} from './dtos';
2426
import { AuthRoles } from './guards';
2527
import { AuthGuard } from '@nestjs/passport';
@@ -97,4 +99,16 @@ export class AuthController {
9799
): Promise<Omit<LoginResponseDto, 'user'>> {
98100
return this.authService.refreshToken(request.user);
99101
}
102+
103+
@Post('forgot/password')
104+
@HttpCode(HttpStatus.NO_CONTENT)
105+
async forgotPassword(@Body() forgotPasswordDto: AuthForgotPasswordDto): Promise<void> {
106+
return this.authService.forgotPassword(forgotPasswordDto.email);
107+
}
108+
109+
@Post('reset/password')
110+
@HttpCode(HttpStatus.NO_CONTENT)
111+
resetPassword(@Body() resetPasswordDto: AuthResetPasswordDto): Promise<void> {
112+
return this.authService.resetPassword(resetPasswordDto.hash, resetPasswordDto.password);
113+
}
100114
}

src/modules/auth/auth.service.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ export class AuthService {
8080
HttpStatus.UNPROCESSABLE_ENTITY,
8181
);
8282
}
83-
const key = `user:${userFound._id.toString()}:confirmEmailHash`;
83+
84+
const key = `auth:confirmEmailHash:${userFound._id.toString()}`;
8485

8586
if (userFound.emailVerified === true) {
8687
throw new HttpException(
@@ -157,7 +158,8 @@ export class AuthService {
157158
HttpStatus.NOT_FOUND,
158159
);
159160
}
160-
const key = `user:${user._id.toString()}:confirmEmailHash`;
161+
162+
const key = `auth:confirmEmailHash:${user._id.toString()}`;
161163

162164
if (!(await this.redisService.existsUniqueKey(key))) {
163165
throw new HttpException(
@@ -405,6 +407,111 @@ export class AuthService {
405407
return { accessToken, refreshToken, accessTokenExpires };
406408
}
407409

410+
async forgotPassword(email: string): Promise<void> {
411+
const user = await this.usersService.findByEmail(email);
412+
413+
if (!user) {
414+
throw new HttpException(
415+
{
416+
status: HttpStatus.UNPROCESSABLE_ENTITY,
417+
errors: {
418+
email: 'emailNotExists',
419+
},
420+
},
421+
HttpStatus.UNPROCESSABLE_ENTITY,
422+
);
423+
}
424+
425+
const tokenExpiresIn = this.configService.getOrThrow('AUTH_FORGOT_TOKEN_EXPIRES_IN');
426+
427+
const tokenExpires = Date.now() + convertTimeString(tokenExpiresIn);
428+
429+
const hash = await this.jwtService.signAsync(
430+
{
431+
forgotUserId: user._id,
432+
},
433+
{
434+
secret: this.configService.getOrThrow('AUTH_FORGOT_SECRET'),
435+
expiresIn: tokenExpiresIn,
436+
},
437+
);
438+
439+
const key = `auth:forgotPassword:${user._id.toString()}`;
440+
await Promise.all([
441+
this.redisService.set(key, { hash, tokenExpires }, convertTimeString(tokenExpiresIn)),
442+
this.mailService.forgotPassword({
443+
to: user.email,
444+
data: {
445+
hash,
446+
tokenExpires,
447+
},
448+
}),
449+
]);
450+
}
451+
452+
async resetPassword(hash: string, password: string): Promise<void> {
453+
let userId: User['_id'];
454+
455+
try {
456+
const jwtData = await this.jwtService.verifyAsync<{
457+
forgotUserId: User['_id'];
458+
}>(hash, {
459+
secret: this.configService.getOrThrow('AUTH_FORGOT_SECRET'),
460+
});
461+
462+
userId = jwtData.forgotUserId;
463+
} catch {
464+
throw new HttpException(
465+
{
466+
status: HttpStatus.UNPROCESSABLE_ENTITY,
467+
errors: {
468+
hash: `invalidHash`,
469+
},
470+
},
471+
HttpStatus.UNPROCESSABLE_ENTITY,
472+
);
473+
}
474+
475+
const user = await this.usersService.findById(userId);
476+
477+
if (!user) {
478+
throw new HttpException(
479+
{
480+
status: HttpStatus.UNPROCESSABLE_ENTITY,
481+
errors: {
482+
hash: `notFound`,
483+
},
484+
},
485+
HttpStatus.UNPROCESSABLE_ENTITY,
486+
);
487+
}
488+
const key = `auth:forgotPassword:${user._id.toString()}`;
489+
490+
if (!this.redisService.existsUniqueKey(key)) {
491+
throw new HttpException(
492+
{
493+
status: HttpStatus.UNPROCESSABLE_ENTITY,
494+
errors: {
495+
hash: `hashRevoked`,
496+
},
497+
},
498+
HttpStatus.UNPROCESSABLE_ENTITY,
499+
);
500+
}
501+
502+
user.password = password;
503+
504+
await Promise.all([
505+
this.redisService.del(key),
506+
this.sessionService.softDelete({
507+
user: {
508+
_id: user._id,
509+
},
510+
}),
511+
this.usersService.update(user._id, user),
512+
]);
513+
}
514+
408515
private async getTokensData(data: {
409516
userId: User['_id'];
410517
role: User['role'];
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsEmail } from 'class-validator';
3+
import { Transform } from 'class-transformer';
4+
import { lowerCaseTransformer } from '~/common/transformers';
5+
6+
export class AuthForgotPasswordDto {
7+
@ApiProperty({
8+
example: 'test@techcell.cloud',
9+
description: 'User email',
10+
required: true,
11+
})
12+
@Transform(lowerCaseTransformer)
13+
@IsEmail()
14+
email: string;
15+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty } from 'class-validator';
3+
4+
export class AuthResetPasswordDto {
5+
@ApiProperty({
6+
example: 'password',
7+
description: 'User new password',
8+
required: true,
9+
})
10+
@IsNotEmpty()
11+
password: string;
12+
13+
@ApiProperty({
14+
example: 'hash',
15+
required: true,
16+
})
17+
@IsNotEmpty()
18+
hash: string;
19+
}

src/modules/auth/dtos/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export * from './response-login.dto';
44
export * from './response-refresh-token.dto';
55
export * from './auth-resend-confirm-email.dto';
66
export * from './auth-confirm-email.dto';
7+
export * from './auth-forgot-password.dto';
8+
export * from './auth-reset-password.dto';
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface MailData<T = never> {
2+
to: string;
3+
data: T;
4+
}

src/modules/mail/mail.service.ts

Lines changed: 66 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MailerConfig } from './mail.config';
55
import { I18nContext } from 'nestjs-i18n';
66
import { I18nTranslations } from '../i18n';
77
import { MaybeType } from '~/common/types';
8+
import { MailData } from './mail-data.interface';
89

910
@Injectable()
1011
export class MailService {
@@ -136,79 +137,71 @@ export class MailService {
136137
});
137138
}
138139

139-
// async sendForgotPasswordMail(
140-
// { userEmail, firstName, otpCode }: ForgotPasswordEmailDTO,
141-
// lang,
142-
// transporter?: string,
143-
// retryCount = 0,
144-
// ) {
145-
// const i18Context = new I18nContext(lang, this.i18n);
146-
// if (retryCount > this.MAX_RETRIES) {
147-
// this.logger.debug(`Send mail failed: too many retries`);
148-
// return {
149-
// message: 'Failed to send email',
150-
// };
151-
// }
152-
153-
// const transporterName = this.resolveTransporter(transporter);
154-
// const message = `Mail sent: ${userEmail}`;
155-
// this.logger.debug(
156-
// `Sending forgot password mail to ${userEmail} with transporter: ${transporterName}`,
157-
// );
158-
// return this.mailerService
159-
// .sendMail({
160-
// transporterName,
161-
// to: userEmail,
162-
// subject: i18Context.t('emailMessage.RESET_PASSWORD_SUBJECT'),
163-
// template: 'forgot-password',
164-
// context: {
165-
// userEmail,
166-
// firstName,
167-
// otpCode,
168-
// FORGOT_PASSWORD_TEXT_1: i18Context.t('emailMessage.FORGOT_PASSWORD_TEXT_1'),
169-
// YOUR_OTP_CODE_TEXT: i18Context.t('emailMessage.YOUR_OTP_CODE_TEXT'),
170-
// EXPIRED_TIME_OTP_TEXT: i18Context.t('emailMessage.EXPIRED_TIME_OTP_TEXT', {
171-
// args: {
172-
// time: '5',
173-
// },
174-
// }),
175-
// INSTRUCTIONS: i18Context.t('emailMessage.INSTRUCTIONS'),
176-
// INSTRUCTIONS_TEXT_ENTER_OTP_RESET_PASSWORD: i18Context.t(
177-
// 'emailMessage.INSTRUCTIONS_TEXT_ENTER_OTP_RESET_PASSWORD',
178-
// ),
179-
// EMAIL_CREDIT: i18Context.t('emailMessage.EMAIL_CREDIT'),
180-
// },
181-
// attachments: [
182-
// {
183-
// filename: 'logo-red.png',
184-
// path: join(this.TEMPLATES_PATH, 'images/logo-red.png'),
185-
// cid: 'logo_red',
186-
// },
187-
// ],
188-
// })
189-
// .then(() => {
190-
// this.logger.debug(`Mail sent: ${userEmail}`);
191-
// return {
192-
// message: message,
193-
// };
194-
// })
195-
// .catch(async (error) => {
196-
// this.logger.debug(`Send mail failed: ${error.message}`);
197-
// transporter = this.getNextTransporter(transporterName);
198-
// this.logger.debug(`Retry send mail with transporter: ${transporter}`);
199-
// return await this.sendForgotPasswordMail(
200-
// { userEmail, firstName, otpCode },
201-
// lang,
202-
// transporter,
203-
// retryCount + 1,
204-
// );
205-
// })
206-
// .finally(() => {
207-
// return {
208-
// message: message,
209-
// };
210-
// });
211-
// }
140+
async forgotPassword(
141+
mailData: MailData<{ hash: string; tokenExpires: number }>,
142+
retryData: {
143+
retryCount?: number;
144+
transporter?: string;
145+
} = { retryCount: 0, transporter: SENDGRID_TRANSPORT },
146+
) {
147+
const { retryCount = 0, transporter = SENDGRID_TRANSPORT } = retryData;
148+
149+
if (retryCount > this.MAX_RETRIES) {
150+
this.logger.debug(`Send mail failed: too many retries`);
151+
return {
152+
message: 'Failed to send email',
153+
};
154+
}
155+
156+
const i18n = I18nContext.current<I18nTranslations>();
157+
let resetPasswordTitle: MaybeType<string>;
158+
let text1: MaybeType<string>;
159+
let text2: MaybeType<string>;
160+
let text3: MaybeType<string>;
161+
let text4: MaybeType<string>;
162+
163+
if (i18n) {
164+
[resetPasswordTitle, text1, text2, text3, text4] = await Promise.all([
165+
i18n.t('mail-context.RESET_PASSWORD.title'),
166+
i18n.t('mail-context.RESET_PASSWORD.text1'),
167+
i18n.t('mail-context.RESET_PASSWORD.text2'),
168+
i18n.t('mail-context.RESET_PASSWORD.text3'),
169+
i18n.t('mail-context.RESET_PASSWORD.text4'),
170+
]);
171+
}
172+
173+
const url = new URL(process.env.FE_DOMAIN + '/password-change');
174+
url.searchParams.set('hash', mailData.data.hash);
175+
url.searchParams.set('expires', mailData.data.tokenExpires.toString());
176+
177+
let transporterName = this.resolveTransporter(transporter);
178+
await this.mailerService
179+
.sendMail({
180+
to: mailData.to,
181+
subject: resetPasswordTitle,
182+
text: `${url.toString()} ${resetPasswordTitle}`,
183+
template: 'reset-password',
184+
context: {
185+
title: resetPasswordTitle,
186+
url: url.toString(),
187+
actionTitle: resetPasswordTitle,
188+
app_name: 'TechCell.cloud',
189+
text1,
190+
text2,
191+
text3,
192+
text4,
193+
},
194+
})
195+
.catch(async (error) => {
196+
this.logger.debug(`Send mail failed: ${error.message}`);
197+
transporterName = this.getNextTransporter(transporterName);
198+
this.logger.debug(`Retry send mail with transporter: ${transporterName}`);
199+
await this.forgotPassword(mailData, {
200+
transporter: transporterName,
201+
retryCount: retryCount + 1,
202+
});
203+
});
204+
}
212205

213206
private resolveTransporter(transporter = SENDGRID_TRANSPORT) {
214207
if (!this.TRANSPORTERS.includes(transporter)) {

0 commit comments

Comments
 (0)