Browse Source

HSB-462 feat: infra token module and sh apis (#4191)

* feat: infra token module added

* feat: infra token guard added

* feat: token prefix removed

* feat: get pending invites api added

* docs: swagger doc added for get user invites api

* feat: delete user invitation api added

* feat: get users api added

* feat: update user api added

* feat: update admin status api added

* feat: create invitation api added

* chore: swagger doc update for create user invite

* feat: interceptor added to track last used on

* feat: change db schema

* chore: readonly tag added

* feat: get user by id api added

* fix: return type of a function

* feat: controller name change

* chore: improve token extractino

* chore: added email validation logic

---------

Co-authored-by: Balu Babu <balub997@gmail.com>
Mir Arif Hasan 7 months ago
parent
commit
783d911f8d

+ 3 - 0
packages/hoppscotch-backend/package.json

@@ -35,11 +35,14 @@
     "@nestjs/passport": "10.0.2",
     "@nestjs/platform-express": "10.2.7",
     "@nestjs/schedule": "4.0.1",
+    "@nestjs/swagger": "7.4.0",
     "@nestjs/terminus": "10.2.3",
     "@nestjs/throttler": "5.0.1",
     "@prisma/client": "5.8.1",
     "argon2": "0.30.3",
     "bcrypt": "5.1.0",
+    "class-transformer": "0.5.1",
+    "class-validator": "0.14.1",
     "cookie": "0.5.0",
     "cookie-parser": "1.4.6",
     "cron": "3.1.6",

+ 15 - 0
packages/hoppscotch-backend/prisma/migrations/20240726121956_infra_token/migration.sql

@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "InfraToken" (
+    "id" TEXT NOT NULL,
+    "creatorUid" 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 DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "InfraToken_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "InfraToken_token_key" ON "InfraToken"("token");

+ 10 - 0
packages/hoppscotch-backend/prisma/schema.prisma

@@ -232,3 +232,13 @@ model PersonalAccessToken {
   createdOn DateTime  @default(now()) @db.Timestamp(3)
   updatedOn DateTime  @updatedAt @db.Timestamp(3)
 }
+
+model InfraToken {
+  id         String    @id @default(cuid())
+  creatorUid String
+  label      String
+  token      String    @unique @default(uuid())
+  expiresOn  DateTime? @db.Timestamp(3)
+  createdOn  DateTime  @default(now()) @db.Timestamp(3)
+  updatedOn  DateTime  @default(now()) @db.Timestamp(3)
+}

+ 2 - 15
packages/hoppscotch-backend/src/access-token/access-token.service.ts

@@ -2,7 +2,7 @@ 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 { calculateExpirationDate, isValidLength } from 'src/utils';
 import * as E from 'fp-ts/Either';
 import {
   ACCESS_TOKEN_EXPIRY_INVALID,
@@ -20,17 +20,6 @@ export class AccessTokenService {
   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
    *
@@ -97,9 +86,7 @@ export class AccessTokenService {
       data: {
         userUid: user.uid,
         label: createAccessTokenDto.label,
-        expiresOn: this.calculateExpirationDate(
-          createAccessTokenDto.expiryInDays,
-        ),
+        expiresOn: calculateExpirationDate(createAccessTokenDto.expiryInDays),
       },
     });
 

+ 7 - 0
packages/hoppscotch-backend/src/admin/admin.service.ts

@@ -161,6 +161,13 @@ export class AdminService {
    * @returns an Either of boolean or error string
    */
   async revokeUserInvitations(inviteeEmails: string[]) {
+    const areAllEmailsValid = inviteeEmails.every((email) =>
+      validateEmail(email),
+    );
+    if (!areAllEmailsValid) {
+      return E.left(INVALID_EMAIL);
+    }
+
     try {
       await this.prisma.invitedUsers.deleteMany({
         where: {

+ 2 - 0
packages/hoppscotch-backend/src/app.module.ts

@@ -29,6 +29,7 @@ import { ScheduleModule } from '@nestjs/schedule';
 import { HealthModule } from './health/health.module';
 import { AccessTokenModule } from './access-token/access-token.module';
 import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.interceptor';
+import { InfraTokenModule } from './infra-token/infra-token.module';
 
 @Module({
   imports: [
@@ -105,6 +106,7 @@ import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.
     ScheduleModule.forRoot(),
     HealthModule,
     AccessTokenModule,
+    InfraTokenModule,
   ],
   providers: [
     GQLComplexityPlugin,

+ 15 - 0
packages/hoppscotch-backend/src/decorators/bearer-token.decorator.ts

@@ -0,0 +1,15 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+
+/**
+ ** Decorator to fetch refresh_token from cookie
+ */
+export const BearerToken = createParamDecorator(
+  (data: unknown, context: ExecutionContext) => {
+    const request = context.switchToHttp().getRequest<Request>();
+
+    // authorization token will be "Bearer <token>"
+    const authorization = request.headers['authorization'];
+    // Remove "Bearer " and return the token only
+    return authorization.split(' ')[1];
+  },
+);

+ 43 - 0
packages/hoppscotch-backend/src/errors.ts

@@ -810,3 +810,46 @@ export const ACCESS_TOKEN_INVALID = 'TOKEN_INVALID';
  * (AccessTokenService)
  */
 export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';
+
+/**
+ * The provided label for the infra-token is short (less than 3 characters)
+ * (InfraTokenService)
+ */
+export const INFRA_TOKEN_LABEL_SHORT = 'infra_token/label_too_short';
+
+/**
+ * The provided expiryInDays value is not valid
+ * (InfraTokenService)
+ */
+export const INFRA_TOKEN_EXPIRY_INVALID = 'infra_token/expiry_days_invalid';
+
+/**
+ * The provided Infra Token ID is invalid
+ * (InfraTokenService)
+ */
+export const INFRA_TOKEN_NOT_FOUND = 'infra_token/infra_token_not_found';
+
+/**
+ * Authorization missing in header (Check 'Authorization' Header)
+ * (InfraTokenGuard)
+ */
+export const INFRA_TOKEN_HEADER_MISSING =
+  'infra_token/authorization_token_missing';
+
+/**
+ * Infra Token is invalid
+ * (InfraTokenGuard)
+ */
+export const INFRA_TOKEN_INVALID_TOKEN = 'infra_token/invalid_token';
+
+/**
+ * Infra Token is expired
+ * (InfraTokenGuard)
+ */
+export const INFRA_TOKEN_EXPIRED = 'infra_token/expired';
+
+/**
+ * Token creator not found
+ * (InfraTokenService)
+ */
+export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found';

+ 2 - 0
packages/hoppscotch-backend/src/gql-schema.ts

@@ -29,6 +29,7 @@ import { UserHistoryUserResolver } from './user-history/user.resolver';
 import { UserSettingsUserResolver } from './user-settings/user.resolver';
 import { InfraResolver } from './admin/infra.resolver';
 import { InfraConfigResolver } from './infra-config/infra-config.resolver';
+import { InfraTokenResolver } from './infra-token/infra-token.resolver';
 
 /**
  * All the resolvers present in the application.
@@ -60,6 +61,7 @@ const RESOLVERS = [
   UserSettingsResolver,
   UserSettingsUserResolver,
   InfraConfigResolver,
+  InfraTokenResolver,
 ];
 
 /**

+ 47 - 0
packages/hoppscotch-backend/src/guards/infra-token.guard.ts

@@ -0,0 +1,47 @@
+import {
+  CanActivate,
+  ExecutionContext,
+  Injectable,
+  UnauthorizedException,
+} from '@nestjs/common';
+import { PrismaService } from 'src/prisma/prisma.service';
+import { DateTime } from 'luxon';
+import {
+  INFRA_TOKEN_EXPIRED,
+  INFRA_TOKEN_HEADER_MISSING,
+  INFRA_TOKEN_INVALID_TOKEN,
+} from 'src/errors';
+
+@Injectable()
+export class InfraTokenGuard implements CanActivate {
+  constructor(private readonly prisma: PrismaService) {}
+
+  async canActivate(context: ExecutionContext): Promise<boolean> {
+    const request = context.switchToHttp().getRequest<Request>();
+    const authorization = request.headers['authorization'];
+
+    if (!authorization)
+      throw new UnauthorizedException(INFRA_TOKEN_HEADER_MISSING);
+
+    if (!authorization.startsWith('Bearer '))
+      throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
+
+    const token = authorization.split(' ')[1];
+
+    if (!token) throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
+
+    const infraToken = await this.prisma.infraToken.findUnique({
+      where: { token },
+    });
+
+    if (infraToken === null)
+      throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
+
+    const currentTime = DateTime.now().toISO();
+    if (currentTime > infraToken.expiresOn.toISOString()) {
+      throw new UnauthorizedException(INFRA_TOKEN_EXPIRED);
+    }
+
+    return true;
+  }
+}

Some files were not shown because too many files changed in this diff