diff --git a/__tests__/setup/.test-env b/__tests__/setup/.test-env index 091c589e10..3a7207e70b 100644 --- a/__tests__/setup/.test-env +++ b/__tests__/setup/.test-env @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 4e27e305fc..9354b58b45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/shared/types/user.ts b/shared/types/user.ts index caebeb55b7..8c08066e11 100644 --- a/shared/types/user.ts +++ b/shared/types/user.ts @@ -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 diff --git a/src/app/config/config.ts b/src/app/config/config.ts index 69a2a648dd..d4756cda1f 100644 --- a/src/app/config/config.ts +++ b/src/app/config/config.ts @@ -12,6 +12,7 @@ import { DbConfig, Environment, MailConfig, + PublicApiConfig, } from '../../types' import { @@ -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, @@ -236,6 +243,7 @@ const config: Config = { configureAws, secretEnv: basicVars.core.secretEnv, envSiteName: basicVars.core.envSiteName, + publicApiConfig, } export = config diff --git a/src/app/config/schema.ts b/src/app/config/schema.ts index 2c8ee6a8d2..e08a3f10cb 100644 --- a/src/app/config/schema.ts +++ b/src/app/config/schema.ts @@ -338,6 +338,12 @@ export const optionalVarsSchema: Schema = { 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: { @@ -348,6 +354,14 @@ export const optionalVarsSchema: Schema = { 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 = { diff --git a/src/app/models/user.server.model.ts b/src/app/models/user.server.model.ts index bdeb8f89f9..2a1c1b86e4 100644 --- a/src/app/models/user.server.model.ts +++ b/src/app/models/user.server.model.ts @@ -76,6 +76,13 @@ const compileUserModel = (db: Mongoose) => { flags: { lastSeenFeatureUpdateVersion: Number, }, + apiToken: { + keyHash: { + type: String, + }, + createdAt: Date, + lastUsedAt: Date, + }, }, { timestamps: { diff --git a/src/app/modules/auth/auth.errors.ts b/src/app/modules/auth/auth.errors.ts index 0a93cb2973..30945a54a0 100644 --- a/src/app/modules/auth/auth.errors.ts +++ b/src/app/modules/auth/auth.errors.ts @@ -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) + } +} diff --git a/src/app/modules/auth/auth.middlewares.ts b/src/app/modules/auth/auth.middlewares.ts index c8d6900d84..3a0300faba 100644 --- a/src/app/modules/auth/auth.middlewares.ts +++ b/src/app/modules/auth/auth.middlewares.ts @@ -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) @@ -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 (?\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+)_(?[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 }) + }) +} diff --git a/src/app/modules/auth/auth.service.ts b/src/app/modules/auth/auth.service.ts index 79c9205175..1c9175aada 100644 --- a/src/app/modules/auth/auth.service.ts +++ b/src/app/modules/auth/auth.service.ts @@ -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' @@ -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) @@ -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 => { + 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 => { + 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}` +} diff --git a/src/app/modules/auth/auth.utils.ts b/src/app/modules/auth/auth.utils.ts index be84e751a9..6a0c66739b 100644 --- a/src/app/modules/auth/auth.utils.ts +++ b/src/app/modules/auth/auth.utils.ts @@ -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 => { diff --git a/src/app/modules/auth/constants.ts b/src/app/modules/auth/constants.ts new file mode 100644 index 0000000000..37956c1afb --- /dev/null +++ b/src/app/modules/auth/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_SALT_ROUNDS = 2 diff --git a/src/app/routes/api/api.routes.ts b/src/app/routes/api/api.routes.ts index 7fb4942a83..a180489626 100644 --- a/src/app/routes/api/api.routes.ts +++ b/src/app/routes/api/api.routes.ts @@ -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()) diff --git a/src/app/routes/api/public/v1/admin/admin.routes.ts b/src/app/routes/api/public/v1/admin/admin.routes.ts new file mode 100644 index 0000000000..310f3e30a3 --- /dev/null +++ b/src/app/routes/api/public/v1/admin/admin.routes.ts @@ -0,0 +1,7 @@ +import { Router } from 'express' + +import { AdminFormsPublicRouter } from './forms' + +export const AdminRouter = Router() + +AdminRouter.use('/forms', AdminFormsPublicRouter) diff --git a/src/app/routes/api/public/v1/admin/forms/admin-forms.public.routes.ts b/src/app/routes/api/public/v1/admin/forms/admin-forms.public.routes.ts new file mode 100644 index 0000000000..43e4cb4a15 --- /dev/null +++ b/src/app/routes/api/public/v1/admin/forms/admin-forms.public.routes.ts @@ -0,0 +1,64 @@ +import { Router } from 'express' + +import { rateLimitConfig } from '../../../../../../config/config' +import { authenticateApiKey } from '../../../../../../modules/auth/auth.middlewares' +import * as AdminFormController from '../../../../../../modules/form/admin-form/admin-form.controller' +import * as EncryptSubmissionController from '../../../../../../modules/submission/encrypt-submission/encrypt-submission.controller' +import { limitRate } from '../../../../../../utils/limit-rate' + +export const AdminFormsPublicRouter = Router() + +// All routes in this handler should be protected by authentication. +AdminFormsPublicRouter.use(authenticateApiKey) + +AdminFormsPublicRouter.route('/') + /** + * List the forms managed by the user + * @security bearer + * + * @returns 200 with a list of forms managed by the user + * @returns 401 when user is not authorised + * @returns 422 when user of given id cannnot be found in the database + * @returns 500 when database errors occur + */ + .get( + limitRate({ max: rateLimitConfig.publicApi }), + AdminFormController.handleListDashboardForms, + ) + +/** + * Count the number of submissions for a form + * @route GET /:formId/submissions/count + * @security bearer + * + * @returns 200 with submission counts of given form + * @returns 400 when query.startDate or query.endDate is malformed + * @returns 401 when user does not exist in session + * @returns 403 when user does not have permissions to access form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsPublicRouter.route('/:formId([a-fA-F0-9]{24})/submissions/count').get( + AdminFormController.handleCountFormSubmissions, +) + +/** + * Stream download all encrypted responses for a form + * @route GET /:formId/submissions/download + * @security bearer + * + * @returns 200 with stream of encrypted responses + * @returns 400 if form is not an encrypt mode form + * @returns 400 when Joi validation fails + * @returns 401 when user does not exist in session + * @returns 403 when user does not have read permissions for form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 if any errors occurs in stream pipeline or error retrieving form + */ +AdminFormsPublicRouter.route( + '/:formId([a-fA-F0-9]{24})/submissions/download', +).get(EncryptSubmissionController.handleStreamEncryptedResponses) diff --git a/src/app/routes/api/public/v1/admin/forms/index.ts b/src/app/routes/api/public/v1/admin/forms/index.ts new file mode 100644 index 0000000000..922ad5a77c --- /dev/null +++ b/src/app/routes/api/public/v1/admin/forms/index.ts @@ -0,0 +1 @@ +export { AdminFormsPublicRouter } from './admin-forms.public.routes' diff --git a/src/app/routes/api/public/v1/admin/index.ts b/src/app/routes/api/public/v1/admin/index.ts new file mode 100644 index 0000000000..18de387811 --- /dev/null +++ b/src/app/routes/api/public/v1/admin/index.ts @@ -0,0 +1 @@ +export { AdminRouter } from './admin.routes' diff --git a/src/app/routes/api/public/v1/index.ts b/src/app/routes/api/public/v1/index.ts new file mode 100644 index 0000000000..0d8ce49487 --- /dev/null +++ b/src/app/routes/api/public/v1/index.ts @@ -0,0 +1 @@ +export { V1PublicRouter } from './v1.routes' diff --git a/src/app/routes/api/public/v1/v1.routes.ts b/src/app/routes/api/public/v1/v1.routes.ts new file mode 100644 index 0000000000..18ffc71621 --- /dev/null +++ b/src/app/routes/api/public/v1/v1.routes.ts @@ -0,0 +1,7 @@ +import { Router } from 'express' + +import { AdminRouter } from './admin' + +export const V1PublicRouter = Router() + +V1PublicRouter.use('/admin', AdminRouter) diff --git a/src/types/config.ts b/src/types/config.ts index c814f2e346..62df834f56 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -52,6 +52,12 @@ export type RateLimitConfig = { submissions: number sendAuthOtp: number downloadPaymentReceipt: number + publicApi: number +} + +export type PublicApiConfig = { + apiEnv: string + apiKeyVersion: string } export type ReactMigrationConfig = { @@ -92,6 +98,7 @@ export type Config = { reactMigration: ReactMigrationConfig secretEnv: string envSiteName: string + publicApiConfig: PublicApiConfig // Functions configureAws: () => Promise @@ -172,11 +179,15 @@ export interface IOptionalVarsSchema { submissions: number sendAuthOtp: number downloadPaymentReceipt: number + publicApi: number } reactMigration: { // TODO (#5826): Toggle to use fetch for submissions instead of axios. Remove once network error is resolved useFetchForSubmissions: boolean } + publicApi: { + apiKeyVersion: string + } } export interface IBucketUrlSchema {