From 95ef52ed8b034b4744c5bb955a80fd5178c94998 Mon Sep 17 00:00:00 2001 From: Jesse Desjardins Date: Thu, 5 Feb 2026 18:58:18 -0500 Subject: [PATCH] Added authentication and guards to HTTP routes; updated README --- README.md | 50 +++++++- set-env.sh | 5 +- src/authentication/authentication.guard.ts | 92 ++++++++++++++- src/authentication/authentication.module.ts | 13 ++- src/authentication/authentication.service.ts | 115 ++++++++++--------- src/entries/entries.controller.ts | 8 ++ src/entries/entries.module.ts | 7 +- src/users/users.module.ts | 3 +- 8 files changed, 232 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 03cfbed..e2c111f 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,58 @@ $ npm install ## Running local infrastructure -To simplify development, all local infrastructure is handled with Docker. The folder `dev_infra` contains the folloeing: +To simplify development, all local infrastructure is handled with Docker. The folder `dev_infra` contains the following: - `compose.yaml`: A Docker compose file that stands up a Postgres database and a pgAdmin instance to visualize. +To start up the local infrastructure, open a new terminal session, navigate to the `dev_infra` folder, and run the following command: + +```bash +# start up the docker instances for the Postgres database and the pgAdmin interface +$ docker compose up + +# use 'ctrl + c' on the keyboard to close the Docker view +``` + +Once finished, use the following command to tear down the local infrastructure: + +```bash +# tear down the docker instances +$ docker compose down +``` + +### Authentication + +To test authentication in development, a dev cognito pool was created. Its' important values are included in the `set-env.sh` file. The pool consists of the following users: + +- email: jesse.desjardins@ccascoe.org, username: 5c6d4528-5021-704a-27fd-68342d44783b, password: DuckTester25! + +An access token will be required to test the routes. A safe way of getting one is as follows: + +1. Logging into the default [Cognito Login page](https://ca-central-1ea62zdmoc.auth.ca-central-1.amazoncognito.com/login/continue?client_id=7ph76km1h1v4vkt4rj9c3s4o6&redirect_uri=https%3A%2F%2Fd84l1y8p4kdic.cloudfront.net&response_type=code&scope=email+openid+phone) using that URL, as it has the `openid` scope included. + +2. After logging in, the URL will have a `code` parameter appended at then end. Use that code in the following curl command: + +```bash + curl -X POST \ + https://ca-central-1ea62zdmoc.auth.ca-central-1.amazoncognito.com/oauth2/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "client_id=7ph76km1h1v4vkt4rj9c3s4o6" \ + -d "code=" \ + -d "redirect_uri=https://d84l1y8p4kdic.cloudfront.net" +``` + +3. The curl command will return, among other things, an `access_token` value. That token will be valid for 1 hour and will work for manual tests of the API if included in all API calls. + +#### Alternative ways of retrieving tokens + +Using the bolow command will get a token from the AWS CLI, but it will not have the required "openId" scope and will fail authorization in the API: + +```bash +aws cognito-idp admin-initiate-auth --user-pool-id "ca-central-1_ea62zDmOC" --client-id "7ph76km1h1v4vkt4rj9c3s4o6" --auth-flow "ADMIN_USER_PASSWORD_AUTH" --auth-parameters "USERNAME=5c6d4528-5021-704a-27fd-68342d44783b,PASSWORD=DuckTester25!" +``` + ### Database Migrations Any time a change is made to a `.entity.ts` file, a new migration will need to be generated and added to the `appDataSource.ts` file. This ensures the API is refencing the most up-to-date (and valid!) database schema. These are the migration commands: diff --git a/set-env.sh b/set-env.sh index 4d065fc..d194a3d 100644 --- a/set-env.sh +++ b/set-env.sh @@ -7,6 +7,9 @@ export DB_PASSWORD=glossary_dev_password export DB_NAME=glossary_dev_db # Authentication server info -export AUTH_BASE_URL= +export AUTH_USER_POOL_ID=ca-central-1_ea62zDmOC +export AUTH_REGION=ca-central-1 +export AUTH_BASE_URL=https://ca-central-1ea62zdmoc.auth.ca-central-1.amazoncognito.com +export AUTH_APP_CLIENT_ID=7ph76km1h1v4vkt4rj9c3s4o6 npm run db:run_migrations \ No newline at end of file diff --git a/src/authentication/authentication.guard.ts b/src/authentication/authentication.guard.ts index ac78231..2ef7a23 100644 --- a/src/authentication/authentication.guard.ts +++ b/src/authentication/authentication.guard.ts @@ -1 +1,91 @@ -// TODO create guard \ No newline at end of file +import { + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthenticationService } from './authentication.service'; +import { LogsService } from 'src/logs/logs.service'; +import { Request } from 'express'; + +@Injectable() +export class AuthenticationGuard implements CanActivate { + constructor( + private readonly authenticationService: AuthenticationService, + private readonly logger: LogsService, + ) {} + + /** + * Used by the guard attached to a controller's route. Determines wether the current request is allowed to proceed. + * + * @param context + */ + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + if (!token) { + const exception = new UnauthorizedException( + 'Authrorization: Bearer header invalid', + ); + this.logger.error( + exception.message, + exception.stack, + 'AuthenticationGuard', + ); + throw exception; + } + + try { + // store the user on the request object if we want to retrieve it from the controllers + request['user'] = + await this.authenticationService.authenticateWithCognito(token); + } catch { + const exception = new UnauthorizedException(); + this.logger.error( + exception.message, + exception.stack, + 'AuthenticationGuard', + ); + throw exception; + } + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const authHeader: string | null = ''; + const [type, token] = request.headers.authorization?.split(' ') ?? []; + + if (!type) { + const exception = new HttpException( + 'Authrorization: Bearer header missing', + HttpStatus.UNAUTHORIZED, + ); + this.logger.error( + exception.message, + exception.stack, + 'AuthenticationGuard', + ); + throw exception; + } + + if (type !== 'Bearer') { + const exception = new HttpException( + 'Authrorization: Bearer header invalid', + HttpStatus.UNAUTHORIZED, + ); + this.logger.error( + exception.message, + exception.stack, + 'AuthenticationGuard', + ); + throw exception; + } + return type === 'Bearer' ? token : undefined; + + // const [type, token] = request.headers.authorization?.split(' ') ?? []; + // return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/authentication/authentication.module.ts b/src/authentication/authentication.module.ts index 0384cbd..9d9b504 100644 --- a/src/authentication/authentication.module.ts +++ b/src/authentication/authentication.module.ts @@ -1 +1,12 @@ -// TODO create module \ No newline at end of file +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { AuthenticationGuard } from './authentication.guard'; +import { AuthenticationService } from './authentication.service'; +import { LogsModule } from 'src/logs/logs.module'; + +@Module({ + imports: [HttpModule, LogsModule], + providers: [AuthenticationGuard, AuthenticationService], + exports: [AuthenticationService], +}) +export class AuthenticationModule {} diff --git a/src/authentication/authentication.service.ts b/src/authentication/authentication.service.ts index 1a78b39..0970633 100644 --- a/src/authentication/authentication.service.ts +++ b/src/authentication/authentication.service.ts @@ -1,7 +1,7 @@ -import { HttpService } from "@nestjs/axios"; -import { Injectable } from "@nestjs/common"; -import { lastValueFrom } from "rxjs"; -import { LogsService } from "src/logs/logs.service"; +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { lastValueFrom } from 'rxjs'; +import { LogsService } from 'src/logs/logs.service'; /** * Error thrown when an authentication check has failed @@ -12,60 +12,65 @@ export class AuthenticationError extends Error {} * Simple user class that holds key info for reference later */ export class AuthUser { + /** + * Identifying user ID (i.e. "sub" field in AWS Cognito) + */ + id: string; - /** - * Identifying user ID (i.e. "sub" field in AWS Cognito) - */ - id: string; - - /** - * User's customized username - */ - username: string; + /** + * User's customized username + */ + username: string; } @Injectable() export class AuthenticationService { - constructor(private httpService: HttpService, private readonly logger: LogsService,) { - this.baseURL = process.env.AUTH_BASE_URL + constructor( + private httpService: HttpService, + private readonly logger: LogsService, + ) { + this.baseURL = process.env.AUTH_BASE_URL; + } + /** + * The base URL of the service being used for authentication + */ + private readonly baseURL: string | undefined; + + /** + * Calls the OpenID Connect UserInfo endpoint. + * + * If the call succeeds, the token is valid and the user info is returned in the response. + * If the call fails, the token is either invalid or expired. + * + * @param accessToken Access token passed into the HTTP call for authentication + */ + async authenticateWithCognito(accessToken: string): Promise { + const authUrl = `${this.baseURL}/oauth2/userInfo`; + + try { + const response = this.httpService.get(authUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + const responseData = await lastValueFrom(response); + await this.logger.log( + `Access attempted with resolved user: ${responseData.data.sub}`, + 'AuthenticationService', + ); + + return { + id: responseData?.data.sub ?? '', + username: responseData?.data.preferred_username ?? '', + }; + } catch (err: any) { + const authErr = new AuthenticationError(err.message); + await this.logger.error( + authErr.message, + authErr.stack, + 'AuthenticationService', + ); + throw authErr; } - /** - * The base URL of the service being used for authentication - */ - private readonly baseURL: string | undefined; - - /** - * Calls the OpenID Connect UserInfo endpoint. - * - * If the call succeeds, the token is valid and the user info is returned in the response. - * If the call fails, the token is either invalid or expired. - * - * @param accessToken Access token passed into the HTTP call for authentication - */ - async authenticateWithCognito(accessToken: string): Promise { - const authUrl = `${this.baseURL}/oauth2/userInfo` - - try { - const response = this.httpService.get(authUrl, { - headers: { - Authorization: `Bearer ${accessToken}` - } - }); - const responseData = await lastValueFrom(response) - this.logger.log(`Access attempted with resolved user: ${responseData.data.sub}`, 'AuthenticationService') - - return { - id: responseData?.data.sub ?? "", - username: responseData?.data.preferred_username ?? "" - } - } catch (err: any) { - const authErr = new AuthenticationError(err.message) - await this.logger.error( - authErr.message, - authErr.stack, - 'AuthenticationService', - ); - throw authErr; - } - } -} \ No newline at end of file + } +} diff --git a/src/entries/entries.controller.ts b/src/entries/entries.controller.ts index a4b6c61..317c5fa 100644 --- a/src/entries/entries.controller.ts +++ b/src/entries/entries.controller.ts @@ -7,10 +7,12 @@ import { Param, Post, Put, + UseGuards, } from '@nestjs/common'; import { EntryService } from './entries.service'; import { EntryDTO } from './entries.dto'; import { Entry } from './entries.entity'; +import { AuthenticationGuard } from 'src/authentication/authentication.guard'; @Controller('entries') export class EntriesController { @@ -20,11 +22,13 @@ export class EntriesController { ) {} @Post() + @UseGuards(AuthenticationGuard) async saveEntry(@Body() entry: EntryDTO): Promise { return (await this.entryService.save(entry)).uuid; } @Put(':uuid') + @UseGuards(AuthenticationGuard) async updateEntry( @Param('uuid') uuid: string, @Body() entry: EntryDTO, @@ -33,6 +37,7 @@ export class EntriesController { } @Get() + @UseGuards(AuthenticationGuard) async findAll(): Promise { const entries = await this.entryService.findAll(); return entries.sort((a, b) => { @@ -44,16 +49,19 @@ export class EntriesController { } @Get(':uuid') + @UseGuards(AuthenticationGuard) async findOneByUuid(@Param('uuid') uuid: string): Promise { return await this.entryService.findOneByUuid(uuid); } @Delete(':uuid') + @UseGuards(AuthenticationGuard) async softDelete(@Param('uuid') uuid: string): Promise { await this.entryService.softDeleteByUuid(uuid); } @Put('/restore/:uuid') + @UseGuards(AuthenticationGuard) async restoreSoftDeleted(@Param('uuid') uuid: string): Promise { return await this.entryService.restoreDeletedByUuid(uuid); } diff --git a/src/entries/entries.module.ts b/src/entries/entries.module.ts index a31d160..e8b90ac 100644 --- a/src/entries/entries.module.ts +++ b/src/entries/entries.module.ts @@ -4,9 +4,14 @@ import { Entry } from './entries.entity'; import { EntryService } from './entries.service'; import { EntriesController } from './entries.controller'; import { LogsModule } from 'src/logs/logs.module'; +import { AuthenticationModule } from 'src/authentication/authentication.module'; @Module({ - imports: [TypeOrmModule.forFeature([Entry]), LogsModule], + imports: [ + TypeOrmModule.forFeature([Entry]), + LogsModule, + AuthenticationModule, + ], exports: [EntryService], providers: [EntryService], controllers: [EntriesController], diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 9bdf27a..01c9f36 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './users.entity'; import { UserService } from './users.service'; import { UsersController } from './users.controller'; +import { AuthenticationModule } from 'src/authentication/authentication.module'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User]), AuthenticationModule], exports: [UserService], providers: [UserService], controllers: [UsersController],