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..26236ab 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", @@ -42,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", @@ -78,4 +79,4 @@ "wrap-ansi": "7.0.0", "string-width": "4.1.0" } -} \ No newline at end of file +} 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/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/decorators/apiDecoratorFactory.ts b/src/decorators/apiDecoratorFactory.ts new file mode 100644 index 0000000..fc1a4b0 --- /dev/null +++ b/src/decorators/apiDecoratorFactory.ts @@ -0,0 +1,60 @@ +import { + applyDecorators, + Delete, + Get, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; + +import { Roles } from '@/auth/roles.decorator'; +import { RestAuthorizationGuard } from '@/guards/authorization.guard'; +import { MessagesHelper } from '@/helpers/messages.helper'; +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, + }), + ApiUnauthorizedResponse({ description: MessagesHelper.USER_UNAUTHORIZED }), + ApiOkResponse({ description: options.summary, type: options.responseType }), + options.authRoute && ApiBearerAuth('access-token'), + options.authRoute && UseGuards(RestAuthorizationGuard), + Roles(options.roles), + ].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/decorators/current-user.ts b/src/decorators/current-user.ts new file mode 100644 index 0000000..fbb2a98 --- /dev/null +++ b/src/decorators/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/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/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/guards/authorization.guard.ts b/src/guards/authorization.guard.ts new file mode 100644 index 0000000..8c96fc2 --- /dev/null +++ b/src/guards/authorization.guard.ts @@ -0,0 +1,78 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +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[]; + }; +} + +@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; + 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); + } + } +} 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 = {