Files
hoppscotch/packages/hoppscotch-backend/src/auth/auth.service.ts
2023-05-09 15:55:38 +05:30

381 lines
11 KiB
TypeScript

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(<AuthError>{
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(<AuthTokens>{
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(<DeviceIdentifierToken>{
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.Right<AuthTokens> | E.Left<AuthError>> {
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>{ 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(<AuthError>{
message: elevatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
return E.right(<IsAdmin>{ isAdmin: true });
}
return E.right(<IsAdmin>{ isAdmin: false });
}
}