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
|
||||
|
||||
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=<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
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -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 { 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,7 +12,6 @@ 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)
|
||||
*/
|
||||
@@ -26,8 +25,11 @@ export class AuthUser {
|
||||
|
||||
@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
|
||||
@@ -43,23 +45,26 @@ export class AuthenticationService {
|
||||
* @param accessToken Access token passed into the HTTP call for authentication
|
||||
*/
|
||||
async authenticateWithCognito(accessToken: string): Promise<AuthUser> {
|
||||
const authUrl = `${this.baseURL}/oauth2/userInfo`
|
||||
const authUrl = `${this.baseURL}/oauth2/userInfo`;
|
||||
|
||||
try {
|
||||
const response = this.httpService.get(authUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const responseData = await lastValueFrom(response)
|
||||
this.logger.log(`Access attempted with resolved user: ${responseData.data.sub}`, 'AuthenticationService')
|
||||
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 ?? ""
|
||||
}
|
||||
id: responseData?.data.sub ?? '',
|
||||
username: responseData?.data.preferred_username ?? '',
|
||||
};
|
||||
} catch (err: any) {
|
||||
const authErr = new AuthenticationError(err.message)
|
||||
const authErr = new AuthenticationError(err.message);
|
||||
await this.logger.error(
|
||||
authErr.message,
|
||||
authErr.stack,
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<Entry[]> {
|
||||
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<Entry | null> {
|
||||
return await this.entryService.findOneByUuid(uuid);
|
||||
}
|
||||
|
||||
@Delete(':uuid')
|
||||
@UseGuards(AuthenticationGuard)
|
||||
async softDelete(@Param('uuid') uuid: string): Promise<void> {
|
||||
await this.entryService.softDeleteByUuid(uuid);
|
||||
}
|
||||
|
||||
@Put('/restore/:uuid')
|
||||
@UseGuards(AuthenticationGuard)
|
||||
async restoreSoftDeleted(@Param('uuid') uuid: string): Promise<Entry | null> {
|
||||
return await this.entryService.restoreDeletedByUuid(uuid);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user