diff --git a/.github/workflows/deploy-manually.yml b/.github/workflows/deploy-manually.yml index dea0b1d..cd277dd 100644 --- a/.github/workflows/deploy-manually.yml +++ b/.github/workflows/deploy-manually.yml @@ -35,9 +35,7 @@ jobs: semantic_release: runs-on: ubuntu-latest outputs: - #new_release_published: ${{ steps.semantic.outputs.last_release_version != steps.semantic.outputs.new_release_version }} new_release_published: ${{ steps.semantic.outputs.new_release_published }} - #new_release_version: ${{ steps.semantic.outputs.new_release_version }} new_release_version: ${{ (steps.semantic.outputs.new_release_published == 'true' && steps.semantic.outputs.new_release_version) || (github.event_name == 'workflow_dispatch' && '0.0.0') }} steps: - name: Checkout @@ -57,7 +55,6 @@ jobs: ] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} deploy: if: ${{ github.event_name == 'workflow_dispatch' || needs.semantic_release.outputs.new_release_published == 'true' }} @@ -107,9 +104,8 @@ jobs: ENV: ${{ needs.extract_environment.outputs.environment }} IMAGE_TAG: ${{ needs.semantic_release.outputs.new_release_version }} ACCOUNT_ID: ${{ steps.aws.outputs.aws-account-id }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - docker-compose -f build.docker-compose.yml build --build-arg NPM_TOKEN=${NPM_TOKEN} + docker-compose -f build.docker-compose.yml build docker-compose -f build.docker-compose.yml push - name: Create ZIP file to Deploy AWS Beanstalk diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d51f77..8f71d05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,8 +2,6 @@ name: Test on: pull_request: branches: - - alpha - - beta - main jobs: @@ -21,23 +19,22 @@ jobs: env: ENV: test IMAGE_TAG: test - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | export ENV=test export IMAGE_TAG=test export ACCOUNT_ID=test - docker-compose -f build.docker-compose.yml build --build-arg NPM_TOKEN=${NPM_TOKEN} + docker-compose -f build.docker-compose.yml build - name: Run Test env: ENV: test IMAGE_TAG: test ACCOUNT_ID: test - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + APP_NAME: ${{ github.event.repository.name }} run: | export ENV=test export IMAGE_TAG=test - docker-compose -f build.docker-compose.yml run -e NPM_TOKEN=${NPM_TOKEN} --rm --entrypoint="npm run test" maestro + docker-compose -f build.docker-compose.yml run --rm --entrypoint="npm run test" $APP_NAME - name: Remove Docker's Trash if: always() diff --git a/.npmrc b/.npmrc deleted file mode 100644 index bd3327a..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -//registry.npmjs.org/:_authToken=${NPM_TOKEN} \ No newline at end of file diff --git a/.releaserc.json b/.releaserc.json index 4f6d6b7..e526302 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -26,7 +26,6 @@ "preset": "eslint" } ], - "@semantic-release/npm", "@semantic-release/github" ] } diff --git a/Dockerfile b/Dockerfile index 0c77cb1..ba242b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,16 @@ FROM node:18.3.0-alpine3.15 as packages WORKDIR /packages -ARG NPM_TOKEN COPY package.json . COPY package-lock.json . -COPY .npmrc . +RUN apk update \ + && apk add --no-cache aws-cli \ + && aws codeartifact login --tool npm --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1 \ + && npm install -RUN apk update && apk add curl unzip -RUN apk add --no-cache aws-cli - -RUN aws codeartifact login --tool npm --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1 - -RUN npm install -RUN rm -f ./.npmrc - -FROM node:14.15.4-alpine3.12 +FROM node:18.3.0-alpine3.15 WORKDIR /app COPY . /app/ -ARG NPM_TOKEN COPY --from=packages /packages/node_modules /app/node_modules RUN npm run build EXPOSE 3333 -ENTRYPOINT npm run start \ No newline at end of file +ENTRYPOINT npm run start diff --git a/README.md b/README.md index 890cecc..12972d2 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,301 @@ -# Maestro

-

Maestro

