Skip to content

Commit

Permalink
feat: bearer token authentication for public APIs (#6376)
Browse files Browse the repository at this point in the history
* feat: add user middleware

* fix: use api key hash instead of api key

* feat: add apiKeySalt as a new config

* feat: add getApiKeyHash

* chore: add API_KEY_SALT to docker-compose.yml

* feat: add separate namespace for external apis

* feat: add handleListDashboardForms to external APIs

* docs: add comments to api auth functions

* fix: add more env vars to config

* feat: add rate limit env var

* tests: add fake API key salt env var

* fix: use salt rounds instead of salt to generate hash

* fix: remove more occurrences of apiKeySalt

* feat: findUserById instead of by hash

* feat: add user ID to req.body.formSg.userId when authenticating with API token

* ref: refine error states, move constants into separate file

* fix: remove API_KEY_SALT from docker-compose

* fix: split API Key in auth middleware

* fix: type ReqBody as unknown

* fix: update types for req body

* ref: remove unused comment

* fix: remove unique from apiKeyHash in user model

* fix: remove extra test env file

* ref: rename external to public

* ref: rename external api to public api

* fix: check length of api key

* docs: add comments

* ref: rename external to public

* fix: check length

* fix: fix typo

* feat: use req.session to store user id

* feat: Store API key in a structure

* feat: increase number of salting round used for API token hashing

* refactor: use local regexes to test auth header

* chore: add TODO to update lastUseAt time

* fix: retrieve user._id (not user.id)

* fix: read keyHash from correct location in user

* refactor: remove unecessary default value

* fix: add dots to match bcrypt\'s base64 alphabet

Documentation on base64 and bcrypt encoding
https://en.wikipedia.org/wiki/Base64#Applications_not_compatible_with_RFC_4648_Base64

* refactor: rename mapRouteExternalApiError into mapRoutePublicApiError

* feat: allow retrieving submissions by api

---------

Co-authored-by: Timothee Groleau <[email protected]>
  • Loading branch information
2 people authored and KenLSM committed Jun 8, 2023
1 parent a8b7ec0 commit 73781dd
Show file tree
Hide file tree
Showing 19 changed files with 315 additions and 2 deletions.
3 changes: 3 additions & 0 deletions __tests__/setup/.test-env
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@ CP_RP_JWKS_ENDPOINT=http://localhost:5000/api/v3/corppass/.well-known/jwks.json

# Payment env vars
SSM_ENV_SITE_NAME=test

# Public API env vars
API_KEY_VERSION=v1
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ services:
- CRON_PAYMENT_API_SECRET=secretKey
# env vars for go integration
- GOGOV_API_KEY
# Bearer token API key format
- API_KEY_VERSION=v1

mockpass:
build: https://github.com/opengovsg/mockpass.git#v3.1.3
Expand Down
7 changes: 7 additions & 0 deletions shared/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export const UserBase = z.object({
lastAccessed: z.date().optional(),
updatedAt: z.date(),
contact: z.string().optional(),
apiToken: z
.object({
keyHash: z.string(),
createdAt: z.date(),
lastUsedAt: z.date().optional(),
})
.optional(),
})
export type UserBase = z.infer<typeof UserBase>

Expand Down
8 changes: 8 additions & 0 deletions src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DbConfig,
Environment,
MailConfig,
PublicApiConfig,
} from '../../types'

import {
Expand Down Expand Up @@ -205,6 +206,12 @@ const configureAws = async () => {
}
}

const apiEnv = isDev ? 'test' : 'live'
const publicApiConfig: PublicApiConfig = {
apiEnv,
apiKeyVersion: basicVars.publicApi.apiKeyVersion,
}

const config: Config = {
app: basicVars.appConfig,
db: dbConfig,
Expand Down Expand Up @@ -236,6 +243,7 @@ const config: Config = {
configureAws,
secretEnv: basicVars.core.secretEnv,
envSiteName: basicVars.core.envSiteName,
publicApiConfig,
}

export = config
14 changes: 14 additions & 0 deletions src/app/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@ export const optionalVarsSchema: Schema<IOptionalVarsSchema> = {
default: 10,
env: 'DOWNLOAD_PAYMENT_RECEIPT_RATE_LIMIT',
},
publicApi: {
doc: 'Per-minute, per-IP, per-instance request limit for public APIs',
format: 'int',
default: 100,
env: 'PUBLIC_API_RATE_LIMIT',
},
},
reactMigration: {
useFetchForSubmissions: {
Expand All @@ -348,6 +354,14 @@ export const optionalVarsSchema: Schema<IOptionalVarsSchema> = {
env: 'REACT_MIGRATION_USE_FETCH_FOR_SUBMISSIONS',
},
},
publicApi: {
apiKeyVersion: {
doc: 'API key version',
format: String,
default: 'v1',
env: 'API_KEY_VERSION',
},
},
}

