feat!: reset password with email

This commit is contained in:
Elias Schneider
2023-02-09 18:17:53 +01:00
parent 8ab359b71d
commit 5d1a7f0310
20 changed files with 459 additions and 156 deletions

View File

@@ -3,6 +3,7 @@ import {
Controller,
ForbiddenException,
HttpCode,
Param,
Patch,
Post,
Req,
@@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
import { TokenDTO } from "./dto/token.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
@@ -34,8 +36,8 @@ export class AuthController {
private config: ConfigService
) {}
@Throttle(10, 5 * 60)
@Post("signUp")
@Throttle(10, 5 * 60)
async signUp(
@Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response
@@ -54,8 +56,8 @@ export class AuthController {
return result;
}
@Throttle(10, 5 * 60)
@Post("signIn")
@Throttle(10, 5 * 60)
@HttpCode(200)
async signIn(
@Body() dto: AuthSignInDTO,
@@ -74,8 +76,8 @@ export class AuthController {
return result;
}
@Throttle(10, 5 * 60)
@Post("signIn/totp")
@Throttle(10, 5 * 60)
@HttpCode(200)
async signInTotp(
@Body() dto: AuthSignInTotpDTO,
@@ -92,6 +94,20 @@ export class AuthController {
return new TokenDTO().from(result);
}
@Post("resetPassword/:email")
@Throttle(5, 5 * 60)
@HttpCode(204)
async requestResetPassword(@Param("email") email: string) {
return await this.authService.requestResetPassword(email);
}
@Post("resetPassword")
@Throttle(5, 5 * 60)
@HttpCode(204)
async resetPassword(@Body() dto: ResetPasswordDTO) {
return await this.authService.resetPassword(dto.token, dto.password);
}
@Patch("password")
@UseGuards(JwtGuard)
async updatePassword(

View File

@@ -1,12 +1,13 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({
imports: [JwtModule.register({})],
imports: [JwtModule.register({}), EmailModule],
controllers: [AuthController],
providers: [AuthService, AuthTotpService, JwtStrategy],
exports: [AuthService],

View File

@@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2";
import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
@@ -19,7 +20,8 @@ export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private config: ConfigService
private config: ConfigService,
private emailService: EmailService
) {}
async signUp(dto: AuthRegisterDTO) {
@@ -87,6 +89,50 @@ export class AuthService {
return { accessToken, refreshToken };
}
async requestResetPassword(email: string) {
const user = await this.prisma.user.findFirst({
where: { email },
include: { resetPasswordToken: true },
});
if (!user) throw new BadRequestException("User not found");
// Delete old reset password token
if (user.resetPasswordToken) {
await this.prisma.resetPasswordToken.delete({
where: { token: user.resetPasswordToken.token },
});
}
const { token } = await this.prisma.resetPasswordToken.create({
data: {
expiresAt: moment().add(1, "hour").toDate(),
user: { connect: { id: user.id } },
},
});
await this.emailService.sendResetPasswordEmail(user.email, token);
}
async resetPassword(token: string, newPassword: string) {
const user = await this.prisma.user.findFirst({
where: { resetPasswordToken: { token } },
});
if (!user) throw new BadRequestException("Token invalid or expired");
const newPasswordHash = await argon.hash(newPassword);
await this.prisma.resetPasswordToken.delete({
where: { token },
});
await this.prisma.user.update({
where: { id: user.id },
data: { password: newPasswordHash },
});
}
async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password");

View File

@@ -6,10 +6,8 @@ import {
} from "@nestjs/common";
import { User } from "@prisma/client";
import * as argon from "argon2";
import * as crypto from "crypto";
import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@Injectable()
export class AuthTotpService {
constructor(
private config: ConfigService,
private prisma: PrismaService,
private authService: AuthService
) {}
@@ -57,9 +54,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not enabled");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
const expected = authenticator.generate(decryptedSecret);
const expected = authenticator.generate(totpSecret);
if (dto.totp !== expected) {
throw new BadRequestException("Invalid code");
@@ -81,41 +76,6 @@ export class AuthTotpService {
return { accessToken, refreshToken };
}
encryptTotpSecret(totpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
let encrypted = cipher.update(totpSecret);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return encrypted.toString("base64");
}
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
async enableTotp(user: User, password: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
@@ -132,7 +92,6 @@ export class AuthTotpService {
// TODO: Maybe make the issuer configurable with env vars?
const secret = authenticator.generateSecret();
const encryptedSecret = this.encryptTotpSecret(secret, password);
const otpURL = totp.keyuri(
user.username || user.email,
@@ -144,7 +103,7 @@ export class AuthTotpService {
where: { id: user.id },
data: {
totpEnabled: true,
totpSecret: encryptedSecret,
totpSecret: secret,
},
});
@@ -177,9 +136,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not in progress");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
const expected = authenticator.generate(decryptedSecret);
const expected = authenticator.generate(totpSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");
@@ -208,9 +165,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not enabled");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
const expected = authenticator.generate(decryptedSecret);
const expected = authenticator.generate(totpSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");

View File

@@ -0,0 +1,8 @@
import { PickType } from "@nestjs/swagger";
import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
@IsString()
token: string;
}