Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bearer token authentication for public APIs #6376

Merged
merged 43 commits into from
Jun 6, 2023
Merged

Conversation

wanlingt
Copy link
Contributor

@wanlingt wanlingt commented May 23, 2023

Problem

Closes FRM-906

Solution

This PR allows admins with a valid API token to access public APIs. API tokens are in the format env_version_userId_token.

Breaking Changes

  • No - this PR is backwards compatible

Features:

  • Created a new base route for public APIs (/api/public/v1)
    Public APIs will not share the same namespace as internal session-based APIs (/api/v3) as the external APIs will have different functions (e.g. updating multiple form fields at one go)
  • The hash of the API token is stored in the user collection as ApiKeyHash
  • Add public API endpoint for listing forms (GET /api/public/v1/admin/forms)
  • Added new error types for public APIs

Tests

Test API

Test 1: API works

  • a. Generate a random string using crypto.randomBytes(32).toString("base64")
  • b. find your user id, and format an API key as test_v1_${userId}_${randomString}
  • c. Store a hash of that key (using 1 salt round) in the user collection, adding it using this structure:
  "apiToken" :{
      "keyHash": "${hashedApiKey}",
      "createdAt": ISODate("2023-06-01T00:00:00.000Z"),
      "lastUsedAtAt": ISODate("2023-06-01T00:00:00.000Z")
      }

(instructions on how to generate the api key and hash are also here)

  • d. Send an API request as such:
curl --request GET \
  --url http://form.gov.sg/api/public/v1/admin/forms \
  --header 'Authorization: Bearer ${apiKey}' \

You should receive a 200 OK response, with the full list of forms associated with your user.

Test 2: Without authorisation header

  • Repeat the same steps as above.
  • In Step 2c, send a curl request without an authorisation header
curl --request GET \
  --url http://form.gov.sg/api/public/v1/admin/forms \
  • You should receive a 400 Bad Request response, with the message "Authorisation header is missing"

Test 3: With wrong credentials

  • Repeat the same steps as above.
  • In Step 2c, send a curl request with a different API token
curl --request GET \
  --url http://form.gov.sg/api/public/v1/admin/forms \
  --header 'Authorization: Bearer test_v1_${userId}_${WRONG_API_TOKEN}' \
  • You should receive a 401 Unauthorized response

Regression Test

Test 4: Regression test for handleListDashboardForms

Deploy Notes

New environment variables:

  • API_KEY_VERSION: current API version
  • PUBLIC_API_RATE_LIMIT: Per-minute, per-IP, per-instance request limit for public APIs

@wanlingt wanlingt marked this pull request as ready for review May 24, 2023 07:53
@wanlingt wanlingt requested a review from timotheeg May 24, 2023 07:53
@wanlingt wanlingt requested a review from LinHuiqing May 24, 2023 08:09
export const BEARER_STRING = 'Bearer'
export const BEARER_SEPARATOR = ' '
export const API_KEY_SEPARATOR = '_'
export const DEFAULT_SALT_ROUNDS = 1
Copy link
Contributor

@LinHuiqing LinHuiqing May 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be exposed in the code or should we load this in as an env var instead? From the point of view of exposing our protocol, I think loading env vars is slightly more secure. It'll also be easier for us to tweak if we want to change it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! Also I think we should set more rounds since this is for the API key which is permanent, rather than something ephemeral like our OTP!

Copy link
Contributor

@timotheeg timotheeg Jun 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be exposed in the code or should we load this in as an env var instead? From the point of view of exposing our protocol, I think loading env vars is slightly more secure. It'll also be easier for us to tweak if we want to change it.

I'm not too worried about this. The strength of the encryption is more important than hiding the number of rounds (i.e. security through obscurity on the algo). Also With bcrypt, the number of rounds is encoded in the hash, so it is visible information to anyone who gets their hand on the hash (though obviously, we should do everything we can to protect against that!).

I also think that changing the number of rounds would be a very infrequent operation, so there's no need to have it a tunable.

I think we should set more rounds since this is for the API key which is permanent, rather than something ephemeral like our OTP!

