From 6f1d5dc544eb58af72ca0c36721b7c07271dce7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ux=C3=ADo?= Date: Thu, 5 Oct 2023 13:41:08 +0200 Subject: [PATCH] Add authentication for SSE endpoint (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - It can be set by `SSE_AUTH_TOKEN` environment variable. If not defined, it will be disabled - Update tests - Add info about endpoint to README --------- Co-authored-by: Moisés Fernández <7888669+moisses89@users.noreply.github.com> --- .env.sample | 3 ++- README.md | 7 +++++- src/auth/basic-auth.guard.ts | 16 +++++++++++++ src/routes/events/events.controller.spec.ts | 4 +++- src/routes/events/events.controller.ts | 12 +++++++++- src/routes/events/events.module.ts | 3 ++- test/app.e2e-spec.ts | 26 +++++++++++++++++---- 7 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 src/auth/basic-auth.guard.ts diff --git a/.env.sample b/.env.sample index fc90360..1372d73 100644 --- a/.env.sample +++ b/.env.sample @@ -6,4 +6,5 @@ ADMIN_EMAIL=admin@safe ADMIN_PASSWORD=password WEBHOOKS_CACHE_TTL=300000 NODE_ENV=dev -#URL_BASE_PATH=/test # Set a globlal url path +SSE_AUTH_TOKEN=aW5mcmFAc2FmZS5nbG9iYWw6YWJjMTIz +# URL_BASE_PATH=/test # Set a globlal url path diff --git a/README.md b/README.md index f3bba77..db077d8 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ Some parameters are common to every event: } ``` -### Message created/confirmed +### Message created/confirmed + ```json { "address": "", @@ -147,11 +148,13 @@ cp .env.sample .env ``` Simple way: + ```bash bash ./scripts/run_tests.sh ``` Manual way: + ```bash docker compose down docker compose up -d rabbitmq db db-migrations @@ -182,3 +185,5 @@ Available endpoints: - /health/ -> Check health for the service. - /admin/ -> Admin panel to edit database models. +- /events/sse/{CHECKSUMMED_SAFE_ADDRESS} -> Server side events endpoint. If `SSE_AUTH_TOKEN` is defined then authentication + will be enabled and header `Authorization: Basic $SSE_AUTH_TOKEN` must be added to the request. diff --git a/src/auth/basic-auth.guard.ts b/src/auth/basic-auth.guard.ts new file mode 100644 index 0000000..1822ecf --- /dev/null +++ b/src/auth/basic-auth.guard.ts @@ -0,0 +1,16 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class BasicAuthGuard implements CanActivate { + constructor(private readonly configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean | Promise { + const request = context.switchToHttp().getRequest(); + const token = this.configService.get('SSE_AUTH_TOKEN', ''); + // If token is not set, authentication is disabled + return ( + token === '' || request.headers['authorization'] === `Basic ${token}` + ); + } +} diff --git a/src/routes/events/events.controller.spec.ts b/src/routes/events/events.controller.spec.ts index 5bb629c..fc240d3 100644 --- a/src/routes/events/events.controller.spec.ts +++ b/src/routes/events/events.controller.spec.ts @@ -1,11 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { EventsController } from './events.controller'; import { EventsService } from './events.service'; import { QueueProvider } from '../../datasources/queue/queue.provider'; import { WebhookService } from '../webhook/webhook.service'; import { firstValueFrom } from 'rxjs'; import { TxServiceEvent, TxServiceEventType } from './event.dto'; -import { BadRequestException } from '@nestjs/common'; describe('EventsController', () => { let controller: EventsController; @@ -13,6 +14,7 @@ describe('EventsController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot()], controllers: [EventsController], providers: [EventsService, QueueProvider, WebhookService], }) diff --git a/src/routes/events/events.controller.ts b/src/routes/events/events.controller.ts index 429df38..1b0fe27 100644 --- a/src/routes/events/events.controller.ts +++ b/src/routes/events/events.controller.ts @@ -1,12 +1,22 @@ -import { BadRequestException, Controller, Param, Sse } from '@nestjs/common'; +import { + BadRequestException, + Controller, + Param, + Sse, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { Observable } from 'rxjs'; import { EventsService } from './events.service'; import { getAddress, isAddress } from 'ethers'; +import { BasicAuthGuard } from '../../auth/basic-auth.guard'; +@ApiTags('events') @Controller('events') export class EventsController { constructor(private readonly eventsService: EventsService) {} + @UseGuards(BasicAuthGuard) @Sse('/sse/:safe') sse(@Param('safe') safe: string): Observable { if (isAddress(safe) && getAddress(safe) === safe) diff --git a/src/routes/events/events.module.ts b/src/routes/events/events.module.ts index 49a5879..7df967e 100644 --- a/src/routes/events/events.module.ts +++ b/src/routes/events/events.module.ts @@ -3,9 +3,10 @@ import { EventsController } from './events.controller'; import { EventsService } from './events.service'; import { QueueModule } from '../../datasources/queue/queue.module'; import { WebhookModule } from '../webhook/webhook.module'; +import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [QueueModule, WebhookModule], + imports: [QueueModule, WebhookModule, ConfigModule], controllers: [EventsController], providers: [EventsService], }) diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 2085e7e..b8f9dde 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -42,8 +42,21 @@ describe('AppController (e2e)', () => { }); describe('/events/sse/:safe (GET)', () => { + const validSafeAddress = '0x8618ce407F169ABB1388348A19632AaFA857CCB9'; + const notValidAddress = '0x8618CE407F169ABB1388348A19632AaFA857CCB9'; + const sseAuthToken = 'aW5mcmFAc2FmZS5nbG9iYWw6YWJjMTIz'; + const sseAuthorizationHeader = `Basic ${sseAuthToken}`; + + it('should be protected by an Authorization token', () => { + const url = `/events/sse/${validSafeAddress}`; + const expected = { + statusCode: 403, + message: 'Forbidden resource', + error: 'Forbidden', + }; + return request(app.getHttpServer()).get(url).expect(403).expect(expected); + }); it('should subscribe and receive Server Side Events', () => { - const validSafeAddress = '0x8618ce407F169ABB1388348A19632AaFA857CCB9'; const msg = { chainId: '1', type: 'SAFE_CREATED' as TxServiceEventType, @@ -60,7 +73,9 @@ describe('AppController (e2e)', () => { const protocol = server instanceof Server ? 'https' : 'http'; const url = protocol + '://127.1.0.1:' + port + path; - const eventSource = new EventSource(url); + const eventSource = new EventSource(url, { + headers: { Authorization: sseAuthorizationHeader }, + }); // Use an empty promise so test has to wait for it, and do the cleanup there const messageReceived = new Promise((resolve) => { eventSource.onmessage = (event) => { @@ -82,14 +97,17 @@ describe('AppController (e2e)', () => { return messageReceived; }); it('should return a 400 if safe address is not EIP55 valid', () => { - const notValidAddress = '0x8618CE407F169ABB1388348A19632AaFA857CCB9'; const url = `/events/sse/${notValidAddress}`; const expected = { statusCode: 400, message: 'Not valid EIP55 address', error: `${notValidAddress} is not a valid EIP55 Safe address`, }; - return request(app.getHttpServer()).get(url).expect(400).expect(expected); + return request(app.getHttpServer()) + .get(url) + .set('Authorization', sseAuthorizationHeader) + .expect(400) + .expect(expected); }); }); });