Skip to content

Commit

Permalink
basic recover password flow
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh committed Sep 15, 2024
1 parent 7a304b0 commit b7372ff
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 8 deletions.
10 changes: 7 additions & 3 deletions api/src/modules/auth/auth.module.ts
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 {}
17 changes: 15 additions & 2 deletions api/src/modules/auth/authentication/authentication.controller.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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);
}
}
4 changes: 1 addition & 3 deletions api/src/modules/auth/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -23,7 +22,6 @@ import { NotificationsModule } from '@api/modules/notifications/notifications.mo
}),
}),
UsersModule,
NotificationsModule,
],
providers: [
AuthenticationService,
Expand All @@ -36,6 +34,6 @@ import { NotificationsModule } from '@api/modules/notifications/notifications.mo
inject: [UsersService, ApiConfigService],
},
],
controllers: [AuthenticationController],
exports: [JwtModule, UsersModule, AuthenticationService],
})
export class AuthenticationModule {}
68 changes: 68 additions & 0 deletions api/src/modules/auth/services/auth.mailer.ts
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 api/src/modules/auth/services/password-recovery.service.ts
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,
});
}
}
4 changes: 4 additions & 0 deletions api/src/modules/config/app-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ export class ApiConfigService {
expiresIn: this.configService.get('JWT_EXPIRES_IN'),
};
}

get(envVarName: string): ConfigService {
return this.configService.get(envVarName);
}
}
1 change: 1 addition & 0 deletions api/src/modules/notifications/notifications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import { EmailModule } from './email/email.module';

@Module({
imports: [EmailModule],
exports: [EmailModule],
})
export class NotificationsModule {}
41 changes: 41 additions & 0 deletions api/test/auth/password-recovery.spec.ts
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);
});
});

0 comments on commit b7372ff

Please sign in to comment.