refactor(v4.0.0-alpha): beginning of a new era

This commit is contained in:
Amruth Pillai
2023-11-05 12:31:42 +01:00
parent 0ba6a444e2
commit 22933bd412
505 changed files with 81829 additions and 0 deletions

View 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.",
};
}
}

View 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],
};
}
}

View 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;
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class GitHubGuard extends AuthGuard("github") {}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class GoogleGuard extends AuthGuard("google") {}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtGuard extends AuthGuard("jwt") {}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalGuard extends AuthGuard("local") {}

View 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;
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class RefreshGuard extends AuthGuard("refresh") {}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class TwoFactorGuard extends AuthGuard("two-factor") {}

View 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();
}
}

View 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);
}
}
}
}

View 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);
}
}
}
}

View 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);
}
}

View 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);
}
}
}

View 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);
}
}

View 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;
}
}

View 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);
};

View 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>;