Skip to content

Commit a01e42f

Browse files
committed
basic recover password flow
1 parent 7a304b0 commit a01e42f

File tree

9 files changed

+171
-16
lines changed

9 files changed

+171
-16
lines changed

api/src/modules/auth/auth.module.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { Module } from '@nestjs/common';
22
import { AuthenticationModule } from '@api/modules/auth/authentication/authentication.module';
33
import { AuthorisationModule } from '@api/modules/auth/authorisation/authorisation.module';
4+
import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service';
5+
import { AuthMailer } from '@api/modules/auth/services/auth.mailer';
6+
import { NotificationsModule } from '@api/modules/notifications/notifications.module';
7+
import { AuthenticationController } from '@api/modules/auth/authentication/authentication.controller';
48

59
@Module({
6-
imports: [AuthenticationModule, AuthorisationModule],
7-
controllers: [],
8-
providers: [],
10+
imports: [AuthenticationModule, AuthorisationModule, NotificationsModule],
11+
controllers: [AuthenticationController],
12+
providers: [PasswordRecoveryService, AuthMailer],
913
})
1014
export class AuthModule {}

api/src/modules/auth/authentication/authentication.controller.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
1+
import { Body, Controller, Post, UseGuards, Headers } from '@nestjs/common';
22
import { User } from '@shared/entities/users/user.entity';
33
import { AuthenticationService } from '@api/modules/auth/authentication/authentication.service';
44
import { LoginDto } from '@api/modules/auth/dtos/login.dto';
55
import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard';
66
import { GetUser } from '@api/modules/auth/decorators/get-user.decorator';
77
import { Public } from '@api/modules/auth/decorators/is-public.decorator';
8+
import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service';
89

910
@Controller('authentication')
1011
export class AuthenticationController {
11-
constructor(private authService: AuthenticationService) {}
12+
constructor(
13+
private authService: AuthenticationService,
14+
private readonly passwordRecovery: PasswordRecoveryService,
15+
) {}
1216

1317
@Public()
1418
@Post('signup')
@@ -22,4 +26,13 @@ export class AuthenticationController {
2226
async login(@GetUser() user: User) {
2327
return this.authService.logIn(user);
2428
}
29+
30+
@Public()
31+
@Post('recover-password')
32+
async recoverPassword(
33+
@Headers('origin') origin: string,
34+
@Body() body: { email: string },
35+
) {
36+
await this.passwordRecovery.recoverPassword(body.email, origin);
37+
}
2538
}

api/src/modules/auth/authentication/authentication.module.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Module } from '@nestjs/common';
22
import { AuthenticationService } from './authentication.service';
3-
import { AuthenticationController } from './authentication.controller';
43
import { PassportModule } from '@nestjs/passport';
54
import { JwtModule } from '@nestjs/jwt';
65
import { ApiConfigModule } from '@api/modules/config/app-config.module';
@@ -9,7 +8,6 @@ import { UsersService } from '@api/modules/users/users.service';
98
import { UsersModule } from '@api/modules/users/users.module';
109
import { LocalStrategy } from '@api/modules/auth/strategies/local.strategy';
1110
import { JwtStrategy } from '@api/modules/auth/strategies/jwt.strategy';
12-
import { NotificationsModule } from '@api/modules/notifications/notifications.module';
1311

