-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature(api): add password recovery service
- Loading branch information
Showing
20 changed files
with
809 additions
and
10 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
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
19 changes: 19 additions & 0 deletions
19
api/apps/api/src/migrations/api/1634127966328-AddPasswordRecoveryTokens.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,19 @@ | ||
import { QueryRunner } from 'typeorm'; | ||
|
||
export class AddPasswordRecoveryTokens1634127966328 { | ||
public async up(queryRunner: QueryRunner): Promise<void> { | ||
await queryRunner.query( | ||
` | ||
CREATE TABLE password_recovery_tokens ( | ||
user_id uuid NOT NULL PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, | ||
created_at TIMESTAMP NOT NULL, | ||
expired_at TIMESTAMP NOT NULL, | ||
value character varying NOT NULL); | ||
CREATE INDEX password_recovery_tokens_value_idx ON password_recovery_tokens(value);`, | ||
); | ||
} | ||
|
||
public async down(queryRunner: QueryRunner): Promise<void> { | ||
await queryRunner.query(`DROP TABLE "password_recovery_tokens"`); | ||
} | ||
} |
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
89 changes: 89 additions & 0 deletions
89
api/apps/api/src/modules/authentication/password-recovery/mailer.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,89 @@ | ||
import { FactoryProvider, Inject, Injectable, Logger } from '@nestjs/common'; | ||
import * as Sparkpost from 'sparkpost'; | ||
import { CreateTransmission, Recipient } from 'sparkpost'; | ||
import { AppConfig } from '@marxan-api/utils/config.utils'; | ||
import { UsersService } from '@marxan-api/modules/users/users.service'; | ||
|
||
export abstract class Mailer { | ||
abstract sendRecoveryEmail(userId: string, token: string): Promise<void>; | ||
|
||
abstract sendPasswordChangedConfirmation(userId: string): Promise<void>; | ||
} | ||
|
||
export const sparkPostProvider: FactoryProvider<Sparkpost> = { | ||
provide: Sparkpost, | ||
useFactory: () => { | ||
const apikey = AppConfig.get<string>('sparkpost.apikey'); | ||
const origin = AppConfig.get<string>('sparkpost.origin'); | ||
return new Sparkpost(apikey, { | ||
origin, | ||
}); | ||
}, | ||
}; | ||
|
||
const passwordResetPrefixToken = Symbol('password reset prefix token'); | ||
export const passwordResetPrefixProvider: FactoryProvider<string> = { | ||
provide: passwordResetPrefixToken, | ||
useFactory: () => { | ||
const prefix = AppConfig.get<string>('passwordReset.tokenPrefix'); | ||
return prefix; | ||
}, | ||
}; | ||
|
||
@Injectable() | ||
export class SparkPostMailer implements Mailer { | ||
private readonly logger = new Logger(this.constructor.name); | ||
|
||
constructor( | ||
private readonly sparkpost: Sparkpost, | ||
private readonly usersService: UsersService, | ||
@Inject(passwordResetPrefixToken) | ||
private readonly passwordResetPrefix: string, | ||
) {} | ||
async sendPasswordChangedConfirmation(userId: string): Promise<void> { | ||
const user = await this.usersService.getById(userId); | ||
const recipient: Recipient = { | ||
address: user.email, | ||
}; | ||
const transmission: CreateTransmission = { | ||
recipients: [recipient], | ||
content: { | ||
template_id: 'confirmation-password-changed', | ||
}, | ||
}; | ||
const result = await this.sparkpost.transmissions.send(transmission); | ||
this.logger.log(result); | ||
} | ||
|
||
async sendRecoveryEmail(userId: string, token: string): Promise<void> { | ||
const user = await this.usersService.getById(userId); | ||
const recipient: Recipient = { | ||
substitution_data: { | ||
urlRecover: this.passwordResetPrefix + token, | ||
}, | ||
address: user.email, | ||
}; | ||
const transmission: CreateTransmission = { | ||
recipients: [recipient], | ||
content: { | ||
template_id: 'marxan-reset-password', | ||
}, | ||
}; | ||
const result = await this.sparkpost.transmissions.send(transmission); | ||
this.logger.log(result); | ||
} | ||
} | ||
|
||
export class ConsoleMailer implements Mailer { | ||
private logger = new Logger(this.constructor.name); | ||
|
||
async sendPasswordChangedConfirmation(userId: string): Promise<void> { | ||
this.logger.log(`sending password changed confirmation to user ${userId}`); | ||
} | ||
|
||
async sendRecoveryEmail(userId: string, token: string): Promise<void> { | ||
this.logger.log( | ||
`sending recovery email to user ${userId} with token ${token}`, | ||
); | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
...ps/api/src/modules/authentication/password-recovery/password-recovery-token.api.entity.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,19 @@ | ||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; | ||
|
||
@Entity(`password_recovery_tokens`) | ||
export class PasswordRecoveryToken { | ||
@PrimaryColumn(`uuid`, { | ||
name: `user_id`, | ||
}) | ||
userId!: string; | ||
|
||
@Column({ name: `created_at` }) | ||
createdAt!: Date; | ||
|
||
@Column({ name: `expired_at` }) | ||
expiredAt!: Date; | ||
|
||
@Column() | ||
@Index() | ||
value!: string; | ||
} |
76 changes: 76 additions & 0 deletions
76
api/apps/api/src/modules/authentication/password-recovery/password-recovery.controller.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,76 @@ | ||
import { apiGlobalPrefixes } from '@marxan-api/api.config'; | ||
import { | ||
Body, | ||
Controller, | ||
Post, | ||
Headers, | ||
UnauthorizedException, | ||
Patch, | ||
} from '@nestjs/common'; | ||
import { | ||
ApiHeader, | ||
ApiNoContentResponse, | ||
ApiOperation, | ||
ApiProperty, | ||
ApiTags, | ||
} from '@nestjs/swagger'; | ||
import { IsEmail } from 'class-validator'; | ||
import { IsPassword } from '@marxan-api/modules/users/dto/is-password.decorator'; | ||
import { PasswordRecoveryService } from './password-recovery.service'; | ||
|
||
class PasswordRecoveryRequestDto { | ||
@IsEmail() | ||
@ApiProperty() | ||
email!: string; | ||
} | ||
|
||
class SetPasswordDto { | ||
@ApiProperty() | ||
@IsPassword() | ||
passwordConfirm!: string; | ||
} | ||
|
||
@Controller(`${apiGlobalPrefixes.v1}/users/me`) | ||
@ApiTags('Authentication') | ||
export class PasswordRecoveryController { | ||
constructor( | ||
private readonly passwordRecoveryService: PasswordRecoveryService, | ||
) {} | ||
|
||
@Post('recover-password') | ||
@ApiOperation({ | ||
description: `Request a password recovery for a user. Triggers sending an email to the user with a reset token.`, | ||
}) | ||
@ApiNoContentResponse() | ||
requestRecovery(@Body() dto: PasswordRecoveryRequestDto) { | ||
// DO NOT await to prevent user enumeration | ||
void this.passwordRecoveryService.resetPassword(dto.email); | ||
} | ||
|
||
@Patch('reset-password') | ||
@ApiOperation({ | ||
description: `Sets a new password for a user using a single use token`, | ||
}) | ||
@ApiNoContentResponse() | ||
@ApiHeader({ | ||
name: `Authorization`, | ||
example: `Bearer e5c468398038c3930f60a26b5ac66ea987203f5df2f390c13867411fc073bef3`, | ||
description: `An header with a token got by a user in the recovery mail`, | ||
required: true, | ||
}) | ||
async setPassword( | ||
@Body() dto: SetPasswordDto, | ||
@Headers('Authorization') authHeader: unknown, | ||
) { | ||
if ( | ||
typeof authHeader !== 'string' || | ||
!authHeader.toLowerCase().startsWith('bearer ') | ||
) | ||
throw new UnauthorizedException(); | ||
const token = authHeader.substr(7, authHeader.length); | ||
await this.passwordRecoveryService.changePassword( | ||
token, | ||
dto.passwordConfirm, | ||
); | ||
} | ||
} |
64 changes: 64 additions & 0 deletions
64
api/apps/api/src/modules/authentication/password-recovery/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,64 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { Either, left, right } from 'fp-ts/Either'; | ||
import { isDefined } from '@marxan/utils'; | ||
import { UserService } from './user.service'; | ||
import { Mailer } from './mailer'; | ||
import { TokenFactory } from './token.factory'; | ||
import { Token } from './token'; | ||
import { RecoveryTokenRepository } from './recovery-token.repository'; | ||
|
||
export const tokenInvalid = Symbol('token invalid'); | ||
type TokenInvalid = typeof tokenInvalid; | ||
|
||
@Injectable() | ||
export class PasswordRecoveryService { | ||
constructor( | ||
private readonly mailer: Mailer, | ||
private readonly tokenFactory: TokenFactory, | ||
private readonly users: UserService, | ||
private readonly tokens: RecoveryTokenRepository, | ||
) {} | ||
|
||
async resetPassword(email: string): Promise<void> { | ||
const userId = await this.users.findUserId(email); | ||
if (!userId) { | ||
return; | ||
} | ||
const token = await this.tokenFactory.create(userId); | ||
await this.tokens.save(token); | ||
await this.mailer.sendRecoveryEmail(userId, token.value); | ||
} | ||
|
||
async changePassword( | ||
resetToken: string, | ||
newPassword: string, | ||
currentDate: Date = new Date(), | ||
): Promise<Either<TokenInvalid, void>> { | ||
const token = await this.tokens.findAndDeleteToken(resetToken); | ||
if (!this.isTokenValid(token, resetToken)) { | ||
return left(tokenInvalid); | ||
} | ||
if (this.isTokenExpired(currentDate, token)) { | ||
return left(tokenInvalid); | ||
} | ||
const userId = token.userId; | ||
|
||
await this.users.setUserPassword(userId, newPassword); | ||
await this.users.logoutUser(userId); | ||
await this.mailer.sendPasswordChangedConfirmation(userId); | ||
return right(void 0); | ||
} | ||
|
||
private isTokenExpired(currentDate: Date, token: Token) { | ||
return currentDate >= token.expiredAt; | ||
} | ||
|
||
private isTokenValid( | ||
token: Token | undefined, | ||
resetToken: string, | ||
): token is Token { | ||
return ( | ||
isDefined(token) && isDefined(token.value) && token.value === resetToken | ||
); | ||
} | ||
} |
Oops, something went wrong.