Skip to content
This repository has been archived by the owner on Apr 19, 2023. It is now read-only.

Commit

Permalink
✨ Add password forgot/reset
Browse files Browse the repository at this point in the history
  • Loading branch information
AnandChowdhary committed Oct 29, 2020
1 parent 6eb1e7e commit f8f47f2
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 10 deletions.
32 changes: 32 additions & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { users } from '@prisma/client';
import { RateLimit } from 'nestjs-rate-limiter';
import { Expose } from 'src/modules/prisma/prisma.interface';
import {
ForgotPasswordDto,
LoginDto,
RegisterDto,
ResendEmailVerificationDto,
ResetPasswordDto,
TotpLoginDto,
} from './auth.dto';
import { AuthService } from './auth.service';
Expand Down Expand Up @@ -80,6 +82,36 @@ export class AuthController {
return this.authService.sendEmailVerification(data.email, true);
}

@Post('forgot-password')
@RateLimit({
points: 10,
duration: 60,
errorMessage: 'Wait for 60 seconds before resetting another password',
})
async forgotPassword(@Body() data: ForgotPasswordDto) {
return this.authService.requestPasswordReset(data.email);
}

@Post('reset-password')
@RateLimit({
points: 10,
duration: 60,
errorMessage: 'Wait for 60 seconds before resetting another password',
})
async resetPassword(
@Ip() ip: string,
@Headers('User-Agent') userAgent: string,
@Body() data: ResetPasswordDto,
) {
return this.authService.resetPassword(
ip,
userAgent,
data.token,
data.password,
data.ignorePwnedPassword,
);
}

@Post('totp-login')
@RateLimit({
points: 10,
Expand Down
21 changes: 21 additions & 0 deletions src/modules/auth/auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ export class ResendEmailVerificationDto {
email: string;
}

export class ForgotPasswordDto {
@IsEmail()
@IsNotEmpty()
email: string;
}

export class ResetPasswordDto {
@IsString()
@IsNotEmpty()
token: string;

@IsString()
@MinLength(8)
@IsNotEmpty()
password: string;

@IsBoolean()
@IsOptional()
ignorePwnedPassword?: boolean;
}

export class LoginDto {
@IsEmail()
@IsNotEmpty()
Expand Down
67 changes: 65 additions & 2 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import { EmailService } from '../email/email.service';
import { Expose } from '../prisma/prisma.interface';
import { PrismaService } from '../prisma/prisma.service';
import { PwnedService } from '../pwned/pwned.service';
import { TWO_FACTOR_TOKEN } from '../tokens/tokens.constants';
import {
TWO_FACTOR_TOKEN,
PASSWORD_RESET_TOKEN,
EMAIL_VERIFY_TOKEN,
} from '../tokens/tokens.constants';
import { TokensService } from '../tokens/tokens.service';
import { RegisterDto } from './auth.dto';
import { AccessTokenClaims } from './auth.interface';
Expand Down Expand Up @@ -78,6 +82,15 @@ export class AuthService {

async register(data: RegisterDto): Promise<Expose<users>> {
const email = data.email;
data.name = data.name
.split(' ')
.map((word, index) =>
index === 0 || index === data.name.split(' ').length
? (word.charAt(0) ?? '').toUpperCase() +
(word.slice(1) ?? '').toLowerCase()
: word,
)
.join(' ');
const emailSafe = safeEmail(email);
const ignorePwnedPassword = !!data.ignorePwnedPassword;
delete data.email;
Expand Down Expand Up @@ -131,9 +144,14 @@ export class AuthService {
: 'auth/email-verification',
data: {
name: emailDetails.user.name,
days: 7,
link: `${this.configService.get<string>(
'frontendUrl',
)}/auth/verify-email?token=`,
)}/auth/verify-email?token=${this.tokensService.signJwt(
EMAIL_VERIFY_TOKEN,
emailDetails.user.id,
'7d',
)}`,
},
});
return { queued: true };
Expand Down Expand Up @@ -220,6 +238,51 @@ export class AuthService {
return this.loginUserWithTotpCode(ipAddress, userAgent, id, code);
}

async requestPasswordReset(email: string) {
const emailSafe = safeEmail(email);
const emailDetails = await this.prisma.emails.findFirst({
where: { emailSafe },
include: { user: true },
});
if (!emailDetails)
throw new HttpException(
'There is no user for this email',
HttpStatus.NOT_FOUND,
);
this.email.send({
to: `"${emailDetails.user.name}" <${email}>`,
template: 'auth/password-reset',
data: {
name: emailDetails.user.name,
minutes: 30,
link: `${this.configService.get<string>(
'frontendUrl',
)}/auth/reset-password?token=${this.tokensService.signJwt(
PASSWORD_RESET_TOKEN,
emailDetails.user.id,
'30m',
)}`,
},
});
return { queued: true };
}

async resetPassword(
ipAddress: string,
userAgent: string,
token: string,
password: string,
ignorePwnedPassword?: boolean,
) {
const id = this.tokensService.verify<number>(PASSWORD_RESET_TOKEN, token);
password = await this.hashAndValidatePassword(
password,
!!ignorePwnedPassword,
);
await this.prisma.users.update({ where: { id }, data: { password } });
return this.loginResponse(ipAddress, userAgent, id);
}

private async loginUserWithTotpCode(
ipAddress: string,
userAgent: string,
Expand Down
2 changes: 2 additions & 0 deletions src/modules/tokens/tokens.constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const TWO_FACTOR_TOKEN = 'TWO_FACTOR_TOKEN';
export const PASSWORD_RESET_TOKEN = 'PASSWORD_RESET_TOKEN';
export const EMAIL_VERIFY_TOKEN = 'EMAIL_VERIFY_TOKEN';
19 changes: 12 additions & 7 deletions src/modules/tokens/tokens.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { v4 } from 'uuid';
import {
Expand All @@ -16,10 +16,11 @@ export class TokensService {

signJwt(
subject: string,
payload: string | object | Buffer,
payload: number | string | object | Buffer,
expiresIn?: string,
options?: SignOptions,
) {
if (typeof payload === 'number') payload = payload.toString();
return sign(payload, this.configService.get<string>('security.jwtSecret'), {
...options,
subject,
Expand All @@ -28,11 +29,15 @@ export class TokensService {
}

verify<T>(subject: string, token: string, options?: VerifyOptions) {
return (verify(
token,
this.configService.get<string>('security.jwtSecret'),
{ ...options, subject },
) as any) as T;
try {
return (verify(
token,
this.configService.get<string>('security.jwtSecret'),
{ ...options, subject },
) as any) as T;
} catch (error) {
throw new UnauthorizedException('This token is invalid');
}
}

decode<T>(token: string, options?: DecodeOptions) {
Expand Down
9 changes: 9 additions & 0 deletions src/templates/auth/password-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Reset your password

Hi {{name}},

Someone (hopefully you) requested a link to reset your password, so here you go.

<a href="{{ link }}" class="btn btn-primary">Reset my password</a>

Note that this link is valid for {{ minutes }} minutes only. If you didn't request this email, you can just ignore it; we won't give anyone else access to your account.
2 changes: 1 addition & 1 deletion src/templates/auth/resend-email-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ Someone (hopefully you) requested to another link to confirm your email, so here

<a href="{{ link }}" class="btn btn-primary">Verify my email</a>

If you didn't request this email, you can just ignore it.
Note that this link is valid for {{ days }} days only. If you didn't request this email, you can just ignore it.

0 comments on commit f8f47f2

Please sign in to comment.