Files
Reactive-Resume/apps/server/src/auth/auth.service.ts
T
2023-11-10 09:07:47 +01:00

322 lines
10 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { LoginDto, RegisterDto } from "@reactive-resume/dto";
import * as bcryptjs from "bcryptjs";
import { randomBytes } from "crypto";
import { authenticator } from "otplib";
import { Config } from "../config/schema";
import { ErrorMessage } from "../constants/error-message";
import { MailService } from "../mail/mail.service";
import { UserService } from "../user/user.service";
import { UtilsService } from "../utils/utils.service";
import { Payload } from "./utils/payload";
@Injectable()
export class AuthService {
constructor(
private readonly configService: ConfigService<Config>,
private readonly userService: UserService,
private readonly mailService: MailService,
private readonly jwtService: JwtService,
private readonly utils: UtilsService,
) {}
private hash(password: string): Promise<string> {
return bcryptjs.hash(password, 10);
}
private compare(password: string, hash: string): Promise<boolean> {
return bcryptjs.compare(password, hash);
}
private async validatePassword(password: string, hashedPassword: string) {
const isValid = await this.compare(password, hashedPassword);
if (!isValid) {
throw new BadRequestException(ErrorMessage.InvalidCredentials);
}
}
generateToken(grantType: "access" | "refresh" | "reset" | "verification", payload?: Payload) {
switch (grantType) {
case "access":
if (!payload) throw new InternalServerErrorException("InvalidTokenPayload");
return this.jwtService.sign(payload, {
secret: this.configService.getOrThrow("ACCESS_TOKEN_SECRET"),
expiresIn: "15m", // 15 minutes
});
case "refresh":
if (!payload) throw new InternalServerErrorException("InvalidTokenPayload");
return this.jwtService.sign(payload, {
secret: this.configService.getOrThrow("REFRESH_TOKEN_SECRET"),
expiresIn: "2d", // 2 days
});
case "reset":
case "verification":
return randomBytes(32).toString("base64url");
default:
throw new InternalServerErrorException("InvalidGrantType: " + grantType);
}
}
async setRefreshToken(email: string, token: string | null) {
await this.userService.updateByEmail(email, {
secrets: {
update: {
refreshToken: token,
lastSignedIn: token ? new Date() : undefined,
},
},
});
}
async validateRefreshToken(payload: Payload, token: string) {
const user = await this.userService.findOneById(payload.id);
const storedRefreshToken = user.secrets?.refreshToken;
if (!storedRefreshToken || storedRefreshToken !== token) throw new ForbiddenException();
if (!user.twoFactorEnabled) return user;
if (payload.isTwoFactorAuth) return user;
}
async register(registerDto: RegisterDto) {
const hashedPassword = await this.hash(registerDto.password);
try {
const user = await this.userService.create({
name: registerDto.name,
email: registerDto.email,
username: registerDto.username,
locale: registerDto.locale,
provider: "email",
emailVerified: false, // Set to true if you don't want to verify user's email
secrets: { create: { password: hashedPassword } },
});
// Do not `await` this function, otherwise the user will have to wait for the email to be sent before the response is returned
this.sendVerificationEmail(user.email);
return user;
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
}
throw new InternalServerErrorException(error);
}
}
async authenticate({ identifier, password }: LoginDto) {
try {
const user = await this.userService.findOneByIdentifier(identifier);
if (!user) {
throw new BadRequestException(ErrorMessage.InvalidCredentials);
}
if (!user.secrets?.password) {
throw new BadRequestException(ErrorMessage.OAuthUser);
}
await this.validatePassword(password, user.secrets?.password);
return user;
} catch (error) {
throw new BadRequestException(ErrorMessage.InvalidCredentials);
}
}
// Password Reset Flows
async forgotPassword(email: string) {
const token = this.generateToken("reset");
await this.userService.updateByEmail(email, {
secrets: { update: { resetToken: token } },
});
const url = `${this.utils.getUrl()}/auth/reset-password?token=${token}`;
const subject = "Reset your Reactive Resume password";
const text = `Please click on the link below to reset your password:\n\n${url}`;
await this.mailService.sendEmail({ to: email, subject, text });
return;
}
async updatePassword(email: string, password: string) {
const hashedPassword = await this.hash(password);
await this.userService.updateByEmail(email, {
secrets: { update: { password: hashedPassword } },
});
}
async resetPassword(token: string, password: string) {
const hashedPassword = await this.hash(password);
await this.userService.updateByResetToken(token, {
resetToken: null,
password: hashedPassword,
});
}
// Email Verification Flows
async sendVerificationEmail(email: string) {
try {
const token = this.generateToken("verification");
// Set the verification token in the database
await this.userService.updateByEmail(email, {
secrets: { update: { verificationToken: token } },
});
const url = `${this.utils.getUrl()}/auth/verify-email?token=${token}`;
const subject = "Verify your email address";
const text = `Please verify your email address by clicking on the link below:\n\n${url}`;
await this.mailService.sendEmail({ to: email, subject, text });
return;
} catch (error) {
throw new InternalServerErrorException(error);
}
}
async verifyEmail(id: string, token: string) {
const user = await this.userService.findOneById(id);
const storedToken = user.secrets?.verificationToken;
if (!storedToken || storedToken !== token) {
throw new BadRequestException(ErrorMessage.InvalidVerificationToken);
}
await this.userService.updateByEmail(user.email, {
emailVerified: true,
secrets: { update: { verificationToken: null } },
});
}
// Two Factor Authentication Flows
async setup2FASecret(email: string) {
// If the user already has 2FA enabled, throw an error
const user = await this.userService.findOneByIdentifier(email);
if (user.twoFactorEnabled) {
throw new BadRequestException(ErrorMessage.TwoFactorAlreadyEnabled);
}
const secret = authenticator.generateSecret();
const uri = authenticator.keyuri(email, "Reactive Resume", secret);
await this.userService.updateByEmail(email, {
secrets: { update: { twoFactorSecret: secret } },
});
return { message: uri };
}
async enable2FA(email: string, code: string) {
const user = await this.userService.findOneByIdentifier(email);
// If the user already has 2FA enabled, throw an error
if (user.twoFactorEnabled) {
throw new BadRequestException(ErrorMessage.TwoFactorAlreadyEnabled);
}
// If the user doesn't have a 2FA secret set, throw an error
if (!user.secrets?.twoFactorSecret) {
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
}
const verified = authenticator.verify({
secret: user.secrets?.twoFactorSecret,
token: code,
});
if (!verified) {
throw new BadRequestException(ErrorMessage.InvalidTwoFactorCode);
}
// Create backup codes and store them in the database
const backupCodes = Array.from({ length: 8 }, () => randomBytes(5).toString("hex"));
await this.userService.updateByEmail(email, {
twoFactorEnabled: true,
secrets: { update: { twoFactorBackupCodes: backupCodes } },
});
return { backupCodes };
}
async disable2FA(email: string) {
const user = await this.userService.findOneByIdentifier(email);
// If the user doesn't have 2FA enabled, throw an error
if (!user.twoFactorEnabled) {
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
}
await this.userService.updateByEmail(email, {
twoFactorEnabled: false,
secrets: { update: { twoFactorSecret: null, twoFactorBackupCodes: [] } },
});
}
async verify2FACode(email: string, code: string) {
const user = await this.userService.findOneByIdentifier(email);
// If the user doesn't have 2FA enabled, or does not have a 2FA secret set, throw an error
if (!user.twoFactorEnabled || !user.secrets?.twoFactorSecret) {
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
}
const verified = authenticator.verify({
secret: user.secrets?.twoFactorSecret,
token: code,
});
if (!verified) {
throw new BadRequestException(ErrorMessage.InvalidTwoFactorCode);
}
return user;
}
async useBackup2FACode(email: string, code: string) {
const user = await this.userService.findOneByIdentifier(email);
// If the user doesn't have 2FA enabled, or does not have a 2FA secret set, throw an error
if (!user.twoFactorEnabled || !user.secrets?.twoFactorSecret) {
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
}
const verified = user.secrets?.twoFactorBackupCodes.includes(code);
if (!verified) {
throw new BadRequestException(ErrorMessage.InvalidTwoFactorBackupCode);
}
// Remove the used backup code from the database
const backupCodes = user.secrets?.twoFactorBackupCodes.filter((c) => c !== code);
await this.userService.updateByEmail(email, {
secrets: { update: { twoFactorBackupCodes: backupCodes } },
});
return user;
}
}