Agreed. I increased the value to 2, which performs 4 rounds. We could do more, but we need to be mindful of processing cost. In the current POC, we compute the hash on every API call (as opposed to one time on login for session-based access).

At POC staged, I'm not overly worried about this.

src/app/modules/auth/auth.service.ts Show resolved Hide resolved
@kenjin-work kenjin-work self-requested a review May 29, 2023 06:13
@KenLSM KenLSM self-requested a review May 29, 2023 06:48
@kenjin-work
Copy link
Contributor

Random thought: Is implementing expiring bearer tokens (e.g. after a year) a common practice? Or is that not required here?

@timotheeg
Copy link
Contributor

timotheeg commented May 31, 2023

Random thought: Is implementing expiring bearer tokens (e.g. after a year) a common practice? Or is that not required here?

Good comment @kenjin-work !

It's common enough, and it indeed removes risk exposure for keys, especially which are not actively in use. But it's not a must-have practice, and many small SaaS do not have expiry on their token (e.g. BetterUptime or Snyk to name just 2 of our vendors). Removing keys can be disruptive to active automations, and so it typically requires some additional mechanisms to inform users (likely with multiple reminders!) that their key will expire in, say, 60 days, 30 days, 10 days, 2 days, 1 day, BOOM 😅

This API initiative is on POC/MVP mode right now, With the primary goal of showing what the authentication model and API namespace can be. We are not even considering building the UI to generate/revoke/regenerate the API tokens yet 😅 .

So we (cc @wanlingt as feature owner) can add requirements as we go to make the feature as robust as can. For example, just like we track lastLoginTime for UI sessions, so too we should track lastUsageTime for the token, and indeed we should add a tokenCreationTime too, so we are ready to add an expiration mechanism later on if we want.

.status(StatusCodes.UNAUTHORIZED)
.json({ message: 'Invalid API key' })
}
req.session.user = { _id: user.id }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note on this: this works because the session middleware is currently running for all routes served by express, and that is sort of wrong.

We should activate the session middleware only for routes which require it (typically under the API router), and for the new "external" APIs, our auth middleware should take responsibility to setup a compatible session object. Something as small as:

Suggested change
req.session.user = { _id: user.id }
if (!req.session) req.session = {}
req.session.user = { _id: user.id }

@timotheeg
Copy link
Contributor

I added some minor changes, but I realized one important thing must be changed: retrieving forms atm returns the entire user object 😱. We need a filter process to make sure any non-intended fields is not returned to the client. The comment applies for both the session base API, and bearer token auth.

@timotheeg
Copy link
Contributor

timotheeg commented Jun 6, 2023

I added the API endpoint to retrieve submission count and submissions themselves (from our ndjson stream controller), and here is a gist that shows how it can be used to load submissions from the server (all const values formids, privateKey, apiKey are from my local env, so obviously they need to be changed accordingly to run yourselve.

One more note: I'm not sure we need to place the public API endpoints within a namespace /admin 🤔 , considering we have bearer token auth, public APIs should necessarily be for admins.

@timotheeg
Copy link
Contributor

timotheeg commented Jun 6, 2023

Also to note / possibly something for discussion.

The apiToken is now a structure that includes createdAt and lastUsedAt fields, and that will be useful for some instrumentation of the feature.

We might need an additional date field regeneratedAt, so if a key is revoked to create a new one, we don't lose stats of when admins first started to use an API key 🤔

Copy link
Contributor

@timotheeg timotheeg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Good enough for demo even in staging / prod.

Important note though: before we start adding keys into users, we MUST change the query that retrieve forms, so the api key hash information NEVER leaves the DB or server.

ATM, the auto fetch of user objects for form->admin and form->collaborators return the api token structure as a user property. We need to return the bare minimum fields for users.

@timotheeg timotheeg merged commit 50d8d1c into develop Jun 6, 2023
@timotheeg timotheeg deleted the feat/admin-api branch June 6, 2023 08:02
KenLSM pushed a commit that referenced this pull request Jun 8, 2023
* 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]>
@LinHuiqing LinHuiqing mentioned this pull request Jun 12, 2023
51 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants