-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
169 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,14 @@ | ||
import { Module } from '@nestjs/common'; | ||
import { AuthenticationModule } from '@api/modules/auth/authentication/authentication.module'; | ||
import { AuthorisationModule } from '@api/modules/auth/authorisation/authorisation.module'; | ||
import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service'; | ||
import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; | ||
import { NotificationsModule } from '@api/modules/notifications/notifications.module'; | ||
import { AuthenticationController } from '@api/modules/auth/authentication/authentication.controller'; | ||
|
||
@Module({ | ||
imports: [AuthenticationModule, AuthorisationModule], | ||
controllers: [], | ||
providers: [], | ||
imports: [AuthenticationModule, AuthorisationModule, NotificationsModule], | ||
controllers: [AuthenticationController], | ||
providers: [PasswordRecoveryService, AuthMailer], | ||
}) | ||
export class AuthModule {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { Inject, Injectable } from '@nestjs/common'; | ||
import { | ||
IEmailServiceInterface, | ||
IEmailServiceToken, | ||
} from '@api/modules/notifications/email/email-service.interface'; | ||
import { ApiConfigService } from '@api/modules/config/app-config.service'; | ||
|
||
export type PasswordRecovery = { | ||
email: string; | ||
token: string; | ||
origin: string; | ||
}; | ||
|
||
@Injectable() | ||
export class AuthMailer { | ||
constructor( | ||
@Inject(IEmailServiceToken) | ||
private readonly emailService: IEmailServiceInterface, | ||
private readonly apiConfig: ApiConfigService, | ||
) {} | ||
|
||
async sendPasswordRecoveryEmail( | ||
passwordRecovery: PasswordRecovery, | ||
): Promise<void> { | ||
// TODO: Investigate if it's worth using a template engine to generate the email content, the mail service provider allows it | ||
// TODO: Use a different expiration time, or different secret altogether for password recovery | ||
|
||
const { expiresIn } = this.apiConfig.getJWTConfig(); | ||
|
||
const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${passwordRecovery.token}`; | ||
|
||
const htmlContent: string = ` | ||
<h1>Dear User,</h1> | ||
<br/> | ||
<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> | ||
<br/> | ||
<p><a href="${resetPasswordUrl}" target="_blank" rel="noopener noreferrer">Secure Password Reset Link</a></p> | ||
<br/> | ||
<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> | ||
<p>If you did not request a password reset, please ignore this email; your password will remain the same.</p> | ||
<br/> | ||
<p>Thank you for using the platform. We're committed to ensuring your account's security.</p> | ||
<p>Best regards.</p>`; | ||
|
||
await this.emailService.sendMail({ | ||
from: 'password-recovery', | ||
to: passwordRecovery.email, | ||
subject: 'Recover Password', | ||
html: htmlContent, | ||
}); | ||
} | ||
} | ||
|
||
const passwordRecoveryTokenExpirationHumanReadable = ( | ||
expiration: string, | ||
): string => { | ||
const unit = expiration.slice(-1); | ||
const value = parseInt(expiration.slice(0, -1), 10); | ||
|
||
switch (unit) { | ||
case 'h': | ||
return `${value} hour${value > 1 ? 's' : ''}`; | ||
case 'd': | ||
return `${value} day${value > 1 ? 's' : ''}`; | ||
default: | ||
return expiration; | ||
} | ||
}; |
32 changes: 32 additions & 0 deletions
32
api/src/modules/auth/services/password-recovery.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; | ||
import { UsersService } from '@api/modules/users/users.service'; | ||
import { JwtService } from '@nestjs/jwt'; | ||
import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; | ||
|
||
@Injectable() | ||
export class PasswordRecoveryService { | ||
logger: Logger = new Logger(PasswordRecoveryService.name); | ||
constructor( | ||
private readonly users: UsersService, | ||
private readonly jwt: JwtService, | ||
private readonly authMailer: AuthMailer, | ||
) {} | ||
|
||
async recoverPassword(email: string, origin: string): Promise<void> { | ||
const user = await this.users.findByEmail(email); | ||
if (!user) { | ||
// TODO: We don't want to expose this info back, but we probably want to log and save this event internally, plus | ||
// maybe sent an email to admin | ||
this.logger.warn( | ||
`Email ${email} not found when trying to recover password`, | ||
); | ||
return; | ||
} | ||
const token = this.jwt.sign({ id: user.id }); | ||
await this.authMailer.sendPasswordRecoveryEmail({ | ||
email: user.email, | ||
token, | ||
origin, | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { TestManager } from '../utils/test-manager'; | ||
import { User } from '@shared/entities/users/user.entity'; | ||
import { MockEmailService } from '../utils/mocks/mock-email.service'; | ||
import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; | ||
|
||
describe('Password Recovery', () => { | ||
let testManager: TestManager; | ||
let testUser: User; | ||
let mockEmailService: MockEmailService; | ||
|
||
beforeAll(async () => { | ||
testManager = await TestManager.createTestManager(); | ||
mockEmailService = | ||
testManager.moduleFixture.get<MockEmailService>(IEmailServiceToken); | ||
}); | ||
beforeEach(async () => { | ||
const { user } = await testManager.setUpTestUser(); | ||
testUser = user; | ||
}); | ||
afterEach(async () => { | ||
await testManager.clearDatabase(); | ||
}); | ||
it('an email should be sent if a user with provided email has been found', async () => { | ||
const response = await testManager | ||
.request() | ||
.post(`/authentication/recover-password`) | ||
.send({ email: testUser.email }); | ||
|
||
expect(response.status).toBe(201); | ||
expect(mockEmailService.sendMail).toHaveBeenCalledTimes(1); | ||
}); | ||
it('should return 200 if user has not been found but no mail should be sent', async () => { | ||
const response = await testManager | ||
.request() | ||
.post(`/authentication/recover-password`) | ||
.send({ email: '[email protected]' }); | ||
|
||
expect(response.status).toBe(201); | ||
expect(mockEmailService.sendMail).toHaveBeenCalledTimes(0); | ||
}); | ||
}); |