diff --git a/docker-compose.yml b/docker-compose.yml index b7f3d2f52..b17c726a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,11 +118,11 @@ services: restart: always environment: # Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) - - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 + # - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 - PORT=3000 volumes: # Uncomment the line below when modifying code. Only applicable when using the "dev" target. - # - ./packages/hoppscotch-backend/:/usr/src/app + - ./packages/hoppscotch-backend/:/usr/src/app - /usr/src/app/node_modules/ depends_on: hoppscotch-db: diff --git a/packages/hoppscotch-backend/prisma/migrations/20240520091033_personal_access_tokens/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240520091033_personal_access_tokens/migration.sql new file mode 100644 index 000000000..53abda80b --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240520091033_personal_access_tokens/migration.sql @@ -0,0 +1,19 @@ + +-- CreateTable +CREATE TABLE "PersonalAccessToken" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "label" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresOn" TIMESTAMP(3), + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token"); + +-- AddForeignKey +ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 0da385415..50b29ea99 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -89,25 +89,26 @@ model TeamEnvironment { } model User { - uid String @id @default(cuid()) - displayName String? - email String? @unique - photoURL String? - isAdmin Boolean @default(false) - refreshToken String? - providerAccounts Account[] - VerificationToken VerificationToken[] - settings UserSettings? - UserHistory UserHistory[] - UserEnvironments UserEnvironment[] - userCollections UserCollection[] - userRequests UserRequest[] - currentRESTSession Json? - currentGQLSession Json? - lastLoggedOn DateTime? - createdOn DateTime @default(now()) @db.Timestamp(3) - invitedUsers InvitedUsers[] - shortcodes Shortcode[] + uid String @id @default(cuid()) + displayName String? + email String? @unique + photoURL String? + isAdmin Boolean @default(false) + refreshToken String? + providerAccounts Account[] + VerificationToken VerificationToken[] + settings UserSettings? + UserHistory UserHistory[] + UserEnvironments UserEnvironment[] + userCollections UserCollection[] + userRequests UserRequest[] + currentRESTSession Json? + currentGQLSession Json? + lastLoggedOn DateTime? + createdOn DateTime @default(now()) @db.Timestamp(3) + invitedUsers InvitedUsers[] + shortcodes Shortcode[] + personalAccessTokens PersonalAccessToken[] } model Account { @@ -219,3 +220,14 @@ model InfraConfig { createdOn DateTime @default(now()) @db.Timestamp(3) updatedOn DateTime @updatedAt @db.Timestamp(3) } + +model PersonalAccessToken { + id String @id @default(cuid()) + userUid String + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + label String + token String @unique @default(uuid()) + expiresOn DateTime? @db.Timestamp(3) + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) +} diff --git a/packages/hoppscotch-backend/src/access-token/access-token.controller.ts b/packages/hoppscotch-backend/src/access-token/access-token.controller.ts new file mode 100644 index 000000000..4b1bd8460 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.controller.ts @@ -0,0 +1,107 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpStatus, + Param, + ParseIntPipe, + Post, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { AccessTokenService } from './access-token.service'; +import { CreateAccessTokenDto } from './dto/create-access-token.dto'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import * as E from 'fp-ts/Either'; +import { throwHTTPErr } from 'src/utils'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { AuthUser } from 'src/types/AuthUser'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard'; +import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor'; +import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors'; +import { createCLIErrorResponse } from './helper'; + +@UseGuards(ThrottlerBehindProxyGuard) +@Controller({ path: 'access-tokens', version: '1' }) +export class AccessTokenController { + constructor( + private readonly accessTokenService: AccessTokenService, + private readonly teamCollectionService: TeamCollectionService, + private readonly teamEnvironmentsService: TeamEnvironmentsService, + ) {} + + @Post('create') + @UseGuards(JwtAuthGuard) + async createPAT( + @GqlUser() user: AuthUser, + @Body() createAccessTokenDto: CreateAccessTokenDto, + ) { + const result = await this.accessTokenService.createPAT( + createAccessTokenDto, + user, + ); + if (E.isLeft(result)) throwHTTPErr(result.left); + return result.right; + } + + @Delete('revoke') + @UseGuards(JwtAuthGuard) + async deletePAT(@Query('id') id: string) { + const result = await this.accessTokenService.deletePAT(id); + + if (E.isLeft(result)) throwHTTPErr(result.left); + return result.right; + } + + @Get('list') + @UseGuards(JwtAuthGuard) + async listAllUserPAT( + @GqlUser() user: AuthUser, + @Query('offset', ParseIntPipe) offset: number, + @Query('limit', ParseIntPipe) limit: number, + ) { + return await this.accessTokenService.listAllUserPAT( + user.uid, + offset, + limit, + ); + } + + @Get('collection/:id') + @UseGuards(PATAuthGuard) + @UseInterceptors(AccessTokenInterceptor) + async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) { + const res = await this.teamCollectionService.getCollectionForCLI( + id, + user.uid, + ); + + if (E.isLeft(res)) + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID), + ); + return res.right; + } + + @Get('environment/:id') + @UseGuards(PATAuthGuard) + @UseInterceptors(AccessTokenInterceptor) + async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) { + const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI( + id, + user.uid, + ); + + if (E.isLeft(res)) + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID), + ); + return res.right; + } +} diff --git a/packages/hoppscotch-backend/src/access-token/access-token.module.ts b/packages/hoppscotch-backend/src/access-token/access-token.module.ts new file mode 100644 index 000000000..344f6f07f --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { AccessTokenController } from './access-token.controller'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { AccessTokenService } from './access-token.service'; +import { TeamCollectionModule } from 'src/team-collection/team-collection.module'; +import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module'; +import { TeamModule } from 'src/team/team.module'; + +@Module({ + imports: [ + PrismaModule, + TeamCollectionModule, + TeamEnvironmentsModule, + TeamModule, + ], + controllers: [AccessTokenController], + providers: [AccessTokenService], + exports: [AccessTokenService], +}) +export class AccessTokenModule {} diff --git a/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts b/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts new file mode 100644 index 000000000..4862e932a --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts @@ -0,0 +1,195 @@ +import { AccessTokenService } from './access-token.service'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + ACCESS_TOKEN_EXPIRY_INVALID, + ACCESS_TOKEN_LABEL_SHORT, + ACCESS_TOKEN_NOT_FOUND, +} from 'src/errors'; +import { AuthUser } from 'src/types/AuthUser'; +import { PersonalAccessToken } from '@prisma/client'; +import { AccessToken } from 'src/types/AccessToken'; +import { HttpStatus } from '@nestjs/common'; + +const mockPrisma = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const accessTokenService = new AccessTokenService(mockPrisma); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, + lastLoggedOn: currentTime, +}; + +const PATCreatedOn = new Date(); +const expiryInDays = 7; +const PATExpiresOn = new Date( + PATCreatedOn.getTime() + expiryInDays * 24 * 60 * 60 * 1000, +); + +const userAccessToken: PersonalAccessToken = { + id: 'skfvhj8uvdfivb', + userUid: user.uid, + label: 'test', + token: '0140e328-b187-4823-ae4b-ed4bec832ac2', + expiresOn: PATExpiresOn, + createdOn: PATCreatedOn, + updatedOn: new Date(), +}; + +const userAccessTokenCasted: AccessToken = { + id: userAccessToken.id, + label: userAccessToken.label, + createdOn: userAccessToken.createdOn, + lastUsedOn: userAccessToken.updatedOn, + expiresOn: userAccessToken.expiresOn, +}; + +beforeEach(() => { + mockReset(mockPrisma); +}); + +describe('AccessTokenService', () => { + describe('createPAT', () => { + test('should throw ACCESS_TOKEN_LABEL_SHORT if label is too short', async () => { + const result = await accessTokenService.createPAT( + { + label: 'a', + expiryInDays: 7, + }, + user, + ); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_LABEL_SHORT, + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + test('should throw ACCESS_TOKEN_EXPIRY_INVALID if expiry date is invalid', async () => { + const result = await accessTokenService.createPAT( + { + label: 'test', + expiryInDays: 9, + }, + user, + ); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_EXPIRY_INVALID, + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + test('should successfully create a new Access Token', async () => { + mockPrisma.personalAccessToken.create.mockResolvedValueOnce( + userAccessToken, + ); + + const result = await accessTokenService.createPAT( + { + label: userAccessToken.label, + expiryInDays, + }, + user, + ); + expect(result).toEqualRight({ + token: `pat-${userAccessToken.token}`, + info: userAccessTokenCasted, + }); + }); + }); + + describe('deletePAT', () => { + test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => { + mockPrisma.personalAccessToken.delete.mockRejectedValueOnce( + 'RecordNotFound', + ); + + const result = await accessTokenService.deletePAT(userAccessToken.id); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('should successfully delete a new Access Token', async () => { + mockPrisma.personalAccessToken.delete.mockResolvedValueOnce( + userAccessToken, + ); + + const result = await accessTokenService.deletePAT(userAccessToken.id); + expect(result).toEqualRight(true); + }); + }); + + describe('listAllUserPAT', () => { + test('should successfully return a list of user Access Tokens', async () => { + mockPrisma.personalAccessToken.findMany.mockResolvedValueOnce([ + userAccessToken, + ]); + + const result = await accessTokenService.listAllUserPAT(user.uid, 0, 10); + expect(result).toEqual([userAccessTokenCasted]); + }); + }); + + describe('getUserPAT', () => { + test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => { + mockPrisma.personalAccessToken.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await accessTokenService.getUserPAT(userAccessToken.token); + expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND); + }); + + test('should successfully return a user Access Tokens', async () => { + mockPrisma.personalAccessToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...userAccessToken, + user, + } as any); + + const result = await accessTokenService.getUserPAT( + `pat-${userAccessToken.token}`, + ); + expect(result).toEqualRight({ + user, + ...userAccessToken, + } as any); + }); + }); + + describe('updateLastUsedforPAT', () => { + test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => { + mockPrisma.personalAccessToken.update.mockRejectedValueOnce( + 'RecordNotFound', + ); + + const result = await accessTokenService.updateLastUsedForPAT( + userAccessToken.token, + ); + expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND); + }); + + test('should successfully update lastUsedOn for a user Access Tokens', async () => { + mockPrisma.personalAccessToken.update.mockResolvedValueOnce( + userAccessToken, + ); + + const result = await accessTokenService.updateLastUsedForPAT( + `pat-${userAccessToken.token}`, + ); + expect(result).toEqualRight(userAccessTokenCasted); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/access-token/access-token.service.ts b/packages/hoppscotch-backend/src/access-token/access-token.service.ts new file mode 100644 index 000000000..3b5f3c87a --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.service.ts @@ -0,0 +1,203 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { CreateAccessTokenDto } from './dto/create-access-token.dto'; +import { AuthUser } from 'src/types/AuthUser'; +import { isValidLength } from 'src/utils'; +import * as E from 'fp-ts/Either'; +import { + ACCESS_TOKEN_EXPIRY_INVALID, + ACCESS_TOKEN_LABEL_SHORT, + ACCESS_TOKEN_NOT_FOUND, +} from 'src/errors'; +import { CreateAccessTokenResponse } from './helper'; +import { PersonalAccessToken } from '@prisma/client'; +import { AccessToken } from 'src/types/AccessToken'; +@Injectable() +export class AccessTokenService { + constructor(private readonly prisma: PrismaService) {} + + TITLE_LENGTH = 3; + VALID_TOKEN_DURATIONS = [7, 30, 60, 90]; + TOKEN_PREFIX = 'pat-'; + + /** + * Calculate the expiration date of the token + * + * @param expiresOn Number of days the token is valid for + * @returns Date object of the expiration date + */ + private calculateExpirationDate(expiresOn: null | number) { + if (expiresOn === null) return null; + return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000); + } + + /** + * Validate the expiration date of the token + * + * @param expiresOn Number of days the token is valid for + * @returns Boolean indicating if the expiration date is valid + */ + private validateExpirationDate(expiresOn: null | number) { + if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn)) + return true; + return false; + } + + /** + * Typecast a database PersonalAccessToken to a AccessToken model + * @param token database PersonalAccessToken + * @returns AccessToken model + */ + private cast(token: PersonalAccessToken): AccessToken { + return { + id: token.id, + label: token.label, + createdOn: token.createdOn, + expiresOn: token.expiresOn, + lastUsedOn: token.updatedOn, + }; + } + + /** + * Extract UUID from the token + * + * @param token Personal Access Token + * @returns UUID of the token + */ + private extractUUID(token): string | null { + if (!token.startsWith(this.TOKEN_PREFIX)) return null; + return token.slice(this.TOKEN_PREFIX.length); + } + + /** + * Create a Personal Access Token + * + * @param createAccessTokenDto DTO for creating a Personal Access Token + * @param user AuthUser object + * @returns Either of the created token or error message + */ + async createPAT(createAccessTokenDto: CreateAccessTokenDto, user: AuthUser) { + const isTitleValid = isValidLength( + createAccessTokenDto.label, + this.TITLE_LENGTH, + ); + if (!isTitleValid) + return E.left({ + message: ACCESS_TOKEN_LABEL_SHORT, + statusCode: HttpStatus.BAD_REQUEST, + }); + + if (!this.validateExpirationDate(createAccessTokenDto.expiryInDays)) + return E.left({ + message: ACCESS_TOKEN_EXPIRY_INVALID, + statusCode: HttpStatus.BAD_REQUEST, + }); + + const createdPAT = await this.prisma.personalAccessToken.create({ + data: { + userUid: user.uid, + label: createAccessTokenDto.label, + expiresOn: this.calculateExpirationDate( + createAccessTokenDto.expiryInDays, + ), + }, + }); + + const res: CreateAccessTokenResponse = { + token: `${this.TOKEN_PREFIX}${createdPAT.token}`, + info: this.cast(createdPAT), + }; + + return E.right(res); + } + + /** + * Delete a Personal Access Token + * + * @param accessTokenID ID of the Personal Access Token + * @returns Either of true or error message + */ + async deletePAT(accessTokenID: string) { + try { + await this.prisma.personalAccessToken.delete({ + where: { id: accessTokenID }, + }); + return E.right(true); + } catch { + return E.left({ + message: ACCESS_TOKEN_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + } + } + + /** + * List all Personal Access Tokens of a user + * + * @param userUid UID of the user + * @param offset Offset for pagination + * @param limit Limit for pagination + * @returns Either of the list of Personal Access Tokens or error message + */ + async listAllUserPAT(userUid: string, offset: number, limit: number) { + const userPATs = await this.prisma.personalAccessToken.findMany({ + where: { + userUid: userUid, + }, + skip: offset, + take: limit, + orderBy: { + createdOn: 'desc', + }, + }); + + const userAccessTokenList = userPATs.map((pat) => this.cast(pat)); + + return userAccessTokenList; + } + + /** + * Get a Personal Access Token + * + * @param accessToken Personal Access Token + * @returns Either of the Personal Access Token or error message + */ + async getUserPAT(accessToken: string) { + const extractedToken = this.extractUUID(accessToken); + if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND); + + try { + const userPAT = await this.prisma.personalAccessToken.findUniqueOrThrow({ + where: { token: extractedToken }, + include: { user: true }, + }); + return E.right(userPAT); + } catch { + return E.left(ACCESS_TOKEN_NOT_FOUND); + } + } + + /** + * Update the last used date of a Personal Access Token + * + * @param token Personal Access Token + * @returns Either of the updated Personal Access Token or error message + */ + async updateLastUsedForPAT(token: string) { + const extractedToken = this.extractUUID(token); + if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND); + + try { + const updatedAccessToken = await this.prisma.personalAccessToken.update({ + where: { token: extractedToken }, + data: { + updatedOn: new Date(), + }, + }); + + return E.right(this.cast(updatedAccessToken)); + } catch { + return E.left(ACCESS_TOKEN_NOT_FOUND); + } + } +} diff --git a/packages/hoppscotch-backend/src/access-token/dto/create-access-token.dto.ts b/packages/hoppscotch-backend/src/access-token/dto/create-access-token.dto.ts new file mode 100644 index 000000000..d837a16a4 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/dto/create-access-token.dto.ts @@ -0,0 +1,5 @@ +// Inputs to create a new PAT +export class CreateAccessTokenDto { + label: string; + expiryInDays: number | null; +} diff --git a/packages/hoppscotch-backend/src/access-token/helper.ts b/packages/hoppscotch-backend/src/access-token/helper.ts new file mode 100644 index 000000000..dc52ed517 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/helper.ts @@ -0,0 +1,17 @@ +import { AccessToken } from 'src/types/AccessToken'; + +// Response type of PAT creation method +export type CreateAccessTokenResponse = { + token: string; + info: AccessToken; +}; + +// Response type of any error in PAT module +export type CLIErrorResponse = { + reason: string; +}; + +// Return a CLIErrorResponse object +export function createCLIErrorResponse(reason: string): CLIErrorResponse { + return { reason }; +} diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 4ba191ad2..3323fc3a0 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -27,6 +27,7 @@ import { MailerModule } from './mailer/mailer.module'; import { PosthogModule } from './posthog/posthog.module'; import { ScheduleModule } from '@nestjs/schedule'; import { HealthModule } from './health/health.module'; +import { AccessTokenModule } from './access-token/access-token.module'; @Module({ imports: [ @@ -102,6 +103,7 @@ import { HealthModule } from './health/health.module'; PosthogModule, ScheduleModule.forRoot(), HealthModule, + AccessTokenModule, ], providers: [GQLComplexityPlugin], controllers: [AppController], diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 208a80464..3c7725ae8 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -761,3 +761,39 @@ export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized'; * Inputs supplied are invalid */ export const INVALID_PARAMS = 'invalid_parameters' as const; + +/** + * The provided label for the access-token is short (less than 3 characters) + * (AccessTokenService) + */ +export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short'; + +/** + * The provided expiryInDays value is not valid + * (AccessTokenService) + */ +export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid'; + +/** + * The provided PAT ID is invalid + * (AccessTokenService) + */ +export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found'; + +/** + * AccessTokens is expired + * (AccessTokenService) + */ +export const ACCESS_TOKENS_EXPIRED = 'TOKEN_EXPIRED'; + +/** + * AccessTokens is invalid + * (AccessTokenService) + */ +export const ACCESS_TOKENS_INVALID = 'TOKEN_INVALID'; + +/** + * AccessTokens is invalid + * (AccessTokenService) + */ +export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID'; diff --git a/packages/hoppscotch-backend/src/guards/rest-pat-auth.guard.ts b/packages/hoppscotch-backend/src/guards/rest-pat-auth.guard.ts new file mode 100644 index 000000000..8c65f3072 --- /dev/null +++ b/packages/hoppscotch-backend/src/guards/rest-pat-auth.guard.ts @@ -0,0 +1,48 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import { Request } from 'express'; +import { AccessTokenService } from 'src/access-token/access-token.service'; +import * as E from 'fp-ts/Either'; +import { DateTime } from 'luxon'; +import { ACCESS_TOKENS_EXPIRED, ACCESS_TOKENS_INVALID } from 'src/errors'; +import { createCLIErrorResponse } from 'src/access-token/helper'; +@Injectable() +export class PATAuthGuard implements CanActivate { + constructor(private accessTokenService: AccessTokenService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID), + ); + } + + const userAccessToken = await this.accessTokenService.getUserPAT(token); + if (E.isLeft(userAccessToken)) + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID), + ); + request.user = userAccessToken.right.user; + + const accessToken = userAccessToken.right; + if (accessToken.expiresOn === null) return true; + + const today = DateTime.now().toISO(); + if (accessToken.expiresOn.toISOString() > today) return true; + + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_EXPIRED), + ); + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/packages/hoppscotch-backend/src/interceptors/access-token.interceptor.ts b/packages/hoppscotch-backend/src/interceptors/access-token.interceptor.ts new file mode 100644 index 000000000..23433e918 --- /dev/null +++ b/packages/hoppscotch-backend/src/interceptors/access-token.interceptor.ts @@ -0,0 +1,34 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + UnauthorizedException, +} from '@nestjs/common'; +import { Observable, map } from 'rxjs'; +import { AccessTokenService } from 'src/access-token/access-token.service'; +import * as E from 'fp-ts/Either'; + +@Injectable() +export class AccessTokenInterceptor implements NestInterceptor { + constructor(private readonly accessTokenService: AccessTokenService) {} + + intercept(context: ExecutionContext, handler: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.split(' ')[1]; + if (!token) { + throw new UnauthorizedException(); + } + + return handler.handle().pipe( + map(async (data) => { + const userAccessToken = + await this.accessTokenService.updateLastUsedForPAT(token); + if (E.isLeft(userAccessToken)) throw new UnauthorizedException(); + + return data; + }), + ); + } +} diff --git a/packages/hoppscotch-backend/src/team-collection/helper.ts b/packages/hoppscotch-backend/src/team-collection/helper.ts index 1958a0a50..b88577126 100644 --- a/packages/hoppscotch-backend/src/team-collection/helper.ts +++ b/packages/hoppscotch-backend/src/team-collection/helper.ts @@ -1,3 +1,5 @@ +import { TeamRequest } from '@prisma/client'; + // Type of data returned from the query to obtain all search results export type SearchQueryReturnType = { id: string; @@ -12,3 +14,12 @@ export type ParentTreeQueryReturnType = { parentID: string; title: string; }; +// Type of data returned from the query to fetch collection details from CLI +export type GetCollectionResponse = { + id: string; + data: string | null; + title: string; + parentID: string | null; + folders: GetCollectionResponse[]; + requests: TeamRequest[]; +}; diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts index d7bea4036..f5ed42217 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts @@ -12,6 +12,7 @@ import { TEAM_COL_REORDERING_FAILED, TEAM_COL_SAME_NEXT_COLL, TEAM_INVALID_COLL_ID, + TEAM_MEMBER_NOT_FOUND, TEAM_NOT_OWNER, } from 'src/errors'; import { PrismaService } from 'src/prisma/prisma.service'; @@ -19,15 +20,18 @@ import { PubSubService } from 'src/pubsub/pubsub.service'; import { AuthUser } from 'src/types/AuthUser'; import { TeamCollectionService } from './team-collection.service'; import { TeamCollection } from './team-collection.model'; +import { TeamService } from 'src/team/team.service'; const mockPrisma = mockDeep(); const mockPubSub = mockDeep(); +const mockTeamService = mockDeep(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const teamCollectionService = new TeamCollectionService( mockPrisma, mockPubSub as any, + mockTeamService, ); const currentTime = new Date(); @@ -1739,3 +1743,63 @@ describe('updateTeamCollection', () => { }); //ToDo: write test cases for exportCollectionsToJSON + +describe('getCollectionForCLI', () => { + test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => { + mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await teamCollectionService.getCollectionForCLI( + 'invalidID', + user.uid, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => { + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + mockTeamService.getTeamMember.mockResolvedValue(null); + + const result = await teamCollectionService.getCollectionForCLI( + rootTeamCollection.id, + user.uid, + ); + expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND); + }); + + // test('should return the TeamCollection data for CLI', async () => { + // mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + // rootTeamCollection, + // ); + // mockTeamService.getTeamMember.mockResolvedValue({ + // membershipID: 'sdc3sfdv', + // userUid: user.uid, + // role: TeamMemberRole.OWNER, + // }); + + // const result = await teamCollectionService.getCollectionForCLI( + // rootTeamCollection.id, + // user.uid, + // ); + // expect(result).toEqualRight({ + // id: rootTeamCollection.id, + // data: JSON.stringify(rootTeamCollection.data), + // title: rootTeamCollection.title, + // parentID: rootTeamCollection.parentID, + // folders: [ + // { + // id: childTeamCollection.id, + // data: JSON.stringify(childTeamCollection.data), + // title: childTeamCollection.title, + // parentID: childTeamCollection.parentID, + // folders: [], + // requests: [], + // }, + // ], + // requests: [], + // }); + // }); +}); diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index 76dd45411..b8af9de92 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -18,23 +18,34 @@ import { TEAM_COL_SEARCH_FAILED, TEAM_REQ_PARENT_TREE_GEN_FAILED, TEAM_COLL_PARENT_TREE_GEN_FAILED, + TEAM_MEMBER_NOT_FOUND, } from '../errors'; import { PubSubService } from '../pubsub/pubsub.service'; import { escapeSqlLikeString, isValidLength } from 'src/utils'; import * as E from 'fp-ts/Either'; import * as O from 'fp-ts/Option'; -import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client'; +import { + Prisma, + TeamCollection as DBTeamCollection, + TeamRequest, +} from '@prisma/client'; import { CollectionFolder } from 'src/types/CollectionFolder'; import { stringToJson } from 'src/utils'; import { CollectionSearchNode } from 'src/types/CollectionSearchNode'; -import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper'; +import { + GetCollectionResponse, + ParentTreeQueryReturnType, + SearchQueryReturnType, +} from './helper'; import { RESTError } from 'src/types/RESTError'; +import { TeamService } from 'src/team/team.service'; @Injectable() export class TeamCollectionService { constructor( private readonly prisma: PrismaService, private readonly pubsub: PubSubService, + private readonly teamService: TeamService, ) {} TITLE_LENGTH = 3; @@ -1344,4 +1355,95 @@ export class TeamCollectionService { return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED); } } + + /** + * Get all requests in a collection + * + * @param collectionID The Collection ID + * @returns A list of all requests in the collection + */ + private async getAllRequestsInCollection(collectionID: string) { + const dbTeamRequests = await this.prisma.teamRequest.findMany({ + where: { + collectionID: collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + const teamRequests = dbTeamRequests.map((tr) => { + return { + id: tr.id, + collectionID: tr.collectionID, + teamID: tr.teamID, + title: tr.title, + request: JSON.stringify(tr.request), + }; + }); + + return teamRequests; + } + + /** + * Get Collection Tree for CLI + * + * @param parentID The parent Collection ID + * @returns Collection tree for CLI + */ + private async getCollectionTreeForCLI(parentID: string | null) { + const childCollections = await this.prisma.teamCollection.findMany({ + where: { parentID }, + orderBy: { orderIndex: 'asc' }, + }); + + const response: GetCollectionResponse[] = []; + + for (const collection of childCollections) { + const folder: GetCollectionResponse = { + id: collection.id, + data: collection.data === null ? null : JSON.stringify(collection.data), + title: collection.title, + parentID: collection.parentID, + folders: await this.getCollectionTreeForCLI(collection.id), + requests: await this.getAllRequestsInCollection(collection.id), + }; + + response.push(folder); + } + + return response; + } + + /** + * Get Collection for CLI + * + * @param collectionID The Collection ID + * @param userUid The User UID + * @returns An Either of the Collection details + */ + async getCollectionForCLI(collectionID: string, userUid: string) { + try { + const collection = await this.prisma.teamCollection.findUniqueOrThrow({ + where: { id: collectionID }, + }); + + const teamMember = await this.teamService.getTeamMember( + collection.teamID, + userUid, + ); + if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND); + + return E.right({ + id: collection.id, + data: collection.data === null ? null : JSON.stringify(collection.data), + title: collection.title, + parentID: collection.parentID, + folders: await this.getCollectionTreeForCLI(collection.id), + requests: await this.getAllRequestsInCollection(collection.id), + }); + } catch (error) { + return E.left(TEAM_COLL_NOT_FOUND); + } + } } diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts index 466dddf69..a1f4d0a23 100644 --- a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts @@ -6,19 +6,24 @@ import { JSON_INVALID, TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_SHORT_NAME, + TEAM_MEMBER_NOT_FOUND, } from 'src/errors'; +import { TeamService } from 'src/team/team.service'; +import { TeamMemberRole } from 'src/team/team.model'; const mockPrisma = mockDeep(); const mockPubSub = { publish: jest.fn().mockResolvedValue(null), }; +const mockTeamService = mockDeep(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const teamEnvironmentsService = new TeamEnvironmentsService( mockPrisma, mockPubSub as any, + mockTeamService, ); const teamEnvironment = { @@ -380,4 +385,47 @@ describe('TeamEnvironmentsService', () => { expect(result).toEqual(0); }); }); + + describe('getTeamEnvironmentForCLI', () => { + test('should successfully return a TeamEnvironment with valid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, + ); + mockTeamService.getTeamMember.mockResolvedValue({ + membershipID: 'sdc3sfdv', + userUid: '123454', + role: TeamMemberRole.OWNER, + }); + + const result = await teamEnvironmentsService.getTeamEnvironmentForCLI( + teamEnvironment.id, + '123454', + ); + expect(result).toEqualRight(teamEnvironment); + }); + + test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce( + 'RejectOnNotFound', + ); + + const result = await teamEnvironmentsService.getTeamEnvironment( + teamEnvironment.id, + ); + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, + ); + mockTeamService.getTeamMember.mockResolvedValue(null); + + const result = await teamEnvironmentsService.getTeamEnvironmentForCLI( + teamEnvironment.id, + '333', + ); + expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND); + }); + }); }); diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts index 7af9481e5..f2b28b70b 100644 --- a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts @@ -6,14 +6,17 @@ import { TeamEnvironment } from './team-environments.model'; import { TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_SHORT_NAME, + TEAM_MEMBER_NOT_FOUND, } from 'src/errors'; import * as E from 'fp-ts/Either'; import { isValidLength } from 'src/utils'; +import { TeamService } from 'src/team/team.service'; @Injectable() export class TeamEnvironmentsService { constructor( private readonly prisma: PrismaService, private readonly pubsub: PubSubService, + private readonly teamService: TeamService, ) {} TITLE_LENGTH = 3; @@ -242,4 +245,30 @@ export class TeamEnvironmentsService { }); return envCount; } + + /** + * Get details of a TeamEnvironment for CLI. + * + * @param id TeamEnvironment ID + * @param userUid User UID + * @returns Either of a TeamEnvironment or error message + */ + async getTeamEnvironmentForCLI(id: string, userUid: string) { + try { + const teamEnvironment = + await this.prisma.teamEnvironment.findFirstOrThrow({ + where: { id }, + }); + + const teamMember = await this.teamService.getTeamMember( + teamEnvironment.teamID, + userUid, + ); + if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND); + + return E.right(teamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } } diff --git a/packages/hoppscotch-backend/src/types/AccessToken.ts b/packages/hoppscotch-backend/src/types/AccessToken.ts new file mode 100644 index 000000000..b467eafd3 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AccessToken.ts @@ -0,0 +1,7 @@ +export type AccessToken = { + id: string; + label: string; + createdOn: Date; + lastUsedOn: Date; + expiresOn: null | Date; +}; diff --git a/packages/hoppscotch-backend/src/types/RESTError.ts b/packages/hoppscotch-backend/src/types/RESTError.ts index 367c51dc0..4aa7d1b6e 100644 --- a/packages/hoppscotch-backend/src/types/RESTError.ts +++ b/packages/hoppscotch-backend/src/types/RESTError.ts @@ -5,6 +5,6 @@ import { HttpStatus } from '@nestjs/common'; ** Since its REST we need to return the HTTP status code along with the error message */ export type RESTError = { - message: string; + message: string | Record; statusCode: HttpStatus; };