Skip to content

Commit

Permalink
feature(api): add password recovery service
Browse files Browse the repository at this point in the history
  • Loading branch information
Dyostiq committed Oct 20, 2021
1 parent 78a2c07 commit 24e2951
Show file tree
Hide file tree
Showing 20 changed files with 809 additions and 10 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ details.
Redis service should listen on the local machine
* `REDIS_COMMANDER_PORT` (number, required): the port on which the
Redis Commander service should listen on the local machine
* `SPARKPOST_APIKEY` (string, required): an API key to be used for Sparkpost,
an email service
* `SPARKPOST_ORIGIN` (string, required): an address of Sparkpost server, please check
Sparkpost's documentation for details
* `PASSWORD_RESET_TOKEN_PREFIX` (string, required): a prefix of an address to reset
a password should point to a reset password screen; the token for reset is appended at the end
* `PASSWORD_RESET_EXPIRATION` (string, optional, default is 30 minutes): a time that a token
for a password reset is valid

The PostgreSQL credentials are used to create a database user when the
PostgreSQL container is started for the first time. PostgreSQL data is persisted
Expand Down
8 changes: 8 additions & 0 deletions api/apps/api/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,13 @@
"limits": {
"shapefileMaxSize": "API_UPLOAD_SHAPEFILE_MAX_SIZE"
}
},
"sparkpost": {
"apikey": "SPARKPOST_APIKEY",
"origin": "SPARKPOST_ORIGIN"
},
"passwordReset": {
"tokenPrefix": "PASSWORD_RESET_TOKEN_PREFIX",
"expiration": "PASSWORD_RESET_EXPIRATION"
}
}
8 changes: 8 additions & 0 deletions api/apps/api/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
"limits": {
"shapefileMaxSize": 10240000
}
},
"sparkpost": {
"apikey": "invalidSparkpostApikey",
"origin": "https://api.eu.sparkpost.com:443"
},
"passwordReset": {
"tokenPrefix": "http://localhost:3000/auth/reset-password?token=",
"expiration": 1800000
}
}

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"`);
}
}
53 changes: 50 additions & 3 deletions api/apps/api/src/modules/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,69 @@ import { AuthenticationService } from './authentication.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { ApiEventsModule } from '@marxan-api/modules/api-events/api-events.module';
import {
Mailer,
passwordResetPrefixProvider,
SparkPostMailer,
sparkPostProvider,
} from './password-recovery/mailer';
import {
UserService,
UserServiceAdapter,
} from './password-recovery/user.service';
import {
CryptoTokenFactory,
expirationOffsetProvider,
TokenFactory,
} from './password-recovery/token.factory';
import {
RecoveryTokenRepository,
TypeORMRecoveryTokenRepository,
} from './password-recovery/recovery-token.repository';
import { PasswordRecoveryService } from './password-recovery/password-recovery.service';
import { PasswordRecoveryToken } from './password-recovery/password-recovery-token.api.entity';
import { PasswordRecoveryController } from './password-recovery/password-recovery.controller';

export const logger = new Logger('Authentication');

@Module({
imports: [
UsersModule,
ApiEventsModule,
forwardRef(() => UsersModule),
PassportModule,
JwtModule.register({
secret: AppConfig.get('auth.jwt.secret'),
signOptions: { expiresIn: AppConfig.get('auth.jwt.expiresIn', '2h') },
}),
TypeOrmModule.forFeature([User, IssuedAuthnToken]),
TypeOrmModule.forFeature([User, IssuedAuthnToken, PasswordRecoveryToken]),
],
providers: [AuthenticationService, LocalStrategy, JwtStrategy],
controllers: [AuthenticationController],
providers: [
AuthenticationService,
LocalStrategy,
JwtStrategy,
passwordResetPrefixProvider,
sparkPostProvider,
{
provide: Mailer,
useClass: SparkPostMailer,
},
{
provide: UserService,
useClass: UserServiceAdapter,
},
expirationOffsetProvider,
{
provide: TokenFactory,
useClass: CryptoTokenFactory,
},
{
provide: RecoveryTokenRepository,
useClass: TypeORMRecoveryTokenRepository,
},
PasswordRecoveryService,
],
controllers: [AuthenticationController, PasswordRecoveryController],
exports: [AuthenticationService],
})
export class AuthenticationModule {}
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}`,
);
}
}
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;
}
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,
);
}
}
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
);
}
}
Loading

0 comments on commit 24e2951

Please sign in to comment.