Created
November 12, 2024 10:05
-
-
Save Yvad60/3ca6eb3f93cd3959ecbd22b152fb907c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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