From d722b4154bd95ed591ff8d4f33e197ae2c210040 Mon Sep 17 00:00:00 2001 From: Vinicius Vale Date: Tue, 20 Aug 2024 09:10:00 -0300 Subject: [PATCH 1/2] feat: addition of apiDecoratorFactory to manage the creation of documentation and guards for restful api --- .env.example | 7 ++- README.md | 7 +++ package.json | 3 +- prisma/schema.prisma | 1 + src/auth/rest-current-user.ts | 14 +++++ src/decorators/apiDecoratorFactory.ts | 56 ++++++++++++++++++ src/forms/forms.controller.ts | 12 ++-- src/graphql/rest-authorization.guard.ts | 79 +++++++++++++++++++++++++ 8 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 src/auth/rest-current-user.ts create mode 100644 src/decorators/apiDecoratorFactory.ts create mode 100644 src/graphql/rest-authorization.guard.ts diff --git a/.env.example b/.env.example index 219239d..b0a5d7a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ # Database -DATABASE_URL= +PG_HOST= +DB_PORT= +PG_USER= +PG_PASSWORD= +PG_DB= +DATABASE_URL=postgresql://${PG_USER}:${PG_PASSWORD}@${PG_HOST}:${DB_PORT}/${PG_DB} # Auth AUTH0_AUDIENCE= diff --git a/README.md b/README.md index 9fe8812..ad1be41 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ $ npm install ``` +## Creating migrations +```bash +# development +$ npm prisma migration dev +``` + + ## Running the app ```bash diff --git a/package.json b/package.json index 8e3bf70..6691e9e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "email:dev": "email dev -p 3001" }, "dependencies": { + "@apollo/server": "^4.11.0", "@apollo/subgraph": "^2.0.5", "@aws-sdk/client-s3": "^3.137.0", "@aws-sdk/s3-request-presigner": "3.67.0", @@ -78,4 +79,4 @@ "wrap-ansi": "7.0.0", "string-width": "4.1.0" } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c9df50c..5730e92 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,7 @@ enum ProfileType { HODLER RECYCLER WASTE_GENERATOR + ADMIN } enum ResidueType { diff --git a/src/auth/rest-current-user.ts b/src/auth/rest-current-user.ts new file mode 100644 index 0000000..fbb2a98 --- /dev/null +++ b/src/auth/rest-current-user.ts @@ -0,0 +1,14 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export interface AuthUser { + sub: string; + permissions?: string[]; +} + +export const RestCurrentUser = createParamDecorator( + (data: unknown, context: ExecutionContext): AuthUser => { + const request = context.switchToHttp().getRequest(); + + return request.user; + }, +); diff --git a/src/decorators/apiDecoratorFactory.ts b/src/decorators/apiDecoratorFactory.ts new file mode 100644 index 0000000..982bf2c --- /dev/null +++ b/src/decorators/apiDecoratorFactory.ts @@ -0,0 +1,56 @@ +import { + applyDecorators, + Delete, + Get, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation } from '@nestjs/swagger'; + +import { Roles } from '@/auth/roles.decorator'; +import { RestAuthorizationGuard } from '@/graphql/rest-authorization.guard'; +import { Role } from '@/util/constants'; + +interface ApiAuthOptions { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + summary: string; + description: string; + authRoute: boolean; + responseType: any; + roles?: Role; +} + +export function ApiAuthOperation(options: ApiAuthOptions) { + const decorators = [ + ApiOperation({ + summary: options.summary, + description: options.description, + }), + ApiOkResponse({ description: options.summary, type: options.responseType }), + options.authRoute && ApiBearerAuth('access-token'), + options.authRoute && + options.roles && + options.roles.length && + Roles(options.roles) && + UseGuards(RestAuthorizationGuard), + ].filter(Boolean) as MethodDecorator[]; + + switch (options.method) { + case 'GET': + decorators.push(Get(options.path)); + break; + case 'POST': + decorators.push(Post(options.path)); + break; + case 'PUT': + decorators.push(Put(options.path)); + break; + case 'DELETE': + decorators.push(Delete(options.path)); + break; + } + + return applyDecorators(...decorators); +} diff --git a/src/forms/forms.controller.ts b/src/forms/forms.controller.ts index 20acba8..aec8487 100644 --- a/src/forms/forms.controller.ts +++ b/src/forms/forms.controller.ts @@ -1,12 +1,12 @@ import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { - ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags, } from '@nestjs/swagger'; +import { ApiAuthOperation } from '@/decorators/apiDecoratorFactory'; import { ApiOkResponsePaginated } from '@/dto/paginated.dto'; import { @@ -19,7 +19,7 @@ import { import { FormsService } from './forms.service'; @ApiTags('forms') -@ApiBearerAuth('access-token') +// @ApiBearerAuth('access-token') @Controller({ path: 'forms', version: '1' }) export class FormsController { constructor(private readonly formsService: FormsService) {} @@ -37,10 +37,14 @@ export class FormsController { return this.formsService.aggregateFormByUserProfile(); } - @Post(':formId/image-url') - @ApiOperation({ + @ApiAuthOperation({ + path: ':formId/image-url', + method: 'POST', summary: 'returns presigned url for image upload', description: 'Returns presigned url for image upload', + authRoute: false, + + responseType: String, }) submitFormImage(@Param('formId') formId: string) { return this.formsService.submitFormImage(formId); diff --git a/src/graphql/rest-authorization.guard.ts b/src/graphql/rest-authorization.guard.ts new file mode 100644 index 0000000..b00a38d --- /dev/null +++ b/src/graphql/rest-authorization.guard.ts @@ -0,0 +1,79 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { Request, Response } from 'express'; // Use Request from 'express' +import { expressjwt } from 'express-jwt'; +import { expressJwtSecret, GetVerificationKey } from 'jwks-rsa'; +import { PERMISSION_SCOPES, Role, ROLES_KEY } from 'src/util/constants'; +import { promisify } from 'util'; + +interface AuthenticatedRequest extends Request { + auth?: { + permissions?: string[]; + [key: string]: any; + }; +} + +@Injectable() +export class RestAuthorizationGuard implements CanActivate { + private AUTH0_AUDIENCE: string; + private AUTH0_DOMAIN: string; + + constructor( + private configService: ConfigService, + private reflector: Reflector, + ) { + this.AUTH0_AUDIENCE = this.configService.get('AUTH0_AUDIENCE'); + this.AUTH0_DOMAIN = this.configService.get('AUTH0_DOMAIN'); + } + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const res = context.switchToHttp().getResponse(); + + const checkJWT = promisify( + expressjwt({ + secret: expressJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: `${this.AUTH0_DOMAIN}.well-known/jwks.json`, + }) as GetVerificationKey, + audience: this.AUTH0_AUDIENCE, + issuer: this.AUTH0_DOMAIN, + algorithms: ['RS256'], + }), + ); + + try { + await checkJWT(req, res); + + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + const scopeRules = req?.auth?.permissions as string[]; + if (requiredRoles) { + if (!scopeRules?.length) return false; + + const [requiredRole] = requiredRoles; + + const hasAccess = scopeRules.every((scopeType) => + PERMISSION_SCOPES[requiredRole].includes(scopeType), + ); + + return hasAccess; + } + + return true; + } catch (err) { + throw new UnauthorizedException(err); + } + } +} From b6f144f9c0612a0faa98a8304493ff34ee6c9abb Mon Sep 17 00:00:00 2001 From: Vinicius Vale Date: Wed, 21 Aug 2024 14:59:47 -0300 Subject: [PATCH 2/2] fix: changes into addition of decorators logic, change the path of rest authorization guards, migration of admin type and addition of exception that will throw when user doesn't have permission to acces the route --- package.json | 4 ++-- .../migration.sql | 2 ++ src/decorators/apiDecoratorFactory.ts | 18 +++++++++++------- .../current-user.ts} | 0 src/graphql/authorization.guard.ts | 10 +++++++++- .../authorization.guard.ts} | 9 ++++----- src/helpers/messages.helper.ts | 1 + 7 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20240821173307_addition_of_admin_type/migration.sql rename src/{auth/rest-current-user.ts => decorators/current-user.ts} (100%) rename src/{graphql/rest-authorization.guard.ts => guards/authorization.guard.ts} (87%) diff --git a/package.json b/package.json index 6691e9e..26236ab 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,9 @@ "apollo-server-express": "^3.10.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", - "express-jwt": "^7.7.5", + "express-jwt": "^8.4.1", "graphql": "^16.5.0", - "jwks-rsa": "^2.1.4", + "jwks-rsa": "^3.1.0", "react-email": "2.1.6", "reflect-metadata": "^0.1.13", "resend": "^3.5.0", diff --git a/prisma/migrations/20240821173307_addition_of_admin_type/migration.sql b/prisma/migrations/20240821173307_addition_of_admin_type/migration.sql new file mode 100644 index 0000000..47a21a7 --- /dev/null +++ b/prisma/migrations/20240821173307_addition_of_admin_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProfileType" ADD VALUE 'ADMIN'; diff --git a/src/decorators/apiDecoratorFactory.ts b/src/decorators/apiDecoratorFactory.ts index 982bf2c..fc1a4b0 100644 --- a/src/decorators/apiDecoratorFactory.ts +++ b/src/decorators/apiDecoratorFactory.ts @@ -6,10 +6,16 @@ import { Put, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; import { Roles } from '@/auth/roles.decorator'; -import { RestAuthorizationGuard } from '@/graphql/rest-authorization.guard'; +import { RestAuthorizationGuard } from '@/guards/authorization.guard'; +import { MessagesHelper } from '@/helpers/messages.helper'; import { Role } from '@/util/constants'; interface ApiAuthOptions { @@ -28,13 +34,11 @@ export function ApiAuthOperation(options: ApiAuthOptions) { summary: options.summary, description: options.description, }), + ApiUnauthorizedResponse({ description: MessagesHelper.USER_UNAUTHORIZED }), ApiOkResponse({ description: options.summary, type: options.responseType }), options.authRoute && ApiBearerAuth('access-token'), - options.authRoute && - options.roles && - options.roles.length && - Roles(options.roles) && - UseGuards(RestAuthorizationGuard), + options.authRoute && UseGuards(RestAuthorizationGuard), + Roles(options.roles), ].filter(Boolean) as MethodDecorator[]; switch (options.method) { diff --git a/src/auth/rest-current-user.ts b/src/decorators/current-user.ts similarity index 100% rename from src/auth/rest-current-user.ts rename to src/decorators/current-user.ts diff --git a/src/graphql/authorization.guard.ts b/src/graphql/authorization.guard.ts index a256acc..45c272c 100644 --- a/src/graphql/authorization.guard.ts +++ b/src/graphql/authorization.guard.ts @@ -13,6 +13,8 @@ import { expressJwtSecret, GetVerificationKey } from 'jwks-rsa'; import { PERMISSION_SCOPES, Role, ROLES_KEY } from 'src/util/constants'; import { promisify } from 'util'; +import { MessagesHelper } from '@/helpers/messages.helper'; + @Injectable() export class AuthorizationGuard implements CanActivate { private AUTH0_AUDIENCE: string; @@ -60,7 +62,9 @@ export class AuthorizationGuard implements CanActivate { const scopeRules = req?.auth.permissions as string[]; if (requiredRoles) { - if (!scopeRules?.length) return false; + if (!scopeRules?.length) { + throw new UnauthorizedException(MessagesHelper.USER_UNAUTHORIZED); + } const [requiredRole] = requiredRoles; @@ -68,6 +72,10 @@ export class AuthorizationGuard implements CanActivate { PERMISSION_SCOPES[requiredRole].includes(scopeType), ); + if (!hasAccess) { + throw new UnauthorizedException(MessagesHelper.USER_UNAUTHORIZED); + } + return hasAccess; } diff --git a/src/graphql/rest-authorization.guard.ts b/src/guards/authorization.guard.ts similarity index 87% rename from src/graphql/rest-authorization.guard.ts rename to src/guards/authorization.guard.ts index b00a38d..8c96fc2 100644 --- a/src/graphql/rest-authorization.guard.ts +++ b/src/guards/authorization.guard.ts @@ -6,16 +6,15 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; -import { Request, Response } from 'express'; // Use Request from 'express' -import { expressjwt } from 'express-jwt'; -import { expressJwtSecret, GetVerificationKey } from 'jwks-rsa'; +import { Request, Response } from 'express'; +import { expressjwt, GetVerificationKey } from 'express-jwt'; +import { expressJwtSecret } from 'jwks-rsa'; import { PERMISSION_SCOPES, Role, ROLES_KEY } from 'src/util/constants'; import { promisify } from 'util'; interface AuthenticatedRequest extends Request { auth?: { permissions?: string[]; - [key: string]: any; }; } @@ -58,7 +57,7 @@ export class RestAuthorizationGuard implements CanActivate { [context.getHandler(), context.getClass()], ); - const scopeRules = req?.auth?.permissions as string[]; + const scopeRules = req?.auth?.permissions; if (requiredRoles) { if (!scopeRules?.length) return false; diff --git a/src/helpers/messages.helper.ts b/src/helpers/messages.helper.ts index d2da7d5..8a0b6b8 100644 --- a/src/helpers/messages.helper.ts +++ b/src/helpers/messages.helper.ts @@ -3,6 +3,7 @@ const usersMessages = { USER_NOT_FOUND: 'User not found', USER_DOES_NOT_HAS_PERMISSION_TO_UPLOAD: 'User do not have permission to submit files', + USER_UNAUTHORIZED: "User doesn't have access to this route", }; const formMessages = {