Skip to content

Commit 92bdaeb

Browse files
committed
Add password request feature
1 parent aef68b4 commit 92bdaeb

17 files changed

+277
-12
lines changed

backend/auth-service/.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,12 @@ GITHUB_CLIENT_ID=GITHUB_CLIENT_ID
1111
GITHUB_CLIENT_SECRET=GITHUB_CLIENT_SECRET
1212
GITHUB_CALLBACK_URL=http://localhost:4000/api/auth/github/callback
1313

14+
# Gmail Service
15+
NODEMAILER_GMAIL_USER=GMAIL_EMAIL
16+
NODEMAILER_GMAIL_PASSWORD=GMAIL_PASSWORD
17+
1418
# Database
15-
MONGO_CONNECTION_STRING=MONGO_CONNECTION_STRING
19+
MONGO_CONNECTION_STRING=MONGO_CONNECTION_STRING
20+
21+
# Frontend
22+
FRONTEND_URL=http://localhost:3000

backend/auth-service/package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/auth-service/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"class-transformer": "^0.5.1",
3333
"class-validator": "^0.14.1",
3434
"google-auth-library": "^9.14.1",
35+
"nodemailer": "^6.9.15",
3536
"passport": "^0.7.0",
3637
"passport-github2": "^0.1.12",
3738
"passport-google-oauth20": "^2.0.0",
@@ -47,6 +48,7 @@
4748
"@types/express": "^4.17.17",
4849
"@types/jest": "^29.5.2",
4950
"@types/node": "^20.3.1",
51+
"@types/nodemailer": "^6.4.16",
5052
"@types/passport-google-oauth20": "^2.0.16",
5153
"@types/supertest": "^6.0.0",
5254
"@typescript-eslint/eslint-plugin": "^8.0.0",

backend/auth-service/src/app.controller.spec.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,4 @@ describe('AppController', () => {
1414
appController = app.get<AppController>(AppController);
1515
});
1616

17-
describe('root', () => {
18-
it('should return "Hello World!"', () => {
19-
expect(appController.getHello()).toBe('Hello World!');
20-
});
21-
});
2217
});

backend/auth-service/src/app.controller.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { Controller } from '@nestjs/common';
22
import { AppService } from './app.service';
33
import { MessagePattern, Payload } from '@nestjs/microservices';
4-
import { AuthDto, AuthIdDto, RefreshTokenDto } from './dto';
4+
import {
5+
AuthDto,
6+
AuthIdDto,
7+
RefreshTokenDto,
8+
ResetPasswordDto,
9+
ResetPasswordRequestDto,
10+
} from './dto';
511

