import { HttpStatus, Injectable } from '@nestjs/common'; import { MailerService } from 'src/mailer/mailer.service'; import { PrismaService } from 'src/prisma/prisma.service'; import { UserService } from 'src/user/user.service'; import { VerifyMagicDto } from './dto/verify-magic.dto'; import { DateTime } from 'luxon'; import * as argon2 from 'argon2'; import * as bcrypt from 'bcrypt'; import * as O from 'fp-ts/Option'; import * as E from 'fp-ts/Either'; import { DeviceIdentifierToken } from 'src/types/Passwordless'; import { INVALID_EMAIL, INVALID_MAGIC_LINK_DATA, VERIFICATION_TOKEN_DATA_NOT_FOUND, MAGIC_LINK_EXPIRED, USER_NOT_FOUND, INVALID_REFRESH_TOKEN, } from 'src/errors'; import { validateEmail } from 'src/utils'; import { AccessTokenPayload, AuthTokens, RefreshTokenPayload, } from 'src/types/AuthTokens'; import { JwtService } from '@nestjs/jwt'; import { AuthError } from 'src/types/AuthError'; import { AuthUser, IsAdmin } from 'src/types/AuthUser'; import { VerificationToken } from '@prisma/client'; import { Origin } from './helper'; @Injectable() export class AuthService { constructor( private usersService: UserService, private prismaService: PrismaService, private jwtService: JwtService, private readonly mailerService: MailerService, ) {} /** * Generate Id and token for email Magic-Link auth * * @param user User Object * @returns Created VerificationToken token */ private async generateMagicLinkTokens(user: AuthUser) { const salt = await bcrypt.genSalt( parseInt(process.env.TOKEN_SALT_COMPLEXITY), ); const expiresOn = DateTime.now() .plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) }) .toISO() .toString(); const idToken = await this.prismaService.verificationToken.create({ data: { deviceIdentifier: salt, userUid: user.uid, expiresOn: expiresOn, }, }); return idToken; } /** * Check if VerificationToken exist or not * * @param magicLinkTokens Object containing deviceIdentifier and token * @returns Option of VerificationToken token */ private async validatePasswordlessTokens(magicLinkTokens: VerifyMagicDto) { try { const tokens = await this.prismaService.verificationToken.findUniqueOrThrow({ where: { passwordless_deviceIdentifier_tokens: { deviceIdentifier: magicLinkTokens.deviceIdentifier, token: magicLinkTokens.token, }, }, }); return O.some(tokens); } catch (error) { return O.none; } } /** * Generate new refresh token for user * * @param userUid User Id * @returns Generated refreshToken */ private async generateRefreshToken(userUid: string) { const refreshTokenPayload: RefreshTokenPayload = { iss: process.env.VITE_BASE_URL, sub: userUid, aud: [process.env.VITE_BASE_URL], }; const refreshToken = await this.jwtService.sign(refreshTokenPayload, { expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days }); const refreshTokenHash = await argon2.hash(refreshToken); const updatedUser = await this.usersService.UpdateUserRefreshToken( refreshTokenHash, userUid, ); if (E.isLeft(updatedUser)) return E.left({ message: updatedUser.left, statusCode: HttpStatus.NOT_FOUND, }); return E.right(refreshToken); } /** * Generate access and refresh token pair * * @param userUid User ID * @returns Either of generated AuthTokens */ async generateAuthTokens(userUid: string) { const accessTokenPayload: AccessTokenPayload = { iss: process.env.VITE_BASE_URL, sub: userUid, aud: [process.env.VITE_BASE_URL], }; const refreshToken = await this.generateRefreshToken(userUid); if (E.isLeft(refreshToken)) return E.left(refreshToken.left); return E.right({ access_token: await this.jwtService.sign(accessTokenPayload, { expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day }), refresh_token: refreshToken.right, }); } /** * Deleted used VerificationToken tokens * * @param passwordlessTokens VerificationToken entry to delete from DB * @returns Either of deleted VerificationToken token */ private async deleteMagicLinkVerificationTokens( passwordlessTokens: VerificationToken, ) { try { const deletedPasswordlessToken = await this.prismaService.verificationToken.delete({ where: { passwordless_deviceIdentifier_tokens: { deviceIdentifier: passwordlessTokens.deviceIdentifier, token: passwordlessTokens.token, }, }, }); return E.right(deletedPasswordlessToken); } catch (error) { return E.left(VERIFICATION_TOKEN_DATA_NOT_FOUND); } } /** * Verify if Provider account exists for User * * @param user User Object * @param SSOUserData User data from SSO providers (Magic,Google,Github,Microsoft) * @returns Either of existing user provider Account */ async checkIfProviderAccountExists(user: AuthUser, SSOUserData) { const provider = await this.prismaService.account.findUnique({ where: { verifyProviderAccount: { provider: SSOUserData.provider, providerAccountId: SSOUserData.id, }, }, }); if (!provider) return O.none; return O.some(provider); } /** * Create User (if not already present) and send email to initiate Magic-Link auth * * @param email User's email * @returns Either containing DeviceIdentifierToken */ async signInMagicLink(email: string, origin: string) { if (!validateEmail(email)) return E.left({ message: INVALID_EMAIL, statusCode: HttpStatus.BAD_REQUEST, }); let user: AuthUser; const queriedUser = await this.usersService.findUserByEmail(email); if (O.isNone(queriedUser)) { user = await this.usersService.createUserViaMagicLink(email); } else { user = queriedUser.value; } const generatedTokens = await this.generateMagicLinkTokens(user); // check to see if origin is valid let url: string; switch (origin) { case Origin.ADMIN: url = process.env.VITE_ADMIN_URL; break; case Origin.APP: url = process.env.VITE_BASE_URL; break; default: // if origin is invalid by default set URL to Hoppscotch-App url = process.env.VITE_BASE_URL; } await this.mailerService.sendAuthEmail(email, { template: 'code-your-own', variables: { inviteeEmail: email, magicLink: `${url}/enter?token=${generatedTokens.token}`, }, }); return E.right({ deviceIdentifier: generatedTokens.deviceIdentifier, }); } /** * Verify and authenticate user from received data for Magic-Link * * @param magicLinkIDTokens magic-link verification tokens from client * @returns Either of generated AuthTokens */ async verifyMagicLinkTokens( magicLinkIDTokens: VerifyMagicDto, ): Promise | E.Left> { const passwordlessTokens = await this.validatePasswordlessTokens( magicLinkIDTokens, ); if (O.isNone(passwordlessTokens)) return E.left({ message: INVALID_MAGIC_LINK_DATA, statusCode: HttpStatus.NOT_FOUND, }); const user = await this.usersService.findUserById( passwordlessTokens.value.userUid, ); if (O.isNone(user)) return E.left({ message: USER_NOT_FOUND, statusCode: HttpStatus.NOT_FOUND, }); /** * * Check to see if entry for Magic-Link is present in the Account table for user * * If user was created with another provider findUserById may return true */ const profile = { provider: 'magic', id: user.value.email, }; const providerAccountExists = await this.checkIfProviderAccountExists( user.value, profile, ); if (O.isNone(providerAccountExists)) { await this.usersService.createProviderAccount( user.value, null, null, profile, ); } const currentTime = DateTime.now().toISO(); if (currentTime > passwordlessTokens.value.expiresOn.toISOString()) return E.left({ message: MAGIC_LINK_EXPIRED, statusCode: HttpStatus.UNAUTHORIZED, }); const tokens = await this.generateAuthTokens( passwordlessTokens.value.userUid, ); if (E.isLeft(tokens)) return E.left({ message: tokens.left.message, statusCode: tokens.left.statusCode, }); const deletedPasswordlessToken = await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value); if (E.isLeft(deletedPasswordlessToken)) return E.left({ message: deletedPasswordlessToken.left, statusCode: HttpStatus.NOT_FOUND, }); return E.right(tokens.right); } /** * Refresh refresh and auth tokens * * @param hashedRefreshToken Hashed refresh token received from client * @param user User Object * @returns Either of generated AuthTokens */ async refreshAuthTokens(hashedRefreshToken: string, user: AuthUser) { // Check to see user is valid if (!user) return E.left({ message: USER_NOT_FOUND, statusCode: HttpStatus.NOT_FOUND, }); // Check to see if the hashed refresh_token received from the client is the same as the refresh_token saved in the DB const isTokenMatched = await argon2.verify( user.refreshToken, hashedRefreshToken, ); if (!isTokenMatched) return E.left({ message: INVALID_REFRESH_TOKEN, statusCode: HttpStatus.NOT_FOUND, }); // if tokens match, generate new pair of auth tokens const generatedAuthTokens = await this.generateAuthTokens(user.uid); if (E.isLeft(generatedAuthTokens)) return E.left({ message: generatedAuthTokens.left.message, statusCode: generatedAuthTokens.left.statusCode, }); return E.right(generatedAuthTokens.right); } /** * Verify is signed in User is an admin or not * * @param user User Object * @returns Either of boolean if user is admin or not */ async verifyAdmin(user: AuthUser) { if (user.isAdmin) return E.right({ isAdmin: true }); const usersCount = await this.usersService.getUsersCount(); if (usersCount === 1) { const elevatedUser = await this.usersService.makeAdmin(user.uid); if (E.isLeft(elevatedUser)) return E.left({ message: elevatedUser.left, statusCode: HttpStatus.NOT_FOUND, }); return E.right({ isAdmin: true }); } return E.right({ isAdmin: false }); } }