diff --git a/packages/hoppscotch-backend/.vscode/settings.json b/packages/hoppscotch-backend/.vscode/settings.json new file mode 100644 index 000000000..4a70cc4e4 --- /dev/null +++ b/packages/hoppscotch-backend/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#93e6fc", + "activityBar.background": "#93e6fc", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#fa45d4", + "activityBarBadge.foreground": "#15202b", + "commandCenter.border": "#15202b99", + "sash.hoverBorder": "#93e6fc", + "statusBar.background": "#61dafb", + "statusBar.foreground": "#15202b", + "statusBarItem.hoverBackground": "#2fcefa", + "statusBarItem.remoteBackground": "#61dafb", + "statusBarItem.remoteForeground": "#15202b", + "titleBar.activeBackground": "#61dafb", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#61dafb99", + "titleBar.inactiveForeground": "#15202b99" + }, + "peacock.remoteColor": "#61dafb" +} diff --git a/packages/hoppscotch-backend/prisma/migrations/20230109064650_auth/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20230109064650_auth/migration.sql new file mode 100644 index 000000000..e13e07dcd --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20230109064650_auth/migration.sql @@ -0,0 +1,158 @@ +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR'); + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "role" "TeamMemberRole" NOT NULL, + "userUid" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamInvitation" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "creatorUid" TEXT NOT NULL, + "inviteeEmail" TEXT NOT NULL, + "inviteeRole" "TeamMemberRole" NOT NULL, + + CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamCollection" ( + "id" TEXT NOT NULL, + "parentID" TEXT, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamRequest" ( + "id" TEXT NOT NULL, + "collectionID" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + "request" JSONB NOT NULL, + + CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Shortcode" ( + "id" TEXT NOT NULL, + "request" JSONB NOT NULL, + "creatorUid" TEXT, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamEnvironment" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "name" TEXT NOT NULL, + "variables" JSONB NOT NULL, + + CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "image" TEXT, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "refreshToken" TEXT, + "createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "providerRefreshToken" TEXT, + "providerAccessToken" TEXT, + "providerScope" TEXT, + "loggedIn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PasswordlessVerification" ( + "deviceIdentifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "expiresOn" TIMESTAMPTZ(3) NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid"); + +-- CreateIndex +CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail"); + +-- CreateIndex +CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordlessVerification_token_key" ON "PasswordlessVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordlessVerification_deviceIdentifier_token_key" ON "PasswordlessVerification"("deviceIdentifier", "token"); + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PasswordlessVerification" ADD CONSTRAINT "PasswordlessVerification_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml b/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..fbffa92c2 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts new file mode 100644 index 000000000..1405cfd99 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { signInMagicDto } from './dto/signin-magic.dto'; +import { verifyMagicDto } from './dto/verify-magic.dto'; + +@Controller('auth') +export class AuthController { + constructor(private authService: AuthService) {} + + @Post('signin') + async signIn(@Body() authData: signInMagicDto) { + return this.authService.signIn(authData.email); + } +} diff --git a/packages/hoppscotch-backend/src/auth/auth.module.ts b/packages/hoppscotch-backend/src/auth/auth.module.ts new file mode 100644 index 000000000..ee45655e1 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { UserModule } from 'src/user/user.module'; +import { MailerModule } from 'src/mailer/mailer.module'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule, UserModule, MailerModule], + providers: [AuthService], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/packages/hoppscotch-backend/src/auth/auth.service.spec.ts b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts new file mode 100644 index 000000000..800ab6626 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts new file mode 100644 index 000000000..fbe281ac1 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -0,0 +1,68 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { MailerService } from 'src/mailer/mailer.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { User } from 'src/user/user.model'; +import { UserService } from 'src/user/user.service'; +import { verifyMagicDto } from './dto/verify-magic.dto'; +import { DateTime } from 'luxon'; +import * as argon2 from 'argon2'; +import bcrypt from 'bcrypt'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import * as TE from 'fp-ts/TaskEither'; +import { PasswordlessToken } from 'src/types/Passwordless'; +import { EmailCodec } from 'src/types/Email'; +import { INVALID_EMAIL } from 'src/errors'; +import { pipe } from 'fp-ts/lib/function'; +import { validateEmail } from 'src/utils'; + +@Injectable() +export class AuthService { + constructor( + private usersService: UserService, + private prismaService: PrismaService, + private readonly mailerService: MailerService, + ) {} + + // generate Id and token for email magiclink + private async generatePasswordlessTokens(user: User) { + const salt = await bcrypt.genSalt(10); + const expiresOn = DateTime.now().plus({ hours: 3 }).toISO().toString(); + + const idToken: PasswordlessToken = + await this.prismaService.passwordlessVerification.create({ + data: { + expiresOn: expiresOn, + deviceIdentifier: salt, + userUid: user.id, + }, + }); + + return idToken; + } + + async signIn(email: string) { + if (!validateEmail(email)) return E.left(INVALID_EMAIL); + + let user: User; + const queriedUser = await this.usersService.findUserByEmail(email); + + if (O.isNone(queriedUser)) { + user = await this.usersService.createUser(email); + } else { + user = queriedUser.value; + } + + const generatedTokens = await this.generatePasswordlessTokens(user); + + this.mailerService.sendMail(email, { + template: 'code-your-own', + variables: { + inviteeEmail: email, + magicLink: `${process.env.APP_DOMAIN}/magic-link?token=${generatedTokens.token}`, + }, + }); + + return { deviceIdentifier: generatedTokens.deviceIdentifier }; + } +} diff --git a/packages/hoppscotch-backend/src/auth/dto/refresh-tokens.dto.ts b/packages/hoppscotch-backend/src/auth/dto/refresh-tokens.dto.ts new file mode 100644 index 000000000..4ad8d681d --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/refresh-tokens.dto.ts @@ -0,0 +1,3 @@ +export class refreshTokensDto { + refresh_token: string; +} diff --git a/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts b/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts new file mode 100644 index 000000000..21af042b7 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts @@ -0,0 +1,3 @@ +export class signInMagicDto { + email: string; +} diff --git a/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts b/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts new file mode 100644 index 000000000..ceaedcec9 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts @@ -0,0 +1,4 @@ +export class verifyMagicDto { + identifier: string; + token: string; +} diff --git a/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts new file mode 100644 index 000000000..f2a0d1b38 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts @@ -0,0 +1,16 @@ +export type MailDescription = { + template: 'team-invitation'; + variables: { + invitee: string; + invite_team_name: string; + action_url: string; + }; +}; + +export type UserMagicLinkMailDescription = { + template: 'code-your-own'; //Alias of template in Postmark, change this to env variable + variables: { + inviteeEmail: string; + magicLink: string; + }; +}; diff --git a/packages/hoppscotch-backend/src/mailer/mailer.module.ts b/packages/hoppscotch-backend/src/mailer/mailer.module.ts new file mode 100644 index 000000000..eba8e9c93 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/mailer.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MailerService } from './mailer.service'; + +@Module({ + providers: [MailerService], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/packages/hoppscotch-backend/src/mailer/mailer.service.ts b/packages/hoppscotch-backend/src/mailer/mailer.service.ts new file mode 100644 index 000000000..204b296a4 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/mailer.service.ts @@ -0,0 +1,40 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Email } from 'src/types/Email'; +import { + MailDescription, + UserMagicLinkMailDescription, +} from './MailDescriptions'; +import * as postmark from 'postmark'; +import { throwErr } from 'src/utils'; +import * as TE from 'fp-ts/TaskEither'; +import { EMAIL_FAILED } from 'src/errors'; + +@Injectable() +export class MailerService implements OnModuleInit { + client: postmark.ServerClient; + + onModuleInit() { + this.client = new postmark.ServerClient( + process.env.POSTMARK_SERVER_TOKEN || + throwErr('No Postmark Server Token defined'), + ); + } + + sendMail( + to: string, + mailDesc: MailDescription | UserMagicLinkMailDescription, + ) { + return TE.tryCatch( + () => + this.client.sendEmailWithTemplate({ + To: to, + From: + process.env.POSTMARK_SENDER_EMAIL || + throwErr('No Postmark Sender Email defined'), + TemplateAlias: mailDesc.template, + TemplateModel: mailDesc.variables, + }), + () => EMAIL_FAILED, + ); + } +} diff --git a/packages/hoppscotch-backend/src/types/Email.ts b/packages/hoppscotch-backend/src/types/Email.ts new file mode 100644 index 000000000..fd4144a8e --- /dev/null +++ b/packages/hoppscotch-backend/src/types/Email.ts @@ -0,0 +1,17 @@ +import * as t from 'io-ts'; + +interface EmailBrand { + readonly Email: unique symbol; +} + +// The validation branded type for an email +export const EmailCodec = t.brand( + t.string, + (x): x is t.Branded => + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + x, + ), + 'Email', +); + +export type Email = t.TypeOf; diff --git a/packages/hoppscotch-backend/src/types/Passwordless.ts b/packages/hoppscotch-backend/src/types/Passwordless.ts new file mode 100644 index 000000000..7478730db --- /dev/null +++ b/packages/hoppscotch-backend/src/types/Passwordless.ts @@ -0,0 +1,9 @@ +import { User } from 'src/user/user.model'; + +export interface PasswordlessToken { + deviceIdentifier: string; + token: string; + userUid: string; + user?: User; + expiresOn: Date; +} diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts new file mode 100644 index 000000000..19845981e --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import * as O from 'fp-ts/Option'; +import { User } from './user.model'; + +@Injectable() +export class UserService { + constructor(private prisma: PrismaService) {} + + async findUserByEmail(email: string) { + try { + const user: User = await this.prisma.user.findUniqueOrThrow({ + where: { + email: email, + }, + }); + return O.some(user); + } catch (error) { + return O.none; + } + } + + async createUser(email: string) { + const createdUser = await this.prisma.user.create({ + data: { + email: email, + accounts: { + create: { + provider: 'magic', + providerAccountId: email, + }, + }, + }, + }); + + return createdUser; + } + + async createUserSSO(accessToken, refreshToken, profile) { + const createdUser = await this.prisma.user.create({ + data: { + name: profile.displayName, + email: profile.emails[0].value, + image: profile.photos[0].value, + accounts: { + create: { + provider: profile.provider, + providerAccountId: profile.id, + providerRefreshToken: refreshToken, + providerAccessToken: accessToken, + }, + }, + }, + }); + + return createdUser; + } + + async createProviderAccount(user, accessToken, refreshToken, profile) { + const createdProvider = await this.prisma.account.create({ + data: { + userId: user.id, + provider: profile.provider, + providerAccountId: profile.id, + providerRefreshToken: refreshToken ? refreshToken : null, + providerAccessToken: accessToken ? accessToken : null, + }, + }); + + return createdProvider; + } +}