mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-18 10:41:56 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
320
apps/server/src/auth/auth.controller.ts
Normal file
320
apps/server/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,320 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
InternalServerErrorException,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
authResponseSchema,
|
||||
backupCodesSchema,
|
||||
ForgotPasswordDto,
|
||||
MessageDto,
|
||||
messageSchema,
|
||||
RegisterDto,
|
||||
ResetPasswordDto,
|
||||
TwoFactorBackupDto,
|
||||
TwoFactorDto,
|
||||
UpdatePasswordDto,
|
||||
UserDto,
|
||||
userSchema,
|
||||
UserWithSecrets,
|
||||
} from "@reactive-resume/dto";
|
||||
import type { Response } from "express";
|
||||
import { ZodSerializerDto } from "nestjs-zod";
|
||||
|
||||
import { ErrorMessage } from "../constants/error-message";
|
||||
import { User } from "../user/decorators/user.decorator";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { GitHubGuard } from "./guards/github.guard";
|
||||
import { GoogleGuard } from "./guards/google.guard";
|
||||
import { JwtGuard } from "./guards/jwt.guard";
|
||||
import { LocalGuard } from "./guards/local.guard";
|
||||
import { RefreshGuard } from "./guards/refresh.guard";
|
||||
import { TwoFactorGuard } from "./guards/two-factor.guard";
|
||||
import { getCookieOptions } from "./utils/cookie";
|
||||
import { payloadSchema } from "./utils/payload";
|
||||
|
||||
@ApiTags("Authentication")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
|
||||
private async exchangeToken(id: string, email: string, isTwoFactorAuth: boolean = false) {
|
||||
try {
|
||||
const payload = payloadSchema.parse({ id, isTwoFactorAuth });
|
||||
|
||||
const accessToken = this.authService.generateToken("access", payload);
|
||||
const refreshToken = this.authService.generateToken("refresh", payload);
|
||||
|
||||
// Set Refresh Token in Database
|
||||
await this.authService.setRefreshToken(email, refreshToken);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(error, ErrorMessage.SomethingWentWrong);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthenticationResponse(
|
||||
user: UserWithSecrets,
|
||||
response: Response,
|
||||
isTwoFactorAuth: boolean = false,
|
||||
redirect: boolean = false,
|
||||
) {
|
||||
let status = "authenticated";
|
||||
|
||||
const redirectUrl = new URL(`${this.utils.getUrl()}/auth/callback`);
|
||||
|
||||
const { accessToken, refreshToken } = await this.exchangeToken(
|
||||
user.id,
|
||||
user.email,
|
||||
isTwoFactorAuth,
|
||||
);
|
||||
|
||||
response.cookie("Authentication", accessToken, getCookieOptions("access"));
|
||||
response.cookie("Refresh", refreshToken, getCookieOptions("refresh"));
|
||||
|
||||
if (user.twoFactorEnabled && !isTwoFactorAuth) status = "2fa_required";
|
||||
|
||||
const responseData = authResponseSchema.parse({ status, user });
|
||||
|
||||
redirectUrl.searchParams.set("status", status);
|
||||
|
||||
if (redirect) response.redirect(redirectUrl.toString());
|
||||
else response.status(200).send(responseData);
|
||||
}
|
||||
|
||||
@Post("register")
|
||||
async register(@Body() registerDto: RegisterDto, @Res({ passthrough: true }) response: Response) {
|
||||
const user = await this.authService.register(registerDto);
|
||||
|
||||
return this.handleAuthenticationResponse(user, response);
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
@UseGuards(LocalGuard)
|
||||
async login(@User() user: UserWithSecrets, @Res({ passthrough: true }) response: Response) {
|
||||
return this.handleAuthenticationResponse(user, response);
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "GitHub")
|
||||
@Get("github")
|
||||
@UseGuards(GitHubGuard)
|
||||
githubLogin() {
|
||||
return;
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "GitHub")
|
||||
@Get("github/callback")
|
||||
@UseGuards(GitHubGuard)
|
||||
async githubCallback(
|
||||
@User() user: UserWithSecrets,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.handleAuthenticationResponse(user, response, false, true);
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "Google")
|
||||
@Get("google")
|
||||
@UseGuards(GoogleGuard)
|
||||
googleLogin() {
|
||||
return;
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "Google")
|
||||
@Get("google/callback")
|
||||
@UseGuards(GoogleGuard)
|
||||
async googleCallback(
|
||||
@User() user: UserWithSecrets,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.handleAuthenticationResponse(user, response, false, true);
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@UseGuards(RefreshGuard)
|
||||
async refresh(@User() user: UserWithSecrets, @Res({ passthrough: true }) response: Response) {
|
||||
return this.handleAuthenticationResponse(user, response, true);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async updatePassword(@User("email") email: string, @Body() { password }: UpdatePasswordDto) {
|
||||
await this.authService.updatePassword(email, password);
|
||||
|
||||
return { message: "Your password has been successfully updated." };
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
async logout(@User() user: UserWithSecrets, @Res({ passthrough: true }) response: Response) {
|
||||
await this.authService.setRefreshToken(user.email, null);
|
||||
|
||||
response.clearCookie("Authentication");
|
||||
response.clearCookie("Refresh");
|
||||
|
||||
const data = messageSchema.parse({ message: "You have been logged out, tschüss!" });
|
||||
response.status(200).send(data);
|
||||
}
|
||||
|
||||
// Two Factor Authentication Flows
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@Post("2fa/setup")
|
||||
@UseGuards(JwtGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async setup2FASecret(@User("email") email: string) {
|
||||
return this.authService.setup2FASecret(email);
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/enable")
|
||||
@UseGuards(JwtGuard)
|
||||
async enable2FA(
|
||||
@User("id") id: string,
|
||||
@User("email") email: string,
|
||||
@Body() { code }: TwoFactorDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const { backupCodes } = await this.authService.enable2FA(email, code);
|
||||
|
||||
const { accessToken, refreshToken } = await this.exchangeToken(id, email, true);
|
||||
|
||||
response.cookie("Authentication", accessToken, getCookieOptions("access"));
|
||||
response.cookie("Refresh", refreshToken, getCookieOptions("refresh"));
|
||||
|
||||
const data = backupCodesSchema.parse({ backupCodes });
|
||||
response.status(200).send(data);
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/disable")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async disable2FA(@User("email") email: string) {
|
||||
await this.authService.disable2FA(email);
|
||||
|
||||
return { message: "Two-factor authentication has been successfully disabled on your account." };
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/verify")
|
||||
@UseGuards(JwtGuard)
|
||||
@ZodSerializerDto(UserDto)
|
||||
async verify2FACode(
|
||||
@User() user: UserWithSecrets,
|
||||
@Body() { code }: TwoFactorDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
await this.authService.verify2FACode(user.email, code);
|
||||
|
||||
const { accessToken, refreshToken } = await this.exchangeToken(user.id, user.email, true);
|
||||
|
||||
response.cookie("Authentication", accessToken, getCookieOptions("access"));
|
||||
response.cookie("Refresh", refreshToken, getCookieOptions("refresh"));
|
||||
|
||||
response.status(200).send(userSchema.parse(user));
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/backup")
|
||||
@UseGuards(JwtGuard)
|
||||
@ZodSerializerDto(UserDto)
|
||||
async useBackup2FACode(
|
||||
@User("id") id: string,
|
||||
@User("email") email: string,
|
||||
@Body() { code }: TwoFactorBackupDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const user = await this.authService.useBackup2FACode(email, code);
|
||||
|
||||
return this.handleAuthenticationResponse(user, response, true);
|
||||
}
|
||||
|
||||
// Password Recovery Flows
|
||||
@ApiTags("Password Reset")
|
||||
@HttpCode(200)
|
||||
@Post("forgot-password")
|
||||
async forgotPassword(@Body() { email }: ForgotPasswordDto) {
|
||||
try {
|
||||
await this.authService.forgotPassword(email);
|
||||
} catch (error) {
|
||||
// pass
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
"A password reset link should have been sent to your inbox, if an account existed with the email you provided.",
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags("Password Reset")
|
||||
@HttpCode(200)
|
||||
@Post("reset-password")
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async resetPassword(@Body() { token, password }: ResetPasswordDto) {
|
||||
try {
|
||||
await this.authService.resetPassword(token, password);
|
||||
|
||||
return { message: "Your password has been successfully reset." };
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidResetToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Email Verification Flows
|
||||
@ApiTags("Email Verification")
|
||||
@Post("verify-email")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async verifyEmail(
|
||||
@User("id") id: string,
|
||||
@User("emailVerified") emailVerified: boolean,
|
||||
@Query("token") token: string,
|
||||
) {
|
||||
if (!token) throw new BadRequestException(ErrorMessage.InvalidVerificationToken);
|
||||
|
||||
if (emailVerified) {
|
||||
throw new BadRequestException(ErrorMessage.EmailAlreadyVerified);
|
||||
}
|
||||
|
||||
await this.authService.verifyEmail(id, token);
|
||||
|
||||
return { message: "Your email has been successfully verified." };
|
||||
}
|
||||
|
||||
@ApiTags("Email Verification")
|
||||
@Post("verify-email/resend")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async resendVerificationEmail(
|
||||
@User("email") email: string,
|
||||
@User("emailVerified") emailVerified: boolean,
|
||||
) {
|
||||
if (emailVerified) {
|
||||
throw new BadRequestException(ErrorMessage.EmailAlreadyVerified);
|
||||
}
|
||||
|
||||
await this.authService.sendVerificationEmail(email);
|
||||
|
||||
return {
|
||||
message: "You should have received a new email with a link to verify your email address.",
|
||||
};
|
||||
}
|
||||
}
|
||||
70
apps/server/src/auth/auth.module.ts
Normal file
70
apps/server/src/auth/auth.module.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { DynamicModule, Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
import { MailModule } from "../mail/mail.module";
|
||||
import { UserModule } from "../user/user.module";
|
||||
import { UserService } from "../user/user.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { DummyStrategy } from "./strategy/dummy.strategy";
|
||||
import { GitHubStrategy } from "./strategy/github.strategy";
|
||||
import { GoogleStrategy } from "./strategy/google.strategy";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
import { LocalStrategy } from "./strategy/local.strategy";
|
||||
import { RefreshStrategy } from "./strategy/refresh.strategy";
|
||||
import { TwoFactorStrategy } from "./strategy/two-factor.strategy";
|
||||
|
||||
@Module({})
|
||||
export class AuthModule {
|
||||
static register(): DynamicModule {
|
||||
return {
|
||||
module: AuthModule,
|
||||
imports: [PassportModule, JwtModule, UserModule, MailModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
LocalStrategy,
|
||||
JwtStrategy,
|
||||
RefreshStrategy,
|
||||
TwoFactorStrategy,
|
||||
|
||||
// OAuth2 Strategies
|
||||
{
|
||||
provide: GoogleStrategy,
|
||||
inject: [ConfigService, UserService],
|
||||
useFactory: (configService: ConfigService<Config>, userService: UserService) => {
|
||||
try {
|
||||
const clientID = configService.getOrThrow("GOOGLE_CLIENT_ID");
|
||||
const clientSecret = configService.getOrThrow("GOOGLE_CLIENT_SECRET");
|
||||
const callbackURL = configService.getOrThrow("GOOGLE_CALLBACK_URL");
|
||||
|
||||
return new GoogleStrategy(clientID, clientSecret, callbackURL, userService);
|
||||
} catch (error) {
|
||||
return new DummyStrategy();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
provide: GitHubStrategy,
|
||||
inject: [ConfigService, UserService],
|
||||
useFactory: (configService: ConfigService<Config>, userService: UserService) => {
|
||||
try {
|
||||
const clientID = configService.getOrThrow("GITHUB_CLIENT_ID");
|
||||
const clientSecret = configService.getOrThrow("GITHUB_CLIENT_SECRET");
|
||||
const callbackURL = configService.getOrThrow("GITHUB_CALLBACK_URL");
|
||||
|
||||
return new GitHubStrategy(clientID, clientSecret, callbackURL, userService);
|
||||
} catch (error) {
|
||||
return new DummyStrategy();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService],
|
||||
};
|
||||
}
|
||||
}
|
||||
321
apps/server/src/auth/auth.service.ts
Normal file
321
apps/server/src/auth/auth.service.ts
Normal file
@ -0,0 +1,321 @@
|
||||
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,
|
||||
language: registerDto.language,
|
||||
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;
|
||||
}
|
||||
}
|
||||
5
apps/server/src/auth/guards/github.guard.ts
Normal file
5
apps/server/src/auth/guards/github.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class GitHubGuard extends AuthGuard("github") {}
|
||||
5
apps/server/src/auth/guards/google.guard.ts
Normal file
5
apps/server/src/auth/guards/google.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleGuard extends AuthGuard("google") {}
|
||||
5
apps/server/src/auth/guards/jwt.guard.ts
Normal file
5
apps/server/src/auth/guards/jwt.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard("jwt") {}
|
||||
5
apps/server/src/auth/guards/local.guard.ts
Normal file
5
apps/server/src/auth/guards/local.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class LocalGuard extends AuthGuard("local") {}
|
||||
10
apps/server/src/auth/guards/optional.guard.ts
Normal file
10
apps/server/src/auth/guards/optional.guard.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { UserDto } from "@reactive-resume/dto";
|
||||
|
||||
@Injectable()
|
||||
export class OptionalGuard extends AuthGuard("two-factor") {
|
||||
handleRequest<TUser = UserDto>(error: Error, user: TUser): TUser {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
5
apps/server/src/auth/guards/refresh.guard.ts
Normal file
5
apps/server/src/auth/guards/refresh.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class RefreshGuard extends AuthGuard("refresh") {}
|
||||
5
apps/server/src/auth/guards/two-factor.guard.ts
Normal file
5
apps/server/src/auth/guards/two-factor.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorGuard extends AuthGuard("two-factor") {}
|
||||
14
apps/server/src/auth/strategy/dummy.strategy.ts
Normal file
14
apps/server/src/auth/strategy/dummy.strategy.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport";
|
||||
|
||||
@Injectable()
|
||||
export class DummyStrategy extends PassportStrategy(Strategy, "dummy") {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
this.fail();
|
||||
}
|
||||
}
|
||||
61
apps/server/src/auth/strategy/github.strategy.ts
Normal file
61
apps/server/src/auth/strategy/github.strategy.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { processUsername } from "@reactive-resume/utils";
|
||||
import { Profile, Strategy, StrategyOptions } from "passport-github2";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
@Injectable()
|
||||
export class GitHubStrategy extends PassportStrategy(Strategy, "github") {
|
||||
constructor(
|
||||
readonly clientID: string,
|
||||
readonly clientSecret: string,
|
||||
readonly callbackURL: string,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({ clientID, clientSecret, callbackURL, scope: ["user:email"] } as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: (err?: string | Error | null, user?: Express.User, info?: unknown) => void,
|
||||
) {
|
||||
const { displayName, emails, photos, username } = profile;
|
||||
|
||||
const email = emails?.[0].value ?? `${username}@github.com`;
|
||||
const picture = photos?.[0].value;
|
||||
|
||||
let user: User | null = null;
|
||||
|
||||
if (!email) throw new BadRequestException();
|
||||
|
||||
try {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
try {
|
||||
user = await this.userService.create({
|
||||
email,
|
||||
picture,
|
||||
language: "en",
|
||||
name: displayName,
|
||||
provider: "github",
|
||||
emailVerified: true, // auto-verify emails
|
||||
username: processUsername(username ?? email.split("@")[0]),
|
||||
secrets: { create: {} },
|
||||
});
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/server/src/auth/strategy/google.strategy.ts
Normal file
61
apps/server/src/auth/strategy/google.strategy.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { processUsername } from "@reactive-resume/utils";
|
||||
import { Profile, Strategy, StrategyOptions, VerifyCallback } from "passport-google-oauth20";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
|
||||
constructor(
|
||||
readonly clientID: string,
|
||||
readonly clientSecret: string,
|
||||
readonly callbackURL: string,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({ clientID, clientSecret, callbackURL, scope: ["email", "profile"] } as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: VerifyCallback,
|
||||
) {
|
||||
const { displayName, emails, photos, username } = profile;
|
||||
|
||||
const email = emails?.[0].value ?? `${username}@google.com`;
|
||||
const picture = photos?.[0].value;
|
||||
|
||||
let user: User | null = null;
|
||||
|
||||
if (!email) throw new BadRequestException();
|
||||
|
||||
try {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
try {
|
||||
user = await this.userService.create({
|
||||
email,
|
||||
picture,
|
||||
language: "en",
|
||||
name: displayName,
|
||||
provider: "google",
|
||||
emailVerified: true, // auto-verify emails
|
||||
username: processUsername(username ?? email.split("@")[0]),
|
||||
secrets: { create: {} },
|
||||
});
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/server/src/auth/strategy/jwt.strategy.ts
Normal file
30
apps/server/src/auth/strategy/jwt.strategy.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
import { Payload } from "../utils/payload";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Authentication];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.get<string>("ACCESS_TOKEN_SECRET"),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors(extractors),
|
||||
ignoreExpiration: false,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(payload: Payload) {
|
||||
return this.userService.findOneById(payload.id);
|
||||
}
|
||||
}
|
||||
22
apps/server/src/auth/strategy/local.strategy.ts
Normal file
22
apps/server/src/auth/strategy/local.strategy.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { IStrategyOptions, Strategy } from "passport-local";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
|
||||
import { AuthService } from "../auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy, "local") {
|
||||
constructor(private readonly authService: AuthService) {
|
||||
super({ usernameField: "identifier" } as IStrategyOptions);
|
||||
}
|
||||
|
||||
async validate(identifier: string, password: string) {
|
||||
try {
|
||||
return this.authService.authenticate({ identifier, password });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/server/src/auth/strategy/refresh.strategy.ts
Normal file
33
apps/server/src/auth/strategy/refresh.strategy.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
|
||||
import { AuthService } from "../auth.service";
|
||||
import { Payload } from "../utils/payload";
|
||||
|
||||
@Injectable()
|
||||
export class RefreshStrategy extends PassportStrategy(Strategy, "refresh") {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Refresh];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.getOrThrow<string>("REFRESH_TOKEN_SECRET"),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors(extractors),
|
||||
passReqToCallback: true,
|
||||
ignoreExpiration: false,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(request: Request, payload: Payload) {
|
||||
const refreshToken = request.cookies?.Refresh;
|
||||
|
||||
return this.authService.validateRefreshToken(payload, refreshToken);
|
||||
}
|
||||
}
|
||||
35
apps/server/src/auth/strategy/two-factor.strategy.ts
Normal file
35
apps/server/src/auth/strategy/two-factor.strategy.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
import { Payload } from "../utils/payload";
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorStrategy extends PassportStrategy(Strategy, "two-factor") {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Authentication];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.get<string>("ACCESS_TOKEN_SECRET"),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors(extractors),
|
||||
ignoreExpiration: false,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(payload: Payload) {
|
||||
const user = await this.userService.findOneById(payload.id);
|
||||
|
||||
// If the user has 2FA disabled, this will follow the same route as JWT Strategy
|
||||
if (!user.twoFactorEnabled) return user;
|
||||
|
||||
if (payload.isTwoFactorAuth) return user;
|
||||
}
|
||||
}
|
||||
26
apps/server/src/auth/utils/cookie.ts
Normal file
26
apps/server/src/auth/utils/cookie.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { InternalServerErrorException } from "@nestjs/common";
|
||||
import { CookieOptions } from "express";
|
||||
|
||||
export const getCookieOptions = (grantType: "access" | "refresh"): CookieOptions => {
|
||||
// Options For Access Token
|
||||
if (grantType === "access") {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: (process.env.PUBLIC_URL ?? "").includes("https://"),
|
||||
expires: new Date(Date.now() + 1000 * 60 * 15), // 15 minutes from now
|
||||
};
|
||||
}
|
||||
|
||||
// Options For Refresh Token
|
||||
if (grantType === "refresh") {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: (process.env.PUBLIC_URL ?? "").includes("https://"),
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 2), // 2 days from now
|
||||
};
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException("InvalidGrantType: " + grantType);
|
||||
};
|
||||
9
apps/server/src/auth/utils/payload.ts
Normal file
9
apps/server/src/auth/utils/payload.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { idSchema } from "@reactive-resume/schema";
|
||||
import { z } from "nestjs-zod/z";
|
||||
|
||||
export const payloadSchema = z.object({
|
||||
id: idSchema,
|
||||
isTwoFactorAuth: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type Payload = z.infer<typeof payloadSchema>;
|
||||
Reference in New Issue
Block a user