diff --git a/api/src/modules/auth/auth.module.ts b/api/src/modules/auth/auth.module.ts index 1a801034..c2ab41d5 100644 --- a/api/src/modules/auth/auth.module.ts +++ b/api/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/api/src/modules/auth/authentication/authentication.controller.ts b/api/src/modules/auth/authentication/authentication.controller.ts index 193b55e8..f8b3f103 100644 --- a/api/src/modules/auth/authentication/authentication.controller.ts +++ b/api/src/modules/auth/authentication/authentication.controller.ts @@ -1,14 +1,18 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Post, UseGuards, Headers } from '@nestjs/common'; import { User } from '@shared/entities/users/user.entity'; import { AuthenticationService } from '@api/modules/auth/authentication/authentication.service'; import { LoginDto } from '@api/modules/auth/dtos/login.dto'; import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard'; import { GetUser } from '@api/modules/auth/decorators/get-user.decorator'; import { Public } from '@api/modules/auth/decorators/is-public.decorator'; +import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service'; @Controller('authentication') export class AuthenticationController { - constructor(private authService: AuthenticationService) {} + constructor( + private authService: AuthenticationService, + private readonly passwordRecovery: PasswordRecoveryService, + ) {} @Public() @Post('signup') @@ -22,4 +26,13 @@ export class AuthenticationController { async login(@GetUser() user: User) { return this.authService.logIn(user); } + + @Public() + @Post('recover-password') + async recoverPassword( + @Headers('origin') origin: string, + @Body() body: { email: string }, + ) { + await this.passwordRecovery.recoverPassword(body.email, origin); + } } diff --git a/api/src/modules/auth/authentication/authentication.module.ts b/api/src/modules/auth/authentication/authentication.module.ts index 2bd00064..7ff5f082 100644 --- a/api/src/modules/auth/authentication/authentication.module.ts +++ b/api/src/modules/auth/authentication/authentication.module.ts @@ -9,7 +9,6 @@ import { UsersService } from '@api/modules/users/users.service'; import { UsersModule } from '@api/modules/users/users.module'; import { LocalStrategy } from '@api/modules/auth/strategies/local.strategy'; import { JwtStrategy } from '@api/modules/auth/strategies/jwt.strategy'; -import { NotificationsModule } from '@api/modules/notifications/notifications.module'; @Module({ imports: [ @@ -23,7 +22,6 @@ import { NotificationsModule } from '@api/modules/notifications/notifications.mo }), }), UsersModule, - NotificationsModule, ], providers: [ AuthenticationService, @@ -36,6 +34,6 @@ import { NotificationsModule } from '@api/modules/notifications/notifications.mo inject: [UsersService, ApiConfigService], }, ], - controllers: [AuthenticationController], + exports: [JwtModule, UsersModule, AuthenticationService], }) export class AuthenticationModule {} diff --git a/api/src/modules/auth/services/auth.mailer.ts b/api/src/modules/auth/services/auth.mailer.ts new file mode 100644 index 00000000..74e9c280 --- /dev/null +++ b/api/src/modules/auth/services/auth.mailer.ts @@ -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 { + // 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 = ` +

Dear User,

+
+

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:

+
+

Secure Password Reset Link

+
+

This link will direct you to our app to create a new password. For security reasons, this link will expire after ${passwordRecoveryTokenExpirationHumanReadable(expiresIn)}.

+

If you did not request a password reset, please ignore this email; your password will remain the same.

+
+

Thank you for using the platform. We're committed to ensuring your account's security.

+

Best regards.

`; + + 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; + } +}; diff --git a/api/src/modules/auth/services/password-recovery.service.ts b/api/src/modules/auth/services/password-recovery.service.ts new file mode 100644 index 00000000..d11d1e8b --- /dev/null +++ b/api/src/modules/auth/services/password-recovery.service.ts @@ -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 { + 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, + }); + } +} diff --git a/api/src/modules/config/app-config.service.ts b/api/src/modules/config/app-config.service.ts index 71f03e2b..ea5ee7d6 100644 --- a/api/src/modules/config/app-config.service.ts +++ b/api/src/modules/config/app-config.service.ts @@ -44,4 +44,8 @@ export class ApiConfigService { expiresIn: this.configService.get('JWT_EXPIRES_IN'), }; } + + get(envVarName: string): ConfigService { + return this.configService.get(envVarName); + } } diff --git a/api/src/modules/notifications/notifications.module.ts b/api/src/modules/notifications/notifications.module.ts index 61a0abcd..49ae52ae 100644 --- a/api/src/modules/notifications/notifications.module.ts +++ b/api/src/modules/notifications/notifications.module.ts @@ -3,5 +3,6 @@ import { EmailModule } from './email/email.module'; @Module({ imports: [EmailModule], + exports: [EmailModule], }) export class NotificationsModule {} diff --git a/api/test/auth/password-recovery.spec.ts b/api/test/auth/password-recovery.spec.ts new file mode 100644 index 00000000..9dba42ac --- /dev/null +++ b/api/test/auth/password-recovery.spec.ts @@ -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(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: 'no-user@test.com' }); + + expect(response.status).toBe(201); + expect(mockEmailService.sendMail).toHaveBeenCalledTimes(0); + }); +});