1412
@Module({
1513
imports: [
@@ -23,7 +21,6 @@ import { NotificationsModule } from '@api/modules/notifications/notifications.mo
2321
}),
2422
}),
2523
UsersModule,
26-
NotificationsModule,
2724
],
2825
providers: [
2926
AuthenticationService,
@@ -36,6 +33,6 @@ import { NotificationsModule } from '@api/modules/notifications/notifications.mo
3633
inject: [UsersService, ApiConfigService],
3734
},
3835
],
39-
controllers: [AuthenticationController],
36+
exports: [JwtModule, UsersModule, AuthenticationService],
4037
})
4138
export class AuthenticationModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Inject, Injectable } from '@nestjs/common';
2+
import {
3+
IEmailServiceInterface,
4+
IEmailServiceToken,
5+
} from '@api/modules/notifications/email/email-service.interface';
6+
import { ApiConfigService } from '@api/modules/config/app-config.service';
7+
8+
export type PasswordRecovery = {
9+
email: string;
10+
token: string;
11+
origin: string;
12+
};
13+
14+
@Injectable()
15+
export class AuthMailer {
16+
constructor(
17+
@Inject(IEmailServiceToken)
18+
private readonly emailService: IEmailServiceInterface,
19+
private readonly apiConfig: ApiConfigService,
20+
) {}
21+
22+
async sendPasswordRecoveryEmail(
23+
passwordRecovery: PasswordRecovery,
24+
): Promise<void> {
25+
// TODO: Investigate if it's worth using a template engine to generate the email content, the mail service provider allows it
26+
// TODO: Use a different expiration time, or different secret altogether for password recovery
27+
28+
const { expiresIn } = this.apiConfig.getJWTConfig();
29+
30+
const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${passwordRecovery.token}`;
31+
32+
const htmlContent: string = `
33+
<h1>Dear User,</h1>
34+
<br/>
35+
<p>We recently received a request to reset your password for your account. If you made this request, please click on the link below to securely change your password:</p>
36+
<br/>
37+
<p><a href="${resetPasswordUrl}" target="_blank" rel="noopener noreferrer">Secure Password Reset Link</a></p>
38+
<br/>
39+
<p>This link will direct you to our app to create a new password. For security reasons, this link will expire after ${passwordRecoveryTokenExpirationHumanReadable(expiresIn)}.</p>
40+
<p>If you did not request a password reset, please ignore this email; your password will remain the same.</p>
41+
<br/>
42+
<p>Thank you for using the platform. We're committed to ensuring your account's security.</p>
43+
<p>Best regards.</p>`;
44+
45+
await this.emailService.sendMail({
46+
from: 'password-recovery',
47+
to: passwordRecovery.email,
48+
subject: 'Recover Password',
49+
html: htmlContent,
50+
});
51+
}
52+
}
53+
54+
const passwordRecoveryTokenExpirationHumanReadable = (
55+
expiration: string,
56+
): string => {
57+
const unit = expiration.slice(-1);
58+
const value = parseInt(expiration.slice(0, -1), 10);
59+
60+
switch (unit) {
61+
case 'h':
62+
return `${value} hour${value > 1 ? 's' : ''}`;
63+
case 'd':
64+
return `${value} day${value > 1 ? 's' : ''}`;
65+
default:
66+
return expiration;
67+
}
68+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { UsersService } from '@api/modules/users/users.service';
3+
import { JwtService } from '@nestjs/jwt';
4+
import { AuthMailer } from '@api/modules/auth/services/auth.mailer';
5+
6+
@Injectable()
7+
export class PasswordRecoveryService {
8+
logger: Logger = new Logger(PasswordRecoveryService.name);
9+
constructor(
10+
private readonly users: UsersService,
11+
private readonly jwt: JwtService,
12+
private readonly authMailer: AuthMailer,
13+
) {}
14+
15+
async recoverPassword(email: string, origin: string): Promise<void> {
16+
const user = await this.users.findByEmail(email);
17+
if (!user) {
18+
// TODO: We don't want to expose this info back, but we probably want to log and save this event internally, plus
19+
// maybe sent an email to admin
20+
this.logger.warn(
21+
`Email ${email} not found when trying to recover password`,
22+
);
23+
return;
24+
}
25+
const token = this.jwt.sign({ id: user.id });
26+
await this.authMailer.sendPasswordRecoveryEmail({
27+
email: user.email,
28+
token,
29+
origin,
30+
});
31+
}
32+
}

api/src/modules/config/app-config.service.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { Injectable } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { DATABASE_ENTITIES } from '@shared/entities/database.entities';
4-
import { readdirSync } from 'fs';
5-
import { join } from 'path';
64

75
export type JWTConfig = {
86
secret: string;
@@ -44,4 +42,8 @@ export class ApiConfigService {
4442
expiresIn: this.configService.get('JWT_EXPIRES_IN'),
4543
};
4644
}
45+
46+
get(envVarName: string): ConfigService {
47+
return this.configService.get(envVarName);
48+
}
4749
}

api/src/modules/notifications/notifications.module.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import { EmailModule } from './email/email.module';
33

44
@Module({
55
imports: [EmailModule],
6+
exports: [EmailModule],
67
})
78
export class NotificationsModule {}
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { TestManager } from '../utils/test-manager';
2+
import { User } from '@shared/entities/users/user.entity';
3+
import { MockEmailService } from '../utils/mocks/mock-email.service';
4+
import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface';
5+
6+
describe('Password Recovery', () => {
7+
let testManager: TestManager;
8+
let testUser: User;
9+
let mockEmailService: MockEmailService;
10+
11+
beforeAll(async () => {
12+
testManager = await TestManager.createTestManager();
13+
mockEmailService =
14+
testManager.moduleFixture.get<MockEmailService>(IEmailServiceToken);
15+
});
16+
beforeEach(async () => {
17+
const { user } = await testManager.setUpTestUser();
18+
testUser = user;
19+
});
20+
afterEach(async () => {
21+
await testManager.clearDatabase();
22+
});
23+
it('an email should be sent if a user with provided email has been found', async () => {
24+
const response = await testManager
25+
.request()
26+
.post(`/authentication/recover-password`)
27+
.send({ email: testUser.email });
28+
29+
expect(response.status).toBe(201);
30+
expect(mockEmailService.sendMail).toHaveBeenCalledTimes(1);
31+
});
32+
it('should return 200 if user has not been found but no mail should be sent', async () => {
33+
const response = await testManager
34+
.request()
35+
.post(`/authentication/recover-password`)
36+
.send({ email: '[email protected]' });
37+
38+
expect(response.status).toBe(201);
39+
expect(mockEmailService.sendMail).toHaveBeenCalledTimes(0);
40+
});
41+
});

api/test/utils/mocks/mock-email.service.ts

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import {
2-
IEmailServiceInterface,
3-
SendMailDTO,
4-
} from '@api/modules/notifications/email/email-service.interface';
1+
import { IEmailServiceInterface } from '@api/modules/notifications/email/email-service.interface';
52
import { Logger } from '@nestjs/common';
63

74
export class MockEmailService implements IEmailServiceInterface {
85
logger: Logger = new Logger(MockEmailService.name);
96

10-
sendMail = jest.fn(async (sendMailDTO: SendMailDTO): Promise<void> => {
7+
sendMail = jest.fn(async (): Promise<void> => {
118
this.logger.log('Mock Email sent');
129
return Promise.resolve();
1310
});

0 commit comments

Comments
 (0)