612
@Controller()
713
export class AppController {
@@ -22,11 +28,26 @@ export class AppController {
2228
return this.appService.logout(dto);
2329
}
2430

31+
@MessagePattern({ cmd: 'request-reset-password' })
32+
requestResetPassword(@Payload() dto: ResetPasswordRequestDto) {
33+
return this.appService.generateResetPasswordRequest(dto);
34+
}
35+
36+
@MessagePattern({ cmd: 'reset-password' })
37+
resetPassword(@Payload() dto: ResetPasswordDto) {
38+
return this.appService.resetPassword(dto);
39+
}
40+
2541
@MessagePattern({ cmd: 'refresh-token' })
2642
refreshToken(@Payload() dto: RefreshTokenDto) {
2743
return this.appService.refreshToken(dto);
2844
}
2945

46+
@MessagePattern({ cmd: 'validate-password-reset-token' })
47+
validatePasswordResetToken(token: string) {
48+
return this.appService.validatePasswordResetToken(token);
49+
}
50+
3051
@MessagePattern({ cmd: 'validate-access-token' })
3152
validateToken(accessToken: string) {
3253
return this.appService.validateAccessToken(accessToken);

backend/auth-service/src/app.service.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import * as bcrypt from 'bcryptjs';
2-
import { Inject, Injectable } from '@nestjs/common';
2+
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
33
import { JwtService } from '@nestjs/jwt';
44
import { Credentials, OAuth2Client } from 'google-auth-library';
55
import { ClientProxy } from '@nestjs/microservices';
6-
import { AuthDto, AuthIdDto, RefreshTokenDto } from './dto';
6+
import {
7+
AuthDto,
8+
AuthIdDto,
9+
RefreshTokenDto,
10+
ResetPasswordDto,
11+
ResetPasswordRequestDto,
12+
} from './dto';
713
import { HttpService } from '@nestjs/axios';
814
import { RpcException } from '@nestjs/microservices';
915
import { firstValueFrom } from 'rxjs';
1016
import axios, { AxiosResponse } from 'axios';
1117
import { Token, TokenPayload } from './interfaces';
1218
import { AccountProvider } from './constants/account-provider.enum';
19+
import * as nodemailer from 'nodemailer';
1320

1421
const SALT_ROUNDS = 10;
1522

@@ -141,6 +148,102 @@ export class AppService {
141148
}
142149
}
143150

151+
public async generateResetPasswordRequest(dto: ResetPasswordRequestDto): Promise<boolean> {
152+
const user = await firstValueFrom(
153+
this.userClient.send(
154+
{
155+
cmd: 'get-user-by-email',
156+
},
157+
dto.email,
158+
),
159+
);
160+
161+
if (!user) {
162+
throw new RpcException('User not found');
163+
}
164+
165+
const resetToken = this.jwtService.sign(
166+
{ userId: user._id.toString(), email: dto.email, type: 'reset-password' },
167+
{
168+
secret: process.env.JWT_SECRET,
169+
expiresIn: '1hr',
170+
},
171+
);
172+
173+
// Send reset password email
174+
await this.sendResetEmail(user.email, resetToken);
175+
176+
return true;
177+
}
178+
179+
public async resetPassword(dto: ResetPasswordDto): Promise<boolean> {
180+
const { userId, email } = await this.validatePasswordResetToken(dto.token);
181+
182+
const hashedPassword = await bcrypt.hash(dto.password, SALT_ROUNDS);
183+
184+
const response = await firstValueFrom(
185+
this.userClient.send(
186+
{ cmd: 'update-user-password' },
187+
{ id: userId, password: hashedPassword },
188+
),
189+
);
190+
191+
if (!response) {
192+
throw new RpcException('Error resetting password');
193+
}
194+
195+
return true;
196+
}
197+
198+
public async validatePasswordResetToken(token: string): Promise<any> {
199+
try {
200+
const decoded = this.jwtService.verify(token, {
201+
secret: process.env.JWT_SECRET,
202+
});
203+
const { userId, email, type } = decoded;
204+
if (type !== 'reset-password') {
205+
throw new RpcException({
206+
statusCode: HttpStatus.UNAUTHORIZED,
207+
message: 'Unauthorized: Invalid or expired password reset token',
208+
});
209+
}
210+
return { userId, email };
211+
} catch (err) {
212+
throw new RpcException({
213+
statusCode: HttpStatus.UNAUTHORIZED,
214+
message: 'Unauthorized: Invalid or expired password reset token',
215+
});
216+
}
217+
}
218+
219+
private async sendResetEmail(email: string, token: string) {
220+
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; // To change next time
221+
222+
const transporter = nodemailer.createTransport({
223+
service: 'gmail',
224+
host: 'smtp.gmail.com',
225+
port: 465,
226+
secure: true,
227+
auth: {
228+
user: process.env.NODEMAILER_GMAIL_USER,
229+
pass: process.env.NODEMAILER_GMAIL_PASSWORD,
230+
},
231+
});
232+
233+
const mailOptions = {
234+
235+
to: email,
236+
subject: 'Password Reset for PeerPrep',
237+
text: `Click here to reset your password: ${resetUrl}`,
238+
};
239+
240+
transporter.sendMail(mailOptions, (error, info) => {
241+
if (error) {
242+
throw new RpcException(`Error sending reset email: ${error.message}`);
243+
}
244+
});
245+
}
246+
144247
public async validateAccessToken(accessToken: string): Promise<any> {
145248
try {
146249
const decoded = this.jwtService.verify(accessToken, {
@@ -412,7 +515,7 @@ export class AppService {
412515
return {
413516
...response.data,
414517
email: emailResponse.data.find((email) => email.primary)?.email,
415-
}
518+
};
416519
} catch (error) {
417520
throw new RpcException(
418521
`Unable to retrieve Github user profile: ${error.message}`,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export { AuthDto } from './auth.dto';
22
export { AuthIdDto } from './auth-id.dto';
33
export { RefreshTokenDto } from './refresh-token.dto';
4+
export { ResetPasswordRequestDto } from './reset-password-request.dto';
5+
export { ResetPasswordDto } from './reset-password.dto';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
2+
3+
export class ResetPasswordRequestDto {
4+
@IsEmail()
5+
@IsNotEmpty()
6+
email: string;
7+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { IsNotEmpty, IsString } from 'class-validator';
2+
3+
export class ResetPasswordDto {
4+
@IsString()
5+
@IsNotEmpty()
6+
token: string;
7+
8+
@IsString()
9+
@IsNotEmpty()
10+
password: string;
11+
}

backend/gateway-service/src/modules/auth/auth.controller.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from '@nestjs/common';
1313
import { Response } from 'express';
1414
import { ApiTags } from '@nestjs/swagger';
15-
import { AuthDto } from './dto';
15+
import { AuthDto, ResetPasswordDto, ResetPasswordRequestDto } from './dto';
1616
import { Token } from './interfaces';
1717
import { ClientProxy } from '@nestjs/microservices';
1818
import { first, firstValueFrom } from 'rxjs';
@@ -45,6 +45,32 @@ export class AuthController {
4545
);
4646
}
4747

48+
@Public()
49+
@Post('reset-password')
50+
async requestResetPassword(@Body() data: ResetPasswordRequestDto): Promise<boolean> {
51+
return await firstValueFrom(
52+
this.authClient.send({ cmd: 'request-reset-password' }, data),
53+
);
54+
}
55+
56+
@Public()
57+
@Post('reset-password/verify')
58+
async verifyResetToken(@Body('token') token: string): Promise<boolean> {
59+
return await firstValueFrom(
60+
this.authClient.send({ cmd: 'validate-password-reset-token' }, token),
61+
);
62+
}
63+
64+
@Public()
65+
@Post('reset-password/confirm')
66+
async resetPassword(
67+
@Body() data: ResetPasswordDto,
68+
): Promise<boolean> {
69+
return await firstValueFrom(
70+
this.authClient.send({ cmd: 'reset-password' }, data),
71+
);
72+
}
73+
4874
@Post('logout')
4975
@HttpCode(HttpStatus.OK)
5076
async logOut(@GetCurrentUserId() userId: string): Promise<boolean> {

0 commit comments

Comments
 (0)