Added authentication and guards to HTTP routes; updated README
This commit is contained in:
50
README.md
50
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,7 +12,6 @@ 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)
|
* Identifying user ID (i.e. "sub" field in AWS Cognito)
|
||||||
*/
|
*/
|
||||||
@@ -26,8 +25,11 @@ export class AuthUser {
|
|||||||
|
|
||||||
@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
|
* The base URL of the service being used for authentication
|
||||||
@@ -43,23 +45,26 @@ export class AuthenticationService {
|
|||||||
* @param accessToken Access token passed into the HTTP call for authentication
|
* @param accessToken Access token passed into the HTTP call for authentication
|
||||||
*/
|
*/
|
||||||
async authenticateWithCognito(accessToken: string): Promise<AuthUser> {
|
async authenticateWithCognito(accessToken: string): Promise<AuthUser> {
|
||||||
const authUrl = `${this.baseURL}/oauth2/userInfo`
|
const authUrl = `${this.baseURL}/oauth2/userInfo`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = this.httpService.get(authUrl, {
|
const response = this.httpService.get(authUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`
|
Authorization: `Bearer ${accessToken}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
const responseData = await lastValueFrom(response)
|
const responseData = await lastValueFrom(response);
|
||||||
this.logger.log(`Access attempted with resolved user: ${responseData.data.sub}`, 'AuthenticationService')
|
await this.logger.log(
|
||||||
|
`Access attempted with resolved user: ${responseData.data.sub}`,
|
||||||
|
'AuthenticationService',
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: responseData?.data.sub ?? "",
|
id: responseData?.data.sub ?? '',
|
||||||
username: responseData?.data.preferred_username ?? ""
|
username: responseData?.data.preferred_username ?? '',
|
||||||
}
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const authErr = new AuthenticationError(err.message)
|
const authErr = new AuthenticationError(err.message);
|
||||||
await this.logger.error(
|
await this.logger.error(
|
||||||
authErr.message,
|
authErr.message,
|
||||||
authErr.stack,
|
authErr.stack,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
Reference in New Issue
Block a user