From d2968bc3122338599031f3671bbcd3a17b0b5129 Mon Sep 17 00:00:00 2001 From: Rajdip Bhattacharya Date: Sun, 12 May 2024 21:43:35 +0530 Subject: [PATCH] feat(api): Add OAuth redirection and polished authentication (#212) --- .env.example | 6 +- .../src/auth/controller/auth.controller.ts | 91 +++++++++++++++---- apps/api/src/auth/service/auth.service.ts | 23 ++++- apps/api/src/common/create-user.ts | 7 +- apps/api/src/common/env/env.schema.ts | 4 +- apps/api/src/common/redirect.ts | 28 ++++++ apps/api/src/common/set-cookie.ts | 16 ++++ .../migration.sql | 5 + apps/api/src/prisma/schema.prisma | 8 ++ apps/api/src/user/service/user.service.ts | 7 +- apps/api/src/user/user.e2e.spec.ts | 3 +- 11 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/common/redirect.ts create mode 100644 apps/api/src/common/set-cookie.ts create mode 100644 apps/api/src/prisma/migrations/20240512141423_add_auth_provider/migration.sql diff --git a/.env.example b/.env.example index abf6fb9f..becff0d3 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,9 @@ FROM_EMAIL="your-name " JWT_SECRET=secret -WEB_FRONTEND_URL=https://keyshade.xyz -WORKSPACE_FRONTEND_URL=https://app.keyshade.xyz +WEB_FRONTEND_URL=http://localhost:3000 +PLATFORM_FRONTEND_URL=http://localhost:3100 +PLATFORM_OAUTH_SUCCESS_REDIRECT_PATH=/oauth/signin +PLATFORM_OAUTH_FAILURE_REDIRECT_PATH=/oauth/failure DOMAIN=localhost \ No newline at end of file diff --git a/apps/api/src/auth/controller/auth.controller.ts b/apps/api/src/auth/controller/auth.controller.ts index cf1cb73e..d57df72c 100644 --- a/apps/api/src/auth/controller/auth.controller.ts +++ b/apps/api/src/auth/controller/auth.controller.ts @@ -3,11 +3,13 @@ import { Get, HttpException, HttpStatus, + Logger, Param, Post, Query, Req, Res, + UnprocessableEntityException, UseGuards } from '@nestjs/common' import { AuthService } from '../service/auth.service' @@ -24,10 +26,18 @@ import { GithubOAuthStrategyFactory } from '../../config/factory/github/github-s import { GoogleOAuthStrategyFactory } from '../../config/factory/google/google-strategy.factory' import { GitlabOAuthStrategyFactory } from '../../config/factory/gitlab/gitlab-strategy.factory' import { Response } from 'express' +import { AuthProvider } from '@prisma/client' +import setCookie from '../../common/set-cookie' +import { + sendOAuthFailureRedirect, + sendOAuthSuccessRedirect +} from '../../common/redirect' @ApiTags('Auth Controller') @Controller('auth') export class AuthController { + private readonly logger = new Logger(AuthController.name) + constructor( private authService: AuthService, private githubOAuthStrategyFactory: GithubOAuthStrategyFactory, @@ -97,12 +107,7 @@ export class AuthController { @Query('otp') otp: string, @Res({ passthrough: true }) response: Response ) { - const { token, ...user } = await this.authService.validateOtp(email, otp) - response.cookie('token', `Bearer ${token}`, { - domain: process.env.DOMAIN ?? 'localhost', - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days, - }) - return user + return setCookie(response, await this.authService.validateOtp(email, otp)) } /* istanbul ignore next */ @@ -144,12 +149,20 @@ export class AuthController { }) async githubOAuthCallback(@Req() req: any) { const { emails, displayName: name, photos } = req.user + + if (!emails.length) { + throw new UnprocessableEntityException( + 'Email information is missing from the OAuth provider data.' + ) + } const email = emails[0].value - const profilePictureUrl = photos[0].value - return await this.authService.handleOAuthLogin( + const profilePictureUrl = photos[0]?.value + + return this.authService.handleOAuthLogin( email, name, - profilePictureUrl + profilePictureUrl, + AuthProvider.GITHUB ) } @@ -190,13 +203,22 @@ export class AuthController { status: HttpStatus.OK, description: 'Logged in successfully' }) - async gitlabOAuthCallback(@Req() req: any) { + async gitlabOAuthCallback(@Req() req: any, @Res() res: Response) { const { emails, displayName: name, avatarUrl: profilePictureUrl } = req.user + + if (!emails.length) { + throw new UnprocessableEntityException( + 'Email information is missing from the OAuth provider data.' + ) + } const email = emails[0].value - return await this.authService.handleOAuthLogin( + + this.handleOAuthProcess( email, name, - profilePictureUrl + profilePictureUrl, + AuthProvider.GITLAB, + res ) } @@ -235,14 +257,51 @@ export class AuthController { status: HttpStatus.OK, description: 'Logged in successfully' }) - async googleOAuthCallback(@Req() req: any) { + async googleOAuthCallback(@Req() req: any, @Res() res: Response) { const { emails, displayName: name, photos } = req.user + + if (!emails.length) { + throw new UnprocessableEntityException( + 'Email information is missing from the OAuth provider data.' + ) + } const email = emails[0].value - const profilePictureUrl = photos[0].value - return await this.authService.handleOAuthLogin( + const profilePictureUrl = photos[0]?.value + + this.handleOAuthProcess( email, name, - profilePictureUrl + profilePictureUrl, + AuthProvider.GOOGLE, + res ) } + + /* istanbul ignore next */ + private async handleOAuthProcess( + email: string, + name: string, + profilePictureUrl: string, + oauthProvider: AuthProvider, + response: Response + ) { + try { + const data = await this.authService.handleOAuthLogin( + email, + name, + profilePictureUrl, + oauthProvider + ) + const user = setCookie(response, data) + sendOAuthSuccessRedirect(response, user) + } catch (error) { + this.logger.warn( + 'User attempted to log in with a different OAuth provider' + ) + sendOAuthFailureRedirect( + response, + 'User attempted to log in with a different OAuth provider' + ) + } + } } diff --git a/apps/api/src/auth/service/auth.service.ts b/apps/api/src/auth/service/auth.service.ts index 84fef52c..58f94502 100644 --- a/apps/api/src/auth/service/auth.service.ts +++ b/apps/api/src/auth/service/auth.service.ts @@ -17,6 +17,7 @@ import { } from '../../mail/services/interface.service' import { PrismaService } from '../../prisma/prisma.service' import createUser from '../../common/create-user' +import { AuthProvider } from '@prisma/client' @Injectable() export class AuthService { @@ -37,7 +38,7 @@ export class AuthService { throw new BadRequestException('Please enter a valid email address') } - const user = await this.createUserIfNotExists(email) + const user = await this.createUserIfNotExists(email, AuthProvider.EMAIL_OTP) const otp = await this.prisma.otp.upsert({ where: { @@ -114,11 +115,13 @@ export class AuthService { async handleOAuthLogin( email: string, name: string, - profilePictureUrl: string - ) { + profilePictureUrl: string, + oauthProvider: AuthProvider + ): Promise { // We need to create the user if it doesn't exist yet const user = await this.createUserIfNotExists( email, + oauthProvider, name, profilePictureUrl ) @@ -151,21 +154,33 @@ export class AuthService { private async createUserIfNotExists( email: string, + authProvider: AuthProvider, name?: string, profilePictureUrl?: string ) { let user = await this.findUserByEmail(email) + // We need to create the user if it doesn't exist yet if (!user) { user = await createUser( { email, name, - profilePictureUrl + profilePictureUrl, + authProvider }, this.prisma ) } + + // If the user has used OAuth to log in, we need to check if the OAuth provider + // used in the current login is different from the one stored in the database + if (user.authProvider !== authProvider) { + throw new UnauthorizedException( + 'The user has signed up with a different authentication provider.' + ) + } + return user } diff --git a/apps/api/src/common/create-user.ts b/apps/api/src/common/create-user.ts index 7891c19a..032d167d 100644 --- a/apps/api/src/common/create-user.ts +++ b/apps/api/src/common/create-user.ts @@ -1,11 +1,11 @@ -import { User, Workspace } from '@prisma/client' +import { AuthProvider, User, Workspace } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' import { CreateUserDto } from '../user/dto/create.user/create.user' import createWorkspace from './create-workspace' import { Logger } from '@nestjs/common' const createUser = async ( - dto: Partial, + dto: Partial & { authProvider: AuthProvider }, prisma: PrismaService ): Promise< User & { @@ -22,7 +22,8 @@ const createUser = async ( profilePictureUrl: dto.profilePictureUrl, isActive: dto.isActive ?? true, isAdmin: dto.isAdmin ?? false, - isOnboardingFinished: dto.isOnboardingFinished ?? false + isOnboardingFinished: dto.isOnboardingFinished ?? false, + authProvider: dto.authProvider } }) diff --git a/apps/api/src/common/env/env.schema.ts b/apps/api/src/common/env/env.schema.ts index a264c523..856b326d 100644 --- a/apps/api/src/common/env/env.schema.ts +++ b/apps/api/src/common/env/env.schema.ts @@ -64,7 +64,9 @@ const generalSchema = z.object({ JWT_SECRET: z.string(), WEB_FRONTEND_URL: z.string().url(), - WORKSPACE_FRONTEND_URL: z.string().url() + PLATFORM_FRONTEND_URL: z.string().url(), + PLATFORM_OAUTH_SUCCESS_REDIRECT_PATH: z.string(), + PLATFORM_OAUTH_FAILURE_REDIRECT_PATH: z.string() }) export type EnvSchemaType = z.infer diff --git a/apps/api/src/common/redirect.ts b/apps/api/src/common/redirect.ts new file mode 100644 index 00000000..f3f19d75 --- /dev/null +++ b/apps/api/src/common/redirect.ts @@ -0,0 +1,28 @@ +import { User } from '@prisma/client' +import { Response } from 'express' + +const platformFrontendUrl = process.env.PLATFORM_FRONTEND_URL +const platformOAuthSuccessRedirectPath = + process.env.PLATFORM_OAUTH_SUCCESS_REDIRECT_PATH +const platformOAuthFailureRedirectPath = + process.env.PLATFORM_OAUTH_FAILURE_REDIRECT_PATH +const platformOAuthSuccessRedirectUrl = `${platformFrontendUrl}${platformOAuthSuccessRedirectPath}` +const platformOAuthFailureRedirectUrl = `${platformFrontendUrl}${platformOAuthFailureRedirectPath}` + +/* istanbul ignore next */ +export function sendOAuthFailureRedirect(response: Response, reason: string) { + response + .status(302) + .redirect(`${platformOAuthSuccessRedirectUrl}?reason=${reason}`) +} + +/* istanbul ignore next */ +export function sendOAuthSuccessRedirect(response: Response, user: User) { + response + .status(302) + .redirect( + `${platformOAuthFailureRedirectUrl}?data=${encodeURIComponent( + JSON.stringify(user) + )}` + ) +} diff --git a/apps/api/src/common/set-cookie.ts b/apps/api/src/common/set-cookie.ts new file mode 100644 index 00000000..e1dc5e7a --- /dev/null +++ b/apps/api/src/common/set-cookie.ts @@ -0,0 +1,16 @@ +import { User } from '@prisma/client' +import { Response } from 'express' +import { UserAuthenticatedResponse } from '../auth/auth.types' + +/* istanbul ignore next */ +export default function setCookie( + response: Response, + data: UserAuthenticatedResponse +): User { + const { token, ...user } = data + response.cookie('token', `Bearer ${token}`, { + domain: process.env.DOMAIN ?? 'localhost', + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days, + }) + return user +} diff --git a/apps/api/src/prisma/migrations/20240512141423_add_auth_provider/migration.sql b/apps/api/src/prisma/migrations/20240512141423_add_auth_provider/migration.sql new file mode 100644 index 00000000..70551e13 --- /dev/null +++ b/apps/api/src/prisma/migrations/20240512141423_add_auth_provider/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "AuthProvider" AS ENUM ('GOOGLE', 'GITHUB', 'GITLAB', 'EMAIL_OTP'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "authProvider" "AuthProvider"; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 43866952..6d665fe4 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -165,6 +165,13 @@ enum IntegrationType { GITLAB } +enum AuthProvider { + GOOGLE + GITHUB + GITLAB + EMAIL_OTP +} + model Event { id String @id @default(cuid()) source EventSource @@ -202,6 +209,7 @@ model User { isActive Boolean @default(true) isOnboardingFinished Boolean @default(false) isAdmin Boolean @default(false) + authProvider AuthProvider? subscription Subscription? workspaceMembers WorkspaceMember[] diff --git a/apps/api/src/user/service/user.service.ts b/apps/api/src/user/service/user.service.ts index 51c68e30..0a0918bd 100644 --- a/apps/api/src/user/service/user.service.ts +++ b/apps/api/src/user/service/user.service.ts @@ -1,6 +1,6 @@ import { ConflictException, Inject, Injectable, Logger } from '@nestjs/common' import { UpdateUserDto } from '../dto/update.user/update.user' -import { User } from '@prisma/client' +import { AuthProvider, User } from '@prisma/client' import { PrismaService } from '../../prisma/prisma.service' import { CreateUserDto } from '../dto/create.user/create.user' import { @@ -142,7 +142,10 @@ export class UserService { } // Create the user's default workspace - const newUser = await createUser(user, this.prisma) + const newUser = await createUser( + { authProvider: AuthProvider.EMAIL_OTP, ...user }, + this.prisma + ) this.log.log(`Created user with email ${user.email}`) await this.mailService.accountLoginEmail(newUser.email) diff --git a/apps/api/src/user/user.e2e.spec.ts b/apps/api/src/user/user.e2e.spec.ts index 4352c0b7..3ccf6121 100644 --- a/apps/api/src/user/user.e2e.spec.ts +++ b/apps/api/src/user/user.e2e.spec.ts @@ -6,7 +6,7 @@ import { Test } from '@nestjs/testing' import { UserModule } from './user.module' import { PrismaService } from '../prisma/prisma.service' import { AppModule } from '../app/app.module' -import { User } from '@prisma/client' +import { AuthProvider, User } from '@prisma/client' import { MAIL_SERVICE } from '../mail/services/interface.service' import { MockMailService } from '../mail/services/mock.service' import { UserService } from './service/user.service' @@ -305,6 +305,7 @@ describe('User Controller Tests', () => { ...payload, id: expect.any(String), profilePictureUrl: null, + authProvider: AuthProvider.EMAIL_OTP, defaultWorkspace: expect.any(Object) }) })