export const prodOnlyVarsSchema: Schema<IProdOnlyVarsSchema> = {
Expand Down
7 changes: 7 additions & 0 deletions src/app/models/user.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ const compileUserModel = (db: Mongoose) => {
flags: {
lastSeenFeatureUpdateVersion: Number,
},
apiToken: {
keyHash: {
type: String,
},
createdAt: Date,
lastUsedAt: Date,
},
},
{
timestamps: {
Expand Down
12 changes: 12 additions & 0 deletions src/app/modules/auth/auth.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,15 @@ export class InvalidOtpError extends ApplicationError {
super(message)
}
}

export class InvalidTokenError extends ApplicationError {
constructor(message = 'Invalid API Key') {
super(message)
}
}

export class MissingTokenError extends ApplicationError {
constructor(message = "User's API Key not found") {
super(message)
}
}
78 changes: 77 additions & 1 deletion src/app/modules/auth/auth.middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { createLoggerWithLabel } from '../../config/logger'
import { createReqMeta } from '../../utils/request'
import { ControllerHandler } from '../core/core.types'

import { isCronPaymentAuthValid, isUserInSession } from './auth.utils'
import { getUserByApiKey } from './auth.service'
import {
isCronPaymentAuthValid,
isUserInSession,
mapRoutePublicApiError,
} from './auth.utils'

const logger = createLoggerWithLabel(module)

Expand Down Expand Up @@ -106,3 +111,74 @@ export const withCronPaymentSecretAuthentication: ControllerHandler = (
.status(StatusCodes.UNAUTHORIZED)
.json({ message: 'Request is unauthorized.' })
}

type bearerTokenRegExpMatchArray =
| null
| (RegExpMatchArray & {
groups: {
token: string
}
})

type apiKeyRegExpMatchArray =
| null
| (RegExpMatchArray & {
groups: {
userId: string
}
})

/**
* Middleware that only allows users with a valid bearer token to pass through to the next handler
*/
export const authenticateApiKey: ControllerHandler = (req, res, next) => {
const authorizationHeader = req.headers.authorization
if (!authorizationHeader) {
return res
.status(StatusCodes.UNAUTHORIZED)
.json({ message: 'Authorisation header is missing' })
}
const bearerMatch = authorizationHeader.match(
/^Bearer (?<token>\S+)$/,
) as bearerTokenRegExpMatchArray
if (!bearerMatch) {
return res
.status(StatusCodes.BAD_REQUEST)
.json({ message: 'Invalid authorisation header format' })
}

// Note: testing the exact token format is not needed
// The minimum knowledge needed about the format is to extract the userId
// Other than that, invalid tokens will simply fail hash comparison
const apiKeyMatch = bearerMatch.groups.token.match(
/^(\w+)_(v\d+)_(?<userId>[0-9a-f]{24})_([a-z0-9/.+]+=*)$/i,
) as apiKeyRegExpMatchArray
if (!apiKeyMatch) {
return res
.status(StatusCodes.BAD_REQUEST)
.json({ message: 'Invalid API key format' })
}
logger.info({
message: 'User attempting to authenticate using API key',
meta: {
action: 'authenticateApiKey',
userId: apiKeyMatch.groups.userId,
token: bearerMatch.groups.token,
},
})
return getUserByApiKey(apiKeyMatch.groups.userId, bearerMatch.groups.token)
.map((user) => {
if (!user) {
return res
.status(StatusCodes.UNAUTHORIZED)
.json({ message: 'Invalid API key' })
}
req.session.user = { _id: user._id }
// TODO: update apiToken lastUsedAt in DB for the user
return next()
})
.mapErr((error) => {
const { errorMessage, statusCode } = mapRoutePublicApiError(error)
return res.status(statusCode).json({ message: errorMessage })
})
}
58 changes: 57 additions & 1 deletion src/app/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import bcrypt from 'bcrypt'
import crypto from 'crypto'
import mongoose from 'mongoose'
import { errAsync, okAsync, Result, ResultAsync } from 'neverthrow'
import validator from 'validator'
Expand Down Expand Up @@ -28,8 +30,15 @@ import {
PrivateFormError,
} from '../form/form.errors'
import * as FormService from '../form/form.service'
import { findUserById } from '../user/user.service'

import { InvalidDomainError, InvalidOtpError } from './auth.errors'
import {
InvalidDomainError,
InvalidOtpError,
InvalidTokenError,
MissingTokenError,
} from './auth.errors'
import { DEFAULT_SALT_ROUNDS } from './constants'

const logger = createLoggerWithLabel(module)
const TokenModel = getTokenModel(mongoose)
Expand Down Expand Up @@ -339,3 +348,50 @@ export const getFormIfPublic = (
FormService.isFormPublic(form).map(() => form),
)
}

/**
* Retrieves the user of the given API key
*
* @returns ok(IUserSchema) if the API key matches the hashed API key in the DB
* @returns err(DatabaseError) if database errors occurs whilst retrieving user
* @returns err(MissingUserError) if user does not exist in the database
*/
export const getUserByApiKey = (
userId: string,
token: string,
): ResultAsync<IUserSchema, Error> => {
return findUserById(userId).andThen((user) => {
if (!user.apiToken?.keyHash) {
return errAsync(new MissingTokenError())
}
return compareHash(token, user.apiToken.keyHash).andThen((isHashMatch) => {
if (isHashMatch) return okAsync(user)
return errAsync(new InvalidTokenError())
})
})
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const getApiKeyHash = (apiKey: string): ResultAsync<string, HashingError> => {
return ResultAsync.fromPromise(
bcrypt.hash(apiKey, DEFAULT_SALT_ROUNDS),
(error) => {
logger.error({
message: 'bcrypt hash error',
meta: {
action: 'getApiKeyHash',
},
error,
})
return new HashingError()
},
).map((hash) => `${hash}`)
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const generateApiKey = (user: IUserSchema): string => {
const key = crypto.randomBytes(32).toString('base64')
const apiEnv = config.publicApiConfig.apiEnv
const apiKeyVersion = config.publicApiConfig.apiKeyVersion
return `${apiEnv}_${apiKeyVersion}_${user._id}_${key}`
}
33 changes: 33 additions & 0 deletions src/app/modules/auth/auth.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,39 @@ export const mapRouteError: MapRouteError = (error, coreErrorMessage) => {
}
}

/**
* Handler to map ApplicationErrors to their correct status code and error
* messages.
* @param error The error to retrieve the status codes and error messages
* @param coreErrorMessage Any error message to return instead of the default core error message, if any
*/
export const mapRoutePublicApiError: MapRouteError = (error) => {
switch (error.constructor) {
case AuthErrors.InvalidTokenError:
return {
statusCode: StatusCodes.UNAUTHORIZED,
errorMessage: error.message,
}
case AuthErrors.MissingTokenError:
return {
statusCode: StatusCodes.UNAUTHORIZED,
errorMessage: error.message,
}
default:
logger.error({
message: 'Unknown route error observed',
meta: {
action: 'mapRouteError',
},
error,
})
return {
statusCode: StatusCodes.UNAUTHORIZED,
errorMessage: 'Something went wrong. Please try again.',
}
}
}

export const isUserInSession = (
session?: SessionData,
): session is AuthedSessionData => {
Expand Down
1 change: 1 addition & 0 deletions src/app/modules/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEFAULT_SALT_ROUNDS = 2
2 changes: 2 additions & 0 deletions src/app/routes/api/api.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { Router } from 'express'

import { errorHandlerMiddlewares } from '../../loaders/express/error-handler'

import { V1PublicRouter } from './public/v1'
import { V3Router } from './v3'

export const ApiRouter = Router()

ApiRouter.use('/v3', V3Router)
ApiRouter.use('/public/v1', V1PublicRouter)
ApiRouter.use(errorHandlerMiddlewares())
7 changes: 7 additions & 0 deletions src/app/routes/api/public/v1/admin/admin.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Router } from 'express'

import { AdminFormsPublicRouter } from './forms'

export const AdminRouter = Router()

AdminRouter.use('/forms', AdminFormsPublicRouter)
Loading

0 comments on commit 73781dd

Please sign in to comment.