Added authentication and guards to HTTP routes; updated README

This commit is contained in:
2026-02-05 18:58:18 -05:00
parent fe02210c34
commit 95ef52ed8b
8 changed files with 232 additions and 61 deletions

View File

@@ -13,10 +13,58 @@ $ npm install
## Running local infrastructure ## 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. - `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=<enter code here>" \
-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 ### 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: 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:

View File

@@ -7,6 +7,9 @@ export DB_PASSWORD=glossary_dev_password
export DB_NAME=glossary_dev_db export DB_NAME=glossary_dev_db
# Authentication server info # 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 npm run db:run_migrations

View File

@@ -1 +1,91 @@
// TODO create guard 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<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
const exception = new UnauthorizedException(
'Authrorization: Bearer <token> 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 <token> header missing',
HttpStatus.UNAUTHORIZED,
);
this.logger.error(
exception.message,
exception.stack,
'AuthenticationGuard',
);
throw exception;
}
if (type !== 'Bearer') {
const exception = new HttpException(
'Authrorization: Bearer <token> 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;
}
}

View File

@@ -1 +1,12 @@
// TODO create module 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 {}

View File

@@ -1,7 +1,7 @@
import { HttpService } from "@nestjs/axios"; import { HttpService } from '@nestjs/axios';
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import { lastValueFrom } from "rxjs"; import { lastValueFrom } from 'rxjs';
import { LogsService } from "src/logs/logs.service"; import { LogsService } from 'src/logs/logs.service';
/** /**
* Error thrown when an authentication check has failed * 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 * Simple user class that holds key info for reference later
*/ */
export class AuthUser { 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) * User's customized username
*/ */
id: string; username: string;
/**
* User's customized username
*/
username: string;
} }
@Injectable() @Injectable()
export class AuthenticationService { export class AuthenticationService {
constructor(private httpService: HttpService, private readonly logger: LogsService,) { constructor(
this.baseURL = process.env.AUTH_BASE_URL 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<AuthUser> {
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<AuthUser> {
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;
}
}
}

View File

@@ -7,10 +7,12 @@ import {
Param, Param,
Post, Post,
Put, Put,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { EntryService } from './entries.service'; import { EntryService } from './entries.service';
import { EntryDTO } from './entries.dto'; import { EntryDTO } from './entries.dto';
import { Entry } from './entries.entity'; import { Entry } from './entries.entity';
import { AuthenticationGuard } from 'src/authentication/authentication.guard';
@Controller('entries') @Controller('entries')
export class EntriesController { export class EntriesController {
@@ -20,11 +22,13 @@ export class EntriesController {
) {} ) {}
@Post() @Post()
@UseGuards(AuthenticationGuard)
async saveEntry(@Body() entry: EntryDTO): Promise<string | undefined> { async saveEntry(@Body() entry: EntryDTO): Promise<string | undefined> {
return (await this.entryService.save(entry)).uuid; return (await this.entryService.save(entry)).uuid;
} }
@Put(':uuid') @Put(':uuid')
@UseGuards(AuthenticationGuard)
async updateEntry( async updateEntry(
@Param('uuid') uuid: string, @Param('uuid') uuid: string,
@Body() entry: EntryDTO, @Body() entry: EntryDTO,
@@ -33,6 +37,7 @@ export class EntriesController {
} }
@Get() @Get()
@UseGuards(AuthenticationGuard)
async findAll(): Promise<Entry[]> { async findAll(): Promise<Entry[]> {
const entries = await this.entryService.findAll(); const entries = await this.entryService.findAll();
return entries.sort((a, b) => { return entries.sort((a, b) => {
@@ -44,16 +49,19 @@ export class EntriesController {
} }
@Get(':uuid') @Get(':uuid')
@UseGuards(AuthenticationGuard)
async findOneByUuid(@Param('uuid') uuid: string): Promise<Entry | null> { async findOneByUuid(@Param('uuid') uuid: string): Promise<Entry | null> {
return await this.entryService.findOneByUuid(uuid); return await this.entryService.findOneByUuid(uuid);
} }
@Delete(':uuid') @Delete(':uuid')
@UseGuards(AuthenticationGuard)
async softDelete(@Param('uuid') uuid: string): Promise<void> { async softDelete(@Param('uuid') uuid: string): Promise<void> {
await this.entryService.softDeleteByUuid(uuid); await this.entryService.softDeleteByUuid(uuid);
} }
@Put('/restore/:uuid') @Put('/restore/:uuid')
@UseGuards(AuthenticationGuard)
async restoreSoftDeleted(@Param('uuid') uuid: string): Promise<Entry | null> { async restoreSoftDeleted(@Param('uuid') uuid: string): Promise<Entry | null> {
return await this.entryService.restoreDeletedByUuid(uuid); return await this.entryService.restoreDeletedByUuid(uuid);
} }

View File

@@ -4,9 +4,14 @@ import { Entry } from './entries.entity';
import { EntryService } from './entries.service'; import { EntryService } from './entries.service';
import { EntriesController } from './entries.controller'; import { EntriesController } from './entries.controller';
import { LogsModule } from 'src/logs/logs.module'; import { LogsModule } from 'src/logs/logs.module';
import { AuthenticationModule } from 'src/authentication/authentication.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Entry]), LogsModule], imports: [
TypeOrmModule.forFeature([Entry]),
LogsModule,
AuthenticationModule,
],
exports: [EntryService], exports: [EntryService],
providers: [EntryService], providers: [EntryService],
controllers: [EntriesController], controllers: [EntriesController],

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './users.entity'; import { User } from './users.entity';
import { UserService } from './users.service'; import { UserService } from './users.service';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { AuthenticationModule } from 'src/authentication/authentication.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User])], imports: [TypeOrmModule.forFeature([User]), AuthenticationModule],
exports: [UserService], exports: [UserService],
providers: [UserService], providers: [UserService],
controllers: [UsersController], controllers: [UsersController],