diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index cb843aae2..594577e21 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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'; @@ -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, diff --git a/src/modules/auth/auth.dto.ts b/src/modules/auth/auth.dto.ts index fff210f13..dde4c5fe0 100644 --- a/src/modules/auth/auth.dto.ts +++ b/src/modules/auth/auth.dto.ts @@ -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() diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index c797912be..655b59fff 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -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'; @@ -78,6 +82,15 @@ export class AuthService { async register(data: RegisterDto): Promise> { 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; @@ -131,9 +144,14 @@ export class AuthService { : 'auth/email-verification', data: { name: emailDetails.user.name, + days: 7, link: `${this.configService.get( 'frontendUrl', - )}/auth/verify-email?token=`, + )}/auth/verify-email?token=${this.tokensService.signJwt( + EMAIL_VERIFY_TOKEN, + emailDetails.user.id, + '7d', + )}`, }, }); return { queued: true }; @@ -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( + '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(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, diff --git a/src/modules/tokens/tokens.constants.ts b/src/modules/tokens/tokens.constants.ts index 25737c5c7..1d5242165 100644 --- a/src/modules/tokens/tokens.constants.ts +++ b/src/modules/tokens/tokens.constants.ts @@ -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'; diff --git a/src/modules/tokens/tokens.service.ts b/src/modules/tokens/tokens.service.ts index 77d4315cb..7773211d2 100644 --- a/src/modules/tokens/tokens.service.ts +++ b/src/modules/tokens/tokens.service.ts @@ -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 { @@ -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('security.jwtSecret'), { ...options, subject, @@ -28,11 +29,15 @@ export class TokensService { } verify(subject: string, token: string, options?: VerifyOptions) { - return (verify( - token, - this.configService.get('security.jwtSecret'), - { ...options, subject }, - ) as any) as T; + try { + return (verify( + token, + this.configService.get('security.jwtSecret'), + { ...options, subject }, + ) as any) as T; + } catch (error) { + throw new UnauthorizedException('This token is invalid'); + } } decode(token: string, options?: DecodeOptions) { diff --git a/src/templates/auth/password-reset.md b/src/templates/auth/password-reset.md new file mode 100644 index 000000000..2698ac60f --- /dev/null +++ b/src/templates/auth/password-reset.md @@ -0,0 +1,9 @@ +# Reset your password + +Hi {{name}}, + +Someone (hopefully you) requested a link to reset your password, so here you go. + +Reset my password + +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. diff --git a/src/templates/auth/resend-email-verification.md b/src/templates/auth/resend-email-verification.md index eaf3d910d..ccad33c8b 100644 --- a/src/templates/auth/resend-email-verification.md +++ b/src/templates/auth/resend-email-verification.md @@ -6,4 +6,4 @@ Someone (hopefully you) requested to another link to confirm your email, so here Verify my email -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.