-This is the Dadosfera´s gateway repository, it´s responsable for the communication between frontend application and Dadosfera´s mirosservices. -## 💻 Requirements +# Maestro +Maestro is the Dadosfera's gateway, it's responsible for the communication between the frontend application and Dadosfera's microservices. -Before you start, make sure you have done the following steps: +## 🚀 Starting +These instructions will allow you to get a working copy of the project on your local machine for development and testing purposes. -* Installed Nodejs version 16.14.2 -* Installed latest NPM version +### 📋 Requirements +- [NodeJS v18.3.0 LTS / NPM v8.11](https://nodejs.org/pt-br/download/) (you can opt to use [NVM](https://github.com/nvm-sh/nvm) to easily manage node versions) +- Request access to AWS Console dev account for **all services** (avoid gradually asking for each needed service. it will slow down your development cycle) + - Create your Access Key on the "Security credentials" menu + - Set the Access Key on your local development machine +- Request access to the dev, stg and prd VPNs +### 🔧 Installation +- Clone the repository + - SSH + ``` + git clone git@github.com:dadosfera/maestro.git + ``` + or + - HTTPS + ``` + git clone https://github.com/dadosfera/maestro.git + ``` -## 🚀 Installing Maestro +- Select the correct node version (optional, only if using [NVM](https://github.com/nvm-sh/nvm)): + ```sh + nvm use + ``` -First of all clone the repository: - * SSH: -``` -git clone git@github.com:dadosfera/maestro.git -``` - * HTTPS: -``` -git clone git@github.com:dadosfera/maestro.git +- Install the project dependencies: + ```sh + npm i + ``` + +- Setup the following enviroment variables: + ``` + ENV= + DUC_URL= + INFACTORY_URL= + OTFACTORY_URL= + PIFACTORY_URL= + SM_OAUTH_PATH= + ``` + +- Start the server: + ```sh + # dev mode + npm run start:dev + + # or in debug mode + npm run start:debug + ``` + The service should start successfully. + +## Authentication decorators +Maestro have utilities to ease the user authentication on every controller and route. The following decorators are available: + +### `@Authenticated` +If the user must be authenticated to make request, we can use the `@Authenticated` decorator in the controller or route, as needed. + +```ts +import { Authenticated } from '../../authentication/authentication.decorator'; + +@Controller('foo') +@Authenticated() +class FooController { + /* ... */ + + @Get('bar') + async getBar() { + this.logger.info('user is authenticated!'); + + return { authenticated: true }; + } +} ``` -## Enviroment variables +```ts +import { Authenticated } from '../../authentication/authentication.decorator'; -Here is a list of enviroment variables needed in order to run the application correctly. +@Controller('foo') +class FooController { + /* ... */ -``` -ENV= -DUC_URL= -INFACTORY_URL= -TRFACTORY_URL= -OTFACTORY_URL= -PIFACTORY_URL= -JWT_PRIVATE_KEY= -AWS_IDENTITY_POOL_ID= + @Get('bar') + @Authenticated() + async getBar() { + this.logger.info('user is authenticated!'); + + return { authenticated: true }; + } + + @Post('bar') + async postBar() { + this.logger.info('user is NOT authenticated!'); + + return { authenticated: false }; + } +} ``` -## Running Maestro +### `@RequireAllPermissions` +This decorator requires that **all permissions** listed are granted to the requesting user. -In order to run Maestro just run the following command: +```ts +import { RequireAllPermissions } from '../../authentication/authentication.decorator'; +import { Permissions } from '../../authentication/permissions.enum'; + +@Controller('foo') +class FooController { + /* ... */ + + @Get('bar') + @RequireAllPermissions(Permissions.BAR.MANAGE, Permissions.BAR.CREATE) + async getBar() { + /* ... */ + } +} ``` -npm run start:dev + +### `@RequireSomePermission` +In the following case, the user is required to have **at least one** listed permission. + +```ts +import { RequireSomePermission } from '../../authentication/authentication.decorator'; +import { Permissions } from '../../authentication/permissions.enum'; + +@Controller('foo') +@RequireSomePermission(Permissions.FOO.MANAGE, Permissions.FOO.CREATE) +class FooController { + /* ... */ + + @Get('bar') + async getBar() { + /* ... */ + } +} ``` -If everything is fine the Maestro will start and be ready to receive HTTP requests \ No newline at end of file + +### `@AuthenticateCondition` +If a more complicated authentication check needs to be done, we can use the `@AuthenticateCondition` decorator to define it. The custom function must return `true` to authenticate the request. + +In the following example: +- all routes on the `FooController` controller can only be requested from localhost +- `POST /foo/bar` can only be requested from localhost **and** by users from customer id `111...eef` + +```ts +import { AuthenticateCondition } from '../../authentication/authentication.decorator'; + +@Controller('foo') +// allow requests only from localhost +@AuthenticateCondition((request: Request) => request.ip === '::ffff:127.0.0.1') +class FooController { + /* ... */ + + @Get('bar') + async getBar() { + /* ... */ + } + + @Post('bar') + // allow requests only from a specific customer + @AuthenticateCondition((request: Request, user: RequestUser) => user.customer_id === '1113e943-2187-4fdd-9c2c-54338fedaeef') + async postBar() { + /* ... */ + } +} +``` + +### Note on authentication decorators +- The old authentication method placed the user data in the `request.body.info` field, this imposes certain issues regarding the request body because this data should be from the frontend without any modification by Maestro. Now this usage is ⚠️ **DEPRECATED** ⚠️. We are working to migrate to the `@User` parameter decorator. The old method is working while the migration is in progress. + +- Authentication decorators can be used together and all of them **must** pass to the request be authenticated, but in the general case you don't need to (*and wouldn't like to...*) use all of them together, as you can code all of the authentication logic in the `@AuthenticateCondition` decorator. + + ```ts + import { + RequireAllPermissions, + RequireSomePermission, + AuthenticateCondition, + } from '../../authentication/authentication.decorator'; + import { Permissions } from '../../authentication/permissions.enum'; + import { Waa, Baz } from './authenticationFunctions' + + @Controller('foo') + @RequireAllPermissions(Permissions.FOO.USE, Permissions.FOO.REQUEST) + @RequireSomePermission(Permissions.FOO.MANAGE, Permissions.FOO.ADMIN) + @AuthenticateCondition(Waa) + @AuthenticateCondition(Baz) + class FooController { + /* ... */ + + @Get('bar') + async getBar() { + /* ... */ + } + + @Post('bar') + @RequireAllPermissions(Permissions.BAR.MANAGE, Permissions.BAR.USE, Permissions.BAR.REQUEST) + async postBar() { + /* ... */ + } + } + ``` + +- If `@RequireAllPermissions` and `@RequireSomePermission` are used with **only a single permission**, they present the **exactly same behavior**. + + ```ts + // same behavior + @RequireAllPermissions(Permissions.BAR.MANAGE) + @RequireSomePermission(Permissions.BAR.MANAGE) + ``` + + + +## `@User` parameter decorator +The requesting user data can be obtained using the @User parameter decorator, like in the following snippet: + +```ts +import { User, RequestUser } from '../../authentication/user.decorator'; + +@Controller('foo') +class FooController { + /* ... */ + + @Get('bar') + async getBar(@User() user: RequestUser) { + this.logger.info(user); + + return { user }; + } +} +``` + +If the user is required to be logged in, set `required` to `true`, as you would want in the `change-password` operation: + +```ts +import { User, RequestUser } from '../../authentication/user.decorator'; + +@Controller('user') +class UserController { + /* ... */ + + @Post('change-password') + @HttpCode(HttpStatus.OK) + async changePassword( + @User({ required: true }) user: RequestUser, + @Body() body: AuthChangePasswordRequest, + ) { + const { oldPassword, newPassword } = body; + + return this.authClient.changePassword({ + userId: user.user_id, + oldPassword, + newPassword, + }); + } +} +``` + +## 📦 Development +### ⌨️ Coding Style +By default, we use [ESLint](https://eslint.org/) + [Prettier](https://prettier.io/) with default settings. + +**We recommend using Visual Studio Code and installing the recommended extensions to ease the development process.** + +### Commits pattern +Our workflow pipeline follows the conventional commits specs ([cheat sheets](https://cheatography.com/albelop/cheat-sheets/conventional-commits/)) to release versions accordingly. + +Format: `[optional scope]: ` + +Example: `FIX: ensure Range headers adhere more closely to RFC 2616` + +### Branching naming convention +- **Feature**: Any code changes for a new module or use case should be done on a feature branch. This branch is created based on the `main` branch. When all changes are done, a Pull Request/Merge Request is needed to put all of these changes back to the `main` branch. Examples: `feature/integrate-swagger`, `feature/JIRA-1234`, `feature/JIRA-1234_support-dark-theme`. + + **It is recommended to use all lower caps letters and hyphen (-) to separate words unless it is a specific item name or ID. Underscore (_) could be used to separate the ID and description.** + +- **Bug Fix**: If the code changes made from the feature branch were rejected after a release, sprint or demo, any necessary fixes after that should be done on the bugfix branch. Examples: `bugfix/more-gray-shades`, `bugfix/JIRA-1444_gray-on-blur-fix`. + +- **Hot Fix**: If there is a need to fix a blocker, do a temporary patch, apply a critical framework or configuration change that should be handled immediately, it should be created as a Hotfix. Examples: `hotfix/disable-endpoint-zero-day-exploit`, `hotfix/increase-scaling-threshold`. + +- **Experimental**: A branch for playing around. Any new feature or idea that is not part of a release or a sprint. Example: `experimental/dark-theme-support`. + +### Making a Pull Request +1. Commit your changes +2. Open the Pull Request on GitHub +3. Send Pull Request link in microsfera Google Chat Group for review and possible approval + +## 🛠️ Built with +Some technologies used in this project: + +- [NestJS](https://docs.nestjs.com) - Framework for building efficient and scalable NodeJS server-side applications + +## ⚙️ Back-end Architecture +The architecture can be found at [this link](https://sites.google.com/dadosfera.ai/wikidoproduto/time/back-end). diff --git a/package-lock.json b/package-lock.json index 2073a97..e149c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9868,8 +9868,9 @@ }, "node_modules/supertest": { "version": "6.2.3", + "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/supertest/-/supertest-6.2.3.tgz", + "integrity": "sha512-3GSdMYTMItzsSYjnIcljxMVZKPW1J9kYHZY+7yLfD0wpPwww97GeImZC1oOk0S5+wYl2niJwuFusBJqwLqYM3g==", "dev": true, - "license": "MIT", "dependencies": { "methods": "^1.1.2", "superagent": "^7.1.3" @@ -17790,6 +17791,8 @@ }, "supertest": { "version": "6.2.3", + "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/supertest/-/supertest-6.2.3.tgz", + "integrity": "sha512-3GSdMYTMItzsSYjnIcljxMVZKPW1J9kYHZY+7yLfD0wpPwww97GeImZC1oOk0S5+wYl2niJwuFusBJqwLqYM3g==", "dev": true, "requires": { "methods": "^1.1.2", diff --git a/package.json b/package.json index 78c597c..a8030ea 100644 --- a/package.json +++ b/package.json @@ -36,13 +36,11 @@ "@nestjs/platform-express": "^8.4.7", "@nestjs/schedule": "^1.1.0", "@nestjs/swagger": "^5.2.1", - "protospack": "2.5.1", - "protospack-v2": "1.1.1", "axios": "^0.25.0", "cron-parser": "^4.4.0", "dadosfera-logs": "^1.0.0-alpha.10", - "elastic-apm-node": "^3.36.0", "dotenv": "^14.3.2", + "elastic-apm-node": "^3.36.0", "helmet": "^5.1.0", "jsonwebtoken": "^8.5.1", "jwk-to-pem": "^2.0.5", @@ -52,6 +50,8 @@ "passport-google-oauth20": "^2.0.0", "passport-hubspot-oauth2": "^1.0.3", "passport-mailchimp": "^1.1.0", + "protospack": "2.5.1", + "protospack-v2": "1.1.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.5.5", @@ -107,4 +107,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/src/authentication/authentication.decorator.ts b/src/authentication/authentication.decorator.ts index e840f54..6c86f13 100644 --- a/src/authentication/authentication.decorator.ts +++ b/src/authentication/authentication.decorator.ts @@ -1,28 +1,52 @@ -import { SetMetadata, applyDecorators } from '@nestjs/common'; import { Request } from 'express'; +import { CustomDecorator } from '@nestjs/common'; import { Permission } from './permissions.enum'; +import { RequestUser } from './user.decorator'; -export const PERMISSIONS_KEY = '__PERMISSIONS__'; -export const MUST_BE_AUTHENTICATED_KEY = '__MUST_BE_AUTHENTICATED__'; -export const CUSTOM_AUTHENTICATION_FUNCTION_KEY = - '__CUSTOM_AUTHENTICATION_FUNCTION__'; +export const AUTH_FUNCTION_KEY = '__AUTH_FUNCTION__'; export type AuthenticationFunction = (req: Request, user: any) => boolean; -export function RequirePermissions(...permissions: Permission[]) { - return applyDecorators( - SetMetadata(PERMISSIONS_KEY, permissions), - SetMetadata(MUST_BE_AUTHENTICATED_KEY, true), +// implementation copied from SetMetadata, but tweaked to get existing values +// of the metadataKey and accumulate it with the new metadataValue +function SetMultipleMetadata(metadataKey, metadataValue): CustomDecorator { + const decoratorFactory = (target, key, descriptor) => { + // .start: tweak + // descriptor?.value = function decorator; target = class decorator + const accumulatedVal = + Reflect.getMetadata(metadataKey, descriptor?.value ?? target) ?? []; + accumulatedVal.push(metadataValue); + // .end: tweak + + if (descriptor) { + Reflect.defineMetadata(metadataKey, accumulatedVal, descriptor.value); + return descriptor; + } + + Reflect.defineMetadata(metadataKey, accumulatedVal, target); + return target; + }; + + decoratorFactory.KEY = metadataKey; + return decoratorFactory as any; +} + +export function RequireAllPermissions(...permissions: Permission[]) { + return SetMultipleMetadata(AUTH_FUNCTION_KEY, (req, user: RequestUser) => + permissions.every(({ seqid }) => user.permissions.includes(seqid)), + ); +} + +export function RequireSomePermission(...permissions: Permission[]) { + return SetMultipleMetadata(AUTH_FUNCTION_KEY, (req, user: RequestUser) => + permissions.some(({ seqid }) => user.permissions.includes(seqid)), ); } export function AuthenticateCondition(func: AuthenticationFunction) { - return applyDecorators( - SetMetadata(CUSTOM_AUTHENTICATION_FUNCTION_KEY, func), - SetMetadata(MUST_BE_AUTHENTICATED_KEY, true), - ); + return SetMultipleMetadata(AUTH_FUNCTION_KEY, func); } export function Authenticated() { - return SetMetadata(MUST_BE_AUTHENTICATED_KEY, true); + return SetMultipleMetadata(AUTH_FUNCTION_KEY, () => true); } diff --git a/src/authentication/authentication.guard.spec.ts b/src/authentication/authentication.guard.spec.ts new file mode 100644 index 0000000..347a549 --- /dev/null +++ b/src/authentication/authentication.guard.spec.ts @@ -0,0 +1,447 @@ +import request from 'supertest'; +import { Test } from '@nestjs/testing'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import jwt from 'jsonwebtoken'; + +import { Body, Controller, Get } from '@nestjs/common'; +import { DadosferaLogger } from 'dadosfera-logs'; +import { + AuthenticateCondition, + Authenticated, + RequireAllPermissions, + RequireSomePermission, +} from './authentication.decorator'; +import { AuthenticationGuard } from './authentication.guard'; +import { Permissions } from './permissions.enum'; +import { AuthClientService } from '../clients/auth/client.service'; + +import ErrorCodes from '../utils/errorCodes'; + +@Controller('no-class-auth') +class NoClassAuthController { + @Get('body') + async getBody(@Body() body) { + return { body }; + } + + @Get('authenticated') + @Authenticated() + async mustBeAuthenticated(@Body() body) { + return { body }; + } + + @Get('required-permission') + @RequireSomePermission(Permissions.ZENDESK.OPEN) + async requiredPermission(@Body() body) { + return { body }; + } + + @Get('has-all-permissions') + @RequireAllPermissions(Permissions.ZENDESK.OPEN, Permissions.METABASE.OPEN) + async hasAllPermissions(@Body() body) { + return { body }; + } + + @Get('has-some-permission') + @RequireSomePermission(Permissions.ZENDESK.OPEN, Permissions.METABASE.OPEN) + async hasSomePermission(@Body() body) { + return { body }; + } +} + +@Controller('class-auth-condition') +@AuthenticateCondition((req) => req.get('x-on-class') === 'ok') +@RequireSomePermission(Permissions.METABASE.OPEN) +class ClassAuthConditionController { + @Get('body') + async getBody(@Body() body) { + return { body }; + } + + @Get('authenticated') + @Authenticated() + async mustBeAuthenticated(@Body() body) { + return { body }; + } + + @Get('required-permission') + @AuthenticateCondition((req) => req.get('x-on-route') === 'ok') + @RequireSomePermission(Permissions.ZENDESK.OPEN) + async requiredPermission(@Body() body) { + return { body }; + } +} + +describe('authentication.guard', () => { + let app: INestApplication; + const jwtSecretA = { + kid: 'token-a-testing-shared-key', + pem: '$tr0ng-SH4Red-secr3t!!!~gl0ba1~]', + }; + const jwtSecretB = { + kid: 'token-b', + pem: 'another-shared-token', + }; + const fakeUserPayload = { + user_id: 'd50d33c7-6c2b-463c-861f-e21667e7c125', + username: 'super.admin', + customer_id: '9d18e8ae-24b9-41a3-9e8f-a25ce57555b11', + customer_name: 'dadosfera', + customer_tier: 'BASIC', + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: AuthClientService, + useValue: { + getPublicKeys: async () => ({ + keys: [jwtSecretA, jwtSecretB], + }), + }, + }, + { + provide: DadosferaLogger, + useValue: { logger: console }, + }, + { + provide: APP_GUARD, + useClass: AuthenticationGuard, + }, + ], + controllers: [NoClassAuthController, ClassAuthConditionController], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + function AssertBodyNoAuth(res: request.Response) { + expect(res.statusCode).toBe(HttpStatus.OK); + expect(res.body).toStrictEqual({ body: {} }); + } + + function AssertBodyWithAuth(res: request.Response) { + expect(res.statusCode).toBe(HttpStatus.OK); + expect(res.body).toStrictEqual({ + body: { + info: { + customer: 'dadosfera', + customer_id: '9d18e8ae-24b9-41a3-9e8f-a25ce57555b11', + customer_tier: 'BASIC', + user_id: 'd50d33c7-6c2b-463c-861f-e21667e7c125', + }, + }, + }); + } + + function AssertUnauthorized(res: request.Response) { + expect(res.statusCode).toBe(HttpStatus.UNAUTHORIZED); + expect(res.body).toStrictEqual({ + code: ErrorCodes.AUTH.UNAUTHORIZED, + error: 'Não autenticado', + message: 'É necessário estar logado para realizar essa operação', + statusCode: HttpStatus.UNAUTHORIZED, + }); + } + + function AssertForbidden(res: request.Response) { + expect(res.statusCode).toBe(HttpStatus.FORBIDDEN); + expect(res.body).toStrictEqual({ + code: ErrorCodes.AUTH.FORBIDDEN, + error: 'Não autorizado', + message: + 'Você não tem permissões suficientes para realizar essa operação', + statusCode: HttpStatus.FORBIDDEN, + }); + } + + function NoClassAuthTest(accessToken: string, tokenName: string[]) { + const route = '/no-class-auth'; + + describe(`${route}, ${tokenName?.length ? tokenName : 'none'} auth`, () => { + it('should GET /body with user data (if available)', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/body`) + .set({ Authorization: accessToken }); + + if (tokenName?.length) { + AssertBodyWithAuth(res); + } else { + AssertBodyNoAuth(res); + } + }); + + it('should GET /authenticated if authenticated', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/authenticated`) + .set({ Authorization: accessToken }); + + if (tokenName?.length) { + AssertBodyWithAuth(res); + } else { + AssertUnauthorized(res); + } + }); + + it('should GET /required-permission if authenticated', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/required-permission`) + .set({ Authorization: accessToken }); + + // zendesk required + if (tokenName?.includes('zendesk')) { + AssertBodyWithAuth(res); + } else if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + + it('should GET /has-all-permissions if authenticated', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/has-all-permissions`) + .set({ Authorization: accessToken }); + + // zendesk and metabase + if (tokenName?.includes('zendesk') && tokenName?.includes('metabase')) { + AssertBodyWithAuth(res); + } else if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + + it('should GET /has-some-permission if authenticated', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/has-some-permission`) + .set({ Authorization: accessToken }); + + // zendesk or metabase + if (tokenName?.includes('zendesk') || tokenName?.includes('metabase')) { + AssertBodyWithAuth(res); + } else if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + }); + } + + function ClassAuthConditionTest(accessToken: string, tokenName: string[]) { + const route = '/class-auth-condition'; + + describe(`${route}, ${tokenName?.length ? tokenName : 'none'} auth`, () => { + describe('GET /body', () => { + it('should GET /body if authenticated and with correct headers', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/body`) + .set({ + 'x-on-class': 'ok', + Authorization: accessToken, + }); + + // metabase required + if (tokenName?.includes('metabase')) { + AssertBodyWithAuth(res); + } else if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + + it('should not GET /body without correct headers', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/body`) + .set({ Authorization: accessToken }); + + if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + }); + + describe('GET /authenticated', () => { + it('should GET /authenticated if authenticated and with correct headers', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/authenticated`) + .set({ + 'x-on-class': 'ok', + Authorization: accessToken, + }); + + // metabase required + if (tokenName?.includes('metabase')) { + AssertBodyWithAuth(res); + } else if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + + it('should not GET /authenticated without correct class headers', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/authenticated`) + .set({ Authorization: accessToken }); + + if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + }); + + describe('GET /required-permission', () => { + it('should GET /required-permission if authenticated and with correct headers', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/required-permission`) + .set({ + 'x-on-class': 'ok', + 'x-on-route': 'ok', + Authorization: accessToken, + }); + + // zendesk and metabase + if ( + tokenName?.includes('zendesk') && + tokenName?.includes('metabase') + ) { + AssertBodyWithAuth(res); + } else if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + + it('should not GET /required-permission without correct route headers', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/required-permission`) + .set({ + 'x-on-class': 'ok', + Authorization: accessToken, + }); + + if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + + it('should not GET /required-permission without correct class headers', async () => { + const res = await request(app.getHttpServer()) + .get(`${route}/required-permission`) + .set({ + 'x-on-route': 'ok', + Authorization: accessToken, + }); + + if (tokenName?.length) { + AssertForbidden(res); + } else { + AssertUnauthorized(res); + } + }); + }); + }); + } + + describe('token validation', () => { + it('should accept token kid A', async () => { + const Authorization = CreateToken([], { + jwt: { + kid: jwtSecretA.kid, + pem: jwtSecretA.pem, + }, + }); + + const res = await request(app.getHttpServer()) + .get('/no-class-auth/authenticated') + .set({ + Authorization, + }); + + AssertBodyWithAuth(res); + }); + + it('should accept token kid B', async () => { + const Authorization = CreateToken([], { + jwt: { + kid: jwtSecretB.kid, + pem: jwtSecretB.pem, + }, + }); + + const res = await request(app.getHttpServer()) + .get('/no-class-auth/authenticated') + .set({ + Authorization, + }); + + AssertBodyWithAuth(res); + }); + + it('should not accept unknown kid', async () => { + const Authorization = CreateToken([], { + jwt: { + kid: 'unknown-key-id', + pem: jwtSecretA.pem, + }, + }); + + const res = await request(app.getHttpServer()) + .get('/no-class-auth/authenticated') + .set({ + Authorization, + }); + + AssertUnauthorized(res); + }); + }); + + function CreateToken(permissions, overrides?: { user?: any; jwt?: any }) { + const tokenPayload = { + ...fakeUserPayload, + permissions: permissions.map(({ seqid }) => seqid), + token_use: 'access', + ...overrides?.user, + }; + + return jwt.sign(tokenPayload, overrides?.jwt?.pem ?? jwtSecretA.pem, { + keyid: overrides?.jwt?.kid ?? jwtSecretA.kid, + }); + } + + NoClassAuthTest(null, null); + ClassAuthConditionTest(null, null); + + const tokenZ = CreateToken([Permissions.ZENDESK.OPEN]); + NoClassAuthTest(tokenZ, ['zendesk']); + ClassAuthConditionTest(tokenZ, ['zendesk']); + + const tokenM = CreateToken([Permissions.METABASE.OPEN]); + NoClassAuthTest(tokenM, ['metabase']); + ClassAuthConditionTest(tokenM, ['metabase']); + + const tokenZM = CreateToken([ + Permissions.ZENDESK.OPEN, + Permissions.METABASE.OPEN, + ]); + NoClassAuthTest(tokenZM, ['zendesk', 'metabase']); + ClassAuthConditionTest(tokenZM, ['zendesk', 'metabase']); +}); diff --git a/src/authentication/authentication.guard.ts b/src/authentication/authentication.guard.ts index 6236408..399ca9f 100644 --- a/src/authentication/authentication.guard.ts +++ b/src/authentication/authentication.guard.ts @@ -11,13 +11,11 @@ import assert from 'assert'; import jwt from 'jsonwebtoken'; import { DadosferaLogger } from 'dadosfera-logs'; import { AuthClientService } from '../clients/auth/client.service'; -import { Permission } from './permissions.enum'; import { AuthenticationFunction, - PERMISSIONS_KEY, - CUSTOM_AUTHENTICATION_FUNCTION_KEY, - MUST_BE_AUTHENTICATED_KEY, + AUTH_FUNCTION_KEY, } from './authentication.decorator'; +import { RequestUser } from './user.decorator'; import ErrorBuilder from '../utils/ErrorBuilder'; import ErrorCodes from '../utils/errorCodes'; @@ -43,7 +41,7 @@ export class AuthenticationGuard return this.loadDucJWKS(); } - async loadDucJWKS() { + private async loadDucJWKS() { const { keys } = await this.authClient.getPublicKeys(); keys.forEach((key) => { @@ -52,57 +50,38 @@ export class AuthenticationGuard } canActivate(ctx: ExecutionContext): boolean { - const requiredPermissions = this.reflector.getAllAndOverride( - PERMISSIONS_KEY, - [ctx.getHandler(), ctx.getClass()], - ); - const customAuthenticationFunction = - this.reflector.getAllAndOverride( - CUSTOM_AUTHENTICATION_FUNCTION_KEY, - [ctx.getHandler(), ctx.getClass()], - ); - const mustBeAuthenticated = this.reflector.getAllAndOverride( - MUST_BE_AUTHENTICATED_KEY, - [ctx.getHandler(), ctx.getClass()], - ); + const authFunctions = this.reflector.getAllAndMerge< + AuthenticationFunction[] + >(AUTH_FUNCTION_KEY, [ctx.getClass(), ctx.getHandler()]); + const mustBeAuthenticated = authFunctions.length > 0; const request = ctx.switchToHttp().getRequest(); - const accessToken = this.verifyToken(request, mustBeAuthenticated); + const accessToken = this.validateToken(request, mustBeAuthenticated); + + if (!mustBeAuthenticated) { + // no need to be authenticated + return true; + } if (!accessToken) { // couldn't load valid token - - if (!mustBeAuthenticated) { - // no need to be authenticated - return true; - } - throw new ErrorBuilder(ErrorCodes.AUTH.UNAUTHORIZED); } - if ( - typeof customAuthenticationFunction === 'function' && - !customAuthenticationFunction(request, request.user) - ) { - // custom authentication function forbidden this request - throw new ErrorBuilder(ErrorCodes.AUTH.FORBIDDEN); - } - - if ( - Array.isArray(requiredPermissions) && - requiredPermissions.length > 0 && - !this.matchPermissions(requiredPermissions, accessToken.permissions) - ) { - // couldn't match permissions + // every authentication function must return true to authenticate + if (!authFunctions.every((func) => func(request, accessToken))) { throw new ErrorBuilder(ErrorCodes.AUTH.FORBIDDEN); } return true; } - private verifyToken(request, mustBeAuthenticated: boolean) { + private validateToken( + request, + mustBeAuthenticated: boolean, + ): RequestUser | false { const accessToken = request.get('Authorization'); - let accessTokenPayload; + let accessTokenPayload: RequestUser; // If the user isn't authenticated, an error will occurr anywhere here. // Fancy error avoidance isn't performed by purpose, such as avoiding to access null values. @@ -154,13 +133,4 @@ export class AuthenticationGuard return accessTokenPayload; } - - private matchPermissions( - requiredPermissions: Permission[], - userPermissions: number[], - ) { - return requiredPermissions.some((permission) => - userPermissions.includes(permission.seqid), - ); - } } diff --git a/src/authentication/user.decorator.spec.ts b/src/authentication/user.decorator.spec.ts new file mode 100644 index 0000000..a8fa989 --- /dev/null +++ b/src/authentication/user.decorator.spec.ts @@ -0,0 +1,174 @@ +import request from 'supertest'; +import { Test } from '@nestjs/testing'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import jwt from 'jsonwebtoken'; + +import { Controller, Get } from '@nestjs/common'; +import { DadosferaLogger } from 'dadosfera-logs'; +import { AuthenticationGuard } from './authentication.guard'; +import { AuthClientService } from '../clients/auth/client.service'; + +import ErrorCodes from '../utils/errorCodes'; +import { User } from './user.decorator'; +import { Permissions } from './permissions.enum'; + +@Controller('user') +class UserController { + @Get() + public getUser(@User() user) { + return { user }; + } + + @Get('options') + public getUserWithOptions(@User({}) user) { + return { user }; + } + + @Get('not-required') + public getUserNotRequired(@User({ required: false }) user) { + return { user }; + } + + @Get('required') + public getUserRequired(@User({ required: true }) user) { + return { user }; + } +} + +describe('user.decorator', () => { + let app: INestApplication; + const jwt_secret = { + kid: 'token-testing-shared-key', + pem: '$tr0ng-SH4Red-secr3t!!!~gl0ba1~]', + }; + const fakeUserPayload = { + user_id: 'd50d33c7-6c2b-463c-861f-e21667e7c125', + username: 'super.admin', + permissions: [Permissions.METABASE.OPEN].map(({ seqid }) => seqid), + customer_id: '9d18e8ae-24b9-41a3-9e8f-a25ce57555b11', + customer_name: 'dadosfera', + customer_tier: 'BASIC', + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: AuthClientService, + useValue: { + getPublicKeys: async () => ({ + keys: [jwt_secret], + }), + }, + }, + { + provide: DadosferaLogger, + useValue: { logger: console }, + }, + { + provide: APP_GUARD, + useClass: AuthenticationGuard, + }, + ], + controllers: [UserController], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + function AssertNoAuth(res: request.Response) { + expect(res.statusCode).toBe(HttpStatus.OK); + expect(res.body).toStrictEqual({}); + } + + function AssertWithAuth(res: request.Response) { + expect(res.statusCode).toBe(HttpStatus.OK); + expect(res.body).toStrictEqual({ user: fakeUserPayload }); + } + + function AssertRequiredNoAuth(res: request.Response) { + expect(res.statusCode).toBe(HttpStatus.UNAUTHORIZED); + expect(res.body).toStrictEqual({ + code: ErrorCodes.AUTH.UNAUTHORIZED, + error: 'Não autenticado', + message: 'É necessário estar logado para realizar essa operação', + statusCode: HttpStatus.UNAUTHORIZED, + }); + } + + function UserTest(accessToken: string) { + describe(`with${accessToken ? '' : 'out'} token`, () => { + it('should GET /user', async () => { + const res = await request(app.getHttpServer()) + .get('/user') + .set({ Authorization: accessToken }); + + if (accessToken) { + AssertWithAuth(res); + } else { + AssertNoAuth(res); + } + }); + + it('should GET /user/options', async () => { + const res = await request(app.getHttpServer()) + .get('/user/options') + .set({ Authorization: accessToken }); + + if (accessToken) { + AssertWithAuth(res); + } else { + AssertNoAuth(res); + } + }); + + it('should GET /user/not-required', async () => { + const res = await request(app.getHttpServer()) + .get('/user/not-required') + .set({ Authorization: accessToken }); + + if (accessToken) { + AssertWithAuth(res); + } else { + AssertNoAuth(res); + } + }); + + it('should GET /user/required if logged', async () => { + const res = await request(app.getHttpServer()) + .get('/user/required') + .set({ Authorization: accessToken }); + + if (accessToken) { + AssertWithAuth(res); + } else { + AssertRequiredNoAuth(res); + } + }); + }); + } + + function CreateToken(overrides?) { + const tokenPayload = { + ...fakeUserPayload, + token_use: 'access', + ...overrides?.user, + }; + + return jwt.sign(tokenPayload, jwt_secret.pem, { + keyid: jwt_secret.kid, + ...overrides?.jwt, + }); + } + + UserTest(null); + + const token = CreateToken(); + UserTest(token); +}); diff --git a/src/authentication/user.decorator.ts b/src/authentication/user.decorator.ts index d5fe984..31041ff 100644 --- a/src/authentication/user.decorator.ts +++ b/src/authentication/user.decorator.ts @@ -6,18 +6,19 @@ import ErrorCodes from '../utils/errorCodes'; export interface RequestUser { user_id: string; username: string; - permissions: string; + permissions: number[]; customer_id: string; customer_name: string; customer_tier: string; } -export const User = createParamDecorator((data: any, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); +export const User: (options?: { required?: boolean }) => ParameterDecorator = + createParamDecorator((options: any, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); - if (!request.user && data?.required) { - throw new ErrorBuilder(ErrorCodes.AUTH.UNAUTHORIZED); - } + if (!request.user && options?.required) { + throw new ErrorBuilder(ErrorCodes.AUTH.UNAUTHORIZED); + } - return request.user; -}); + return request.user; + }); diff --git a/src/error/http-exception.filter.spec.ts b/src/error/http-exception.filter.spec.ts deleted file mode 100644 index 8f016dd..0000000 --- a/src/error/http-exception.filter.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { HttpExceptionFilter } from './http-exception.filter'; - -describe('HttpExceptionFilter', () => { - it('should be defined', () => { - expect(new HttpExceptionFilter()).toBeDefined(); - }); -}); diff --git a/src/modules/health/health.service.spec.ts b/src/modules/health/health.service.spec.ts deleted file mode 100644 index 89e1958..0000000 --- a/src/modules/health/health.service.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('PipelinesGrpcServerService', () => { - it('should be defined', () => { - expect(2 + 2).toBe(4); - }); -}); diff --git a/src/modules/inputs/inputs.service.spec.ts b/src/modules/inputs/inputs.service.spec.ts deleted file mode 100644 index 89e1958..0000000 --- a/src/modules/inputs/inputs.service.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('PipelinesGrpcServerService', () => { - it('should be defined', () => { - expect(2 + 2).toBe(4); - }); -}); diff --git a/src/modules/outputs/outputs.service.spec.ts b/src/modules/outputs/outputs.service.spec.ts deleted file mode 100644 index 89e1958..0000000 --- a/src/modules/outputs/outputs.service.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('PipelinesGrpcServerService', () => { - it('should be defined', () => { - expect(2 + 2).toBe(4); - }); -}); diff --git a/src/modules/pipelines/pipelines.service.spec.ts b/src/modules/pipelines/pipelines.service.spec.ts deleted file mode 100644 index 89e1958..0000000 --- a/src/modules/pipelines/pipelines.service.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('PipelinesGrpcServerService', () => { - it('should be defined', () => { - expect(2 + 2).toBe(4); - }); -}); diff --git a/src/modules/transformations/transformations.service.spec.ts b/src/modules/transformations/transformations.service.spec.ts deleted file mode 100644 index 89e1958..0000000 --- a/src/modules/transformations/transformations.service.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('PipelinesGrpcServerService', () => { - it('should be defined', () => { - expect(2 + 2).toBe(4); - }); -});