Skip to content

Instantly share code, notes, and snippets.

@Yvad60
Created November 12, 2024 10:05
Show Gist options
  • Select an option

  • Save Yvad60/3ca6eb3f93cd3959ecbd22b152fb907c to your computer and use it in GitHub Desktop.

Select an option

Save Yvad60/3ca6eb3f93cd3959ecbd22b152fb907c to your computer and use it in GitHub Desktop.
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { InjectRepository } from "@nestjs/typeorm";
import { Request } from "express";
import { PinoLogger } from "nestjs-pino";
import * as OTPAuth from "otpauth";
import * as QRCode from "qrcode";
import { EntityManager, Repository } from "typeorm";
import { ESetPasswordAction } from "../__shared__/enums/set-password-action.enum";
import { EUserType } from "../__shared__/enums/user-type.enum";
import { IAppConfig } from "../__shared__/interfaces/app-config.interface";
import { forgotPasswordEmailTemplate } from "../__shared__/templates/forgot-password.template";
import { HashEncryption } from "../__shared__/utils/hash-encryption.util";
import { SesService } from "../notifications/ses.service";
import { User } from "../users/entities/user.entity";
import { UsersService } from "../users/users.service";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { LoginDto } from "./dto/login.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { SetPasswordDto } from "./dto/set-password.dto";
import { Auth } from "./entities/auth.entity";
import { IJwtPayload } from "./interfaces/jwt-payload.interface";
@Injectable()
export class AuthService {
constructor(
@InjectRepository(Auth)
private authRepository: Repository<Auth>,
private readonly entityManager: EntityManager,
private configService: ConfigService<IAppConfig>,
private jwtService: JwtService,
private usersService: UsersService,
private sesService: SesService,
private readonly logger: PinoLogger,
) {}
/**
* Staff login
* @param loginDto login DTO
*/
async staffLogin(loginDto: LoginDto) {
return this.login(loginDto, EUserType.STAFF);
}
/**
* Employee login
* @param loginDto login DTO
*/
async employeeLogin(loginDto: LoginDto) {
return this.login(loginDto, EUserType.EMPLOYEE);
}
/**
* Authenticate user
* @param auth Auth
* @param expiresIn token expiry
* @returns access, user, verified and refresh tokens
*/
async authenticateUser(
auth: Auth,
options: { isTwoFactorAuthenticated: boolean },
) {
const loginLogger = this.logger.logger.child({
context: "AuthService",
method: "authenticateUser",
});
const { isTwoFactorAuthenticated } = options;
const { accessToken, refreshToken } = this.generateTokens({
id: auth.user.id,
type: auth.user.type,
role: auth.user.role,
});
// if (isTwoFactorAuthenticated) {
await Promise.all([
this.authRepository.update(auth.id, {
loginCount: auth.loginCount + 1,
lastLogin: new Date(),
isTwoFactorAuthenticated,
}),
this.updateRefreshToken(auth.id, refreshToken),
]);
// }
const user = {
username: auth.username,
firstLogin: auth.loginCount === 0,
};
loginLogger.info({ userId: auth.user.id }, "user logged in successfully");
return {
accessToken,
refreshToken,
user,
verified: isTwoFactorAuthenticated,
};
}
/**
* Login
* @param loginDto login DTO
* @param type user type
*/
async login(loginDto: LoginDto, type: EUserType) {
const { email, password } = loginDto;
const loginLogger = this.logger.logger.child({
context: "AuthService",
method: "login",
});
loginLogger.info({ userType: type }, "user attempting to log in");
const auth = await this.authRepository.findOne({
where: { username: email },
relations: ["user"],
});
if (!auth) {
loginLogger.info(
{
email: email,
},
"Login failed: Invalid credentials",
);
throw new UnauthorizedException("Invalid email or password");
}
if (auth.user.type !== type) {
loginLogger.info(
{
userId: auth.user.id,
},
"Login failed: User type mismatch",
);
throw new UnauthorizedException("Invalid email or password");
}
const isMatch = HashEncryption.compare(password, auth.password, auth.salt);
if (!isMatch) {
loginLogger.warn(
{
userId: auth.user.id,
},
"Login failed: Invalid credentials",
);
throw new UnauthorizedException("Invalid email or password");
}
if (!auth.user.verified) {
loginLogger.info(
{
userId: auth.user.id,
},
"Login failed: Account is not verified",
);
throw new UnauthorizedException("Account is not verified");
}
if (!auth.user.activated) {
loginLogger.warn(
{ userId: auth.user.id },
"Login failed: Account is not activated",
);
throw new UnauthorizedException(
"Account is not activated, please contact support",
);
}
return this.authenticateUser(auth, { isTwoFactorAuthenticated: false });
}
generateTwoFactorTotp(email: string, secret: string) {
const appName = "Terrassign";
const totp = new OTPAuth.TOTP({
issuer: appName,
label: email,
secret,
algorithm: "SHA1",
digits: 6,
period: 30,
});
return totp;
}
/**
* Generate QR code for user
* @param user User
* @returns QR code URI
*/
async generateQrCodeForUser(user: User): Promise<string> {
const auth = await this.findByUserId(user.id);
let secret: string;
if (!auth.twoFactorSecret) {
const twoFactorSecret = new OTPAuth.Secret().base32;
await this.update(auth.id, { twoFactorSecret });
secret = twoFactorSecret;
} else {
secret = auth.twoFactorSecret;
}
const totp = this.generateTwoFactorTotp(user.email, secret);
const uri = totp.toString();
const qrCodeDataUrl = await QRCode.toDataURL(uri);
return qrCodeDataUrl;
}
/**
* Verify code
* @param user User
* @param code code
*/
async verifyTwoFactorCode(user: User, code: string) {
const auth = await this.findByUserId(user.id);
const { twoFactorSecret } = auth;
if (!twoFactorSecret) {
throw new BadRequestException("Two-factor authentication is not set up.");
}
const totp = this.generateTwoFactorTotp(user.email, twoFactorSecret);
const isValid = totp.validate({ token: code, window: 1 }) !== null;
if (!isValid) {
throw new BadRequestException("Two factor code is invalid");
}
return this.authenticateUser(auth, { isTwoFactorAuthenticated: true });
}
/**
* Logout user
* @param user User
*/
async logout(user: User) {
const auth = await this.findByUserId(user.id);
const logoutLogger = this.logger.logger.child({
context: "AuthService",
method: "logout",
});
if (!auth) {
logoutLogger.error(
{ userId: user.id },
"error while logging out - unauthorized",
);
throw new UnauthorizedException();
}
await this.authRepository.update(auth.id, {
refreshToken: null,
refreshTokenSalt: null,
isTwoFactorAuthenticated: false,
});
logoutLogger.info({ user: user.id }, "user tokens cleared");
}
/**
* Refresh token
* @param user user
* @returns new access and refresh tokens
*/
async refreshToken(user: User) {
const refreshTokenLogger = this.logger.logger.child({
context: "Organization Service",
method: "refreshToken",
});
const auth = await this.findByUserId(user.id);
if (!auth) {
refreshTokenLogger.error(
{ userId: user.id },
"Error during token refresh - Unauthorized",
);
throw new UnauthorizedException();
}
const { accessToken, refreshToken } = this.generateTokens({
id: user.id,
type: user.type,
role: user.role,
});
refreshTokenLogger.info({ userId: user.id }, "New tokens generated");
await this.updateRefreshToken(auth.id, refreshToken);
return { accessToken, refreshToken };
}
/**
* Forgot password
* @param forgotPasswordDto ForgotPasswordDto
* @param request Request
* @returns resetToken
*/
async forgotPassword(forgotPasswordDto: ForgotPasswordDto, req: Request) {
const { email, code } = forgotPasswordDto;
const auth = await this.findByUsername(email);
const forgotPasswordLogger = this.logger.logger.child({
email,
context: "AuthService",
method: "forgotPassword",
});
const isValid = await this.verifyTwoFactorCode(auth.user, code);
if (!isValid) throw new BadRequestException("Two factor code is invalid");
if (!auth) {
forgotPasswordLogger.info("user does not exist");
throw new NotFoundException();
}
const reqHost = req.protocol + req.get("Host");
const portalUrl =
auth.user.type === EUserType.EMPLOYEE
? this.configService.get("web").employeePortalUrl
: this.configService.get("web").adminPortalUrl;
if (
this.configService.get("env") === "production" &&
reqHost !== portalUrl
) {
forgotPasswordLogger.warn("Invalid request host");
throw new NotFoundException();
}
const resetToken = this.jwtService.sign({ email });
const resetPasswordLink = `${portalUrl}/auth/reset-password/?token=${resetToken}&action=${ESetPasswordAction.RESET_PASSWORD}`;
const forgotEmail = {
to: [email],
subject: "Reset password",
from: process.env.SENT_EMAIL_FROM,
text: "Reset password.",
html: forgotPasswordEmailTemplate(
`${auth.user.firstName} ${auth.user.surname}`,
resetPasswordLink,
),
};
await this.sesService.sendEmail(forgotEmail);
forgotPasswordLogger.info("Password reset email sent successfully");
}
/**
* Reset password
* @param user User
* @returns resetToken
*/
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise<void> {
const resetPasswordLogger = this.logger.logger.child({
context: "AuthService",
method: "resetPassword",
});
const { password, token } = resetPasswordDto;
const { email } = this.extractPayloadFromToken<{ email: string }>(token);
if (!email) {
resetPasswordLogger.info({ email }, "invalid token");
throw new BadRequestException("Invalid token");
}
const auth = await this.findByUsername(email);
if (!auth) {
resetPasswordLogger.info({ email }, "User not found");
throw new NotFoundException();
}
const { hash, salt } = HashEncryption.hash(password);
resetPasswordLogger.info("password reset");
await this.authRepository.update(auth.id, { password: hash, salt });
}
/**
* Set password for created users
* @param user User
*/
async setPassword(setPasswordDto: SetPasswordDto): Promise<void> {
const setPasswordLogger = this.logger.logger.child({
context: "AuthService",
method: "setPassword",
});
const { password, token } = setPasswordDto;
const { email } = this.extractPayloadFromToken<{ email: string }>(token);
if (!email) {
setPasswordLogger.warn({ email }, "invalid token");
throw new BadRequestException("Invalid token");
}
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new NotFoundException();
}
const auth = await this.findByUsername(email);
if (auth) {
setPasswordLogger.info("password already set");
throw new BadRequestException("Password is already set");
}
setPasswordLogger.info("password set successfully");
return this.entityManager.transaction(async (manager) => {
const auth = new Auth();
const { hash, salt } = HashEncryption.hash(password);
auth.username = email;
auth.password = hash;
auth.salt = salt;
auth.user = user;
await manager.save(Auth, auth);
await manager.update(
User,
{
id: user.id,
},
{ verified: true, activated: true },
);
});
}
/**
* Find auth by user id
* @param userId user id
* @returns Auth
*/
async findByUserId(userId: number): Promise<Auth> {
return this.authRepository.findOne({
where: { user: { id: userId } },
relations: ["user"],
});
}
/**
* Find auth by username
* @param username
* @returns Auth
*/
async findByUsername(username: string): Promise<Auth> {
return this.authRepository.findOne({
where: { username },
relations: ["user"],
});
}
/**
* Update user
* @param id user id
* @param data user data
* @returns User
*/
async update(id: Auth["id"], data: Partial<Auth>): Promise<Auth> {
await this.authRepository.update({ id }, data);
return this.findByUserId(id);
}
/**
* Update refresh token
* @param id auth id
* @param refreshToken refresh token to save
*/
async updateRefreshToken(id: number, refreshToken: string) {
const { hash, salt } = HashEncryption.hash(refreshToken);
await this.authRepository.update(id, {
refreshToken: hash,
refreshTokenSalt: salt,
});
}
/**
* Validate refresh token
* @param userId user id
* @param refreshToken
* @returns User
*/
async validateRefreshToken(
userId: number,
refreshToken: string,
): Promise<User> {
const validateRefreshTokenLogger = this.logger.logger.child({
context: "AuthService",
method: "validateRefreshToken",
});
const auth = await this.findByUserId(userId);
if (!auth?.refreshToken || !auth?.refreshTokenSalt) {
validateRefreshTokenLogger.error(
{ userId },
"could not validate refresh token - unauthorized",
);
throw new UnauthorizedException();
}
const isMatch = HashEncryption.compare(
refreshToken,
auth.refreshToken,
auth.refreshTokenSalt,
);
if (!isMatch) {
throw new UnauthorizedException();
}
return auth.user;
}
/**
* Generate access token
* @param payload JWT payload
* @param expiresIn token expiry
* @returns access and refresh tokens
*/
generateTokens(payload: IJwtPayload) {
const generateTokensLogger = this.logger.logger.child({
context: "AuthService",
method: "generateTokens",
});
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, {
expiresIn: this.configService.get("jwt").refreshExpiresIn,
});
generateTokensLogger.info("Tokens generated");
return { accessToken, refreshToken };
}
/**
* Extract payload from token
* @param token JWT token
* @returns payload
*/
extractPayloadFromToken<T>(token: string): T {
try {
const payload = this.jwtService.verify(token);
return payload;
} catch (error) {
throw new BadRequestException(error?.message || "Invalid token");
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment