Skip to content

Commit

Permalink
Merge pull request #263 from dxw/70-restrict-access-to-requests-with-…
Browse files Browse the repository at this point in the history
…api-key

(70) Restrict access to requests with API key
  • Loading branch information
edavey authored Dec 11, 2024
2 parents fad7456 + 810b4ca commit 93c3929
Show file tree
Hide file tree
Showing 13 changed files with 282 additions and 6 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
ROLLBAR_ACCESS_TOKEN=ROLLBAR_ACCESS_TOKEN
ROLLBAR_ENV=development

# used for generating API "digest" from keys
API_KEY_HMAC_SECRET_KEY=topsecret

DATABASE_URL=postgres://postgres@localhost:5432/dfsseta-apply-for-landing-development

# TODO: Replace `example.com` with the canonical hostname of the app
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ See
[ADR 0012: Standard terminology for the Apply for Landing service](./doc/architecture/decisions/0012-standard-terminology.md)
for details of how we refer to the entities within this domain.

## API

As well as the principal service, which is delivered using the [GOV.UK
Design System][], a JSON API is offered to authorised users, such as arrival
authorities on Landable Bodies. See the [OpenAPI documentation][]. Authentication
is by way of an API key, passed in a header, e.g.

```sh
curl "https://apply-for-landing-ruby-4492c2b72668.herokuapp.com/api/landing-applications" \
-H 'X-API-KEY: my-secret-api-key'
```

## Local development

For bundling JS and CSS you will need:
Expand Down Expand Up @@ -149,10 +161,15 @@ The following environment variables must be set on Heroku;

- `HOSTNAME`: currently `apply-for-landing-ruby-4492c2b72668.herokuapp.com` (the
"Web URL" is shown with `heroku info`)
- `API_KEY_HMAC_SECRET_KEY`: the secret used for generating digests of API keys.
Generate with `SecureRandom.hex(32)` for production use.

[Seed Fu gem]: https://github.com/mbleigh/seed-fu
[`dxw/dfsseta-apply-for-landing-e2e`]:
https://github.com/dxw/dfsseta-apply-for-landing-e2e
[GitHub Action]:
https://github.com/dxw/dfsseta-apply-for-landing-ruby/blob/main/.github/workflows/heroku-deployment.yml
[deployed to Heroku]: https://apply-for-landing-ruby-4492c2b72668.herokuapp.com/
[GOV.UK Design System]: https://design-system.service.gov.uk
[OpenAPI documentation]:
https://apply-for-landing-ruby-4492c2b72668.herokuapp.com/api-docs/
12 changes: 12 additions & 0 deletions app/controllers/api/landing_applications_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
class Api::LandingApplicationsController < ApplicationController
before_action :authenticate_api_key

def index
render json: LandingApplication.includes(:destination).map { |la|
LandingApplicationEntity.new(la).represent
}
end

private

def authenticate_api_key
head :unauthorized unless ApiClientAuthenticator.authenticate?(api_key)
end

def api_key
request.headers["HTTP_X_API_KEY"]
end
end
13 changes: 13 additions & 0 deletions app/lib/api_client_authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class ApiClientAuthenticator
def self.authenticate?(api_key)
return false if api_key.blank?

begin
ApiKey.find_by_token!(api_key)
rescue ActiveRecord::RecordNotFound
return false
end

true
end
end
36 changes: 36 additions & 0 deletions app/models/api_key.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class ApiKey < ApplicationRecord
before_create :generate_unhashed_token
before_create :generate_token_digest

validates :api_client_name, presence: true

# Attribute for storing and accessing the unhashed
# token value directly after creation -- ONLY
attr_accessor :unhashed_token

def self.find_by_token!(token)
find_by!(token_digest: generate_digest(token))
end

def self.find_by_token(token)
find_by(token_digest: generate_digest(token))
end

def self.generate_digest(token)
OpenSSL::HMAC.hexdigest(
"SHA256",
ENV.fetch("API_KEY_HMAC_SECRET_KEY"),
token
)
end

private

def generate_unhashed_token
self.unhashed_token = SecureRandom.base58(30)
end

def generate_token_digest
self.token_digest = self.class.generate_digest(unhashed_token)
end
end
11 changes: 11 additions & 0 deletions db/migrate/20240916110956_create_api_keys.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class CreateApiKeys < ActiveRecord::Migration[7.2]
def change
create_table :api_keys, id: :uuid do |t|
t.string :api_client_name, null: false
t.string :token_digest, null: false

t.timestamps
end
add_index :api_keys, :token_digest, unique: true
end
end
10 changes: 9 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2024_09_02_130630) do
ActiveRecord::Schema[7.2].define(version: 2024_09_16_110956) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"

create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "api_client_name", null: false
t.string "token_digest", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["token_digest"], name: "index_api_keys_on_token_digest", unique: true
end

create_table "landable_bodies", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.text "name", null: false
t.boolean "active", default: true, null: false
Expand Down
14 changes: 14 additions & 0 deletions spec/api/landing_applications_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
let(:decision_timestamp) { Time.current + 1.week }
let(:landing_date) { Date.today + 1.month }
let(:departure_date) { Date.today + 1.month + 1.week }
let(:valid_api_token) { ApiKey.create(api_client_name: "Test").unhashed_token }

around do |example|
ClimateControl.modify API_KEY_HMAC_SECRET_KEY: "secret-key" do
example.run
end
end

before do
FactoryBot.create(:landing_application, {
Expand Down Expand Up @@ -45,6 +52,7 @@
path "/api/landing-applications" do
get "Retrieves a list of landing applications" do
tags "Landing applications"
security [api_key: []]
produces "application/json"

response "200", "success" do
Expand All @@ -62,13 +70,19 @@
example.metadata[:response][:content] = content.deep_merge(example_spec)
end

let(:"X-API-KEY") { valid_api_token }
run_test! do |response|
data = JSON.parse(response.body)
expected_data = JSON.parse(expected_json_representation)

expect(data).to eq(expected_data)
end
end

response "401", "invalid credentials" do
let(:"X-API-KEY") { "rubbish" }
run_test!
end
end
end
end
Expand Down
17 changes: 16 additions & 1 deletion spec/controllers/api/landing_applications_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
require "rails_helper"

RSpec.describe Api::LandingApplicationsController do
describe "GET :index" do
describe "authentication with API Key" do
it "asks the ApiClientAuthenticator to verify the API Key provided in a header" do
allow(ApiClientAuthenticator).to receive(:authenticate?)

request.env["HTTP_X_API_KEY"] = "abc123"
get :index

expect(ApiClientAuthenticator).to have_received(:authenticate?).with("abc123")
end
end

describe "GET :index (with a valid API Key)" do
before do
allow(ApiClientAuthenticator).to receive(:authenticate?).and_return(true)
end

it "asks LandingApplicationEntity to represent each application" do
app_1 = double("app1")
app_2 = double("app2")
Expand Down
40 changes: 40 additions & 0 deletions spec/lib/api_client_authenticator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
RSpec.describe ApiClientAuthenticator do
describe "::authenticate?(api_key)" do
let(:token) { "321cba" }

it "uses ApiKey::find_by_token!(token) to look up the given token" do
allow(ApiKey).to receive(:find_by_token!)

ApiClientAuthenticator.authenticate?(token)

expect(ApiKey).to have_received(:find_by_token!).with("321cba")
end

context "when the token is blank" do
it "returns _false_" do
aggregate_failures do
expect(ApiClientAuthenticator.authenticate?(nil)).to be false
expect(ApiClientAuthenticator.authenticate?("")).to be false
end
end
end

context "when the key exists" do
before { allow(ApiKey).to receive(:find_by_token!).and_return(double) }

it "returns _true_ " do
expect(ApiClientAuthenticator.authenticate?(token)).to be true
end
end

context "when the key does NOT exist" do
before do
allow(ApiKey).to receive(:find_by_token!).and_raise(ActiveRecord::RecordNotFound)
end

it "returns _false_ " do
expect(ApiClientAuthenticator.authenticate?(token)).to be false
end
end
end
end
83 changes: 83 additions & 0 deletions spec/models/api_key_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
RSpec.describe ApiKey do
let(:token) { "unhashed-token" }
let(:secret_key) { "secret-key" }
let(:hashed_token) { "hashed-token" }

around do |example|
ClimateControl.modify API_KEY_HMAC_SECRET_KEY: "secret-key" do
example.run
end
end

before do
allow(SecureRandom).to receive(:base58).and_return(token)
allow(OpenSSL::HMAC).to receive(:hexdigest).and_return(hashed_token)
end

def expects_token_digest_to_have_been_generated
expect(OpenSSL::HMAC).to have_received(:hexdigest).with(
"SHA256",
secret_key,
token
)
end

describe "class methods" do
describe "when creating the API key" do
it "generates a Base58 string of 30 chars as the token" do
ApiKey.new(api_client_name: "Client name").save

expect(SecureRandom).to have_received(:base58).with(30)
end

it "uses OpenSSL::HMAC.hexdigest to generate a hash of the token" do
ApiKey.new(api_client_name: "Client name").save

expects_token_digest_to_have_been_generated
end
end

describe "when retrieving the API key" do
describe "::find_by_token(token)" do
it "generates the digest of the given token" do
ApiKey.find_by_token(token)

expects_token_digest_to_have_been_generated
end
end

describe "::find_by_token!(token)" do
it "generates the digest of the given token" do
ApiKey.find_by_token!(token)
rescue ActiveRecord::RecordNotFound
# what happened before ActiveRecord::RecordNotFound
expects_token_digest_to_have_been_generated
end
end
end

describe "::generate_digest(token)" do
it "uses the secret key to generate a SHA156 digest of the given token" do
ApiKey.generate_digest(token)

expects_token_digest_to_have_been_generated
end
end
end

describe "instance_methods" do
describe "#unhashed_token" do
it "returns the unhashed token immediately after creation" do
key = ApiKey.create(api_client_name: "Client name")
expect(key.unhashed_token).to eq(token)
end

it "does NOT return unhashed on subsequent retrieval" do
ApiKey.create(api_client_name: "Client name")
key = ApiKey.find_by!(api_client_name: "Client name")

expect(key.unhashed_token).to be_nil
end
end
end
end
12 changes: 12 additions & 0 deletions spec/swagger_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
version: "v1"
},
paths: {},
components: {
securitySchemes: {
api_key: {
type: :apiKey,
name: "X-API-KEY",
in: :header
}
}
},
security: [
api_key: []
],
servers: [
{
url: "https://{defaultHost}",
Expand Down
20 changes: 16 additions & 4 deletions swagger/v1/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ paths:
summary: Retrieves a list of landing applications
tags:
- Landing applications
security:
- api_key: []
responses:
"200":
description: success
Expand All @@ -18,16 +20,26 @@ paths:
test_example:
value:
- application_id: f5f81284-e377-4017-aab1-1efac2119a2c
destination: Planet X
pilot:
name: Fred Smith
email: [email protected]
licence_id: 1233ABC00123
permit_issued_at: "2024-09-18T07:53:21Z"
permit_issued_at: "2024-09-24T10:28:31Z"
permit_id: LP-3522-HNWD
destination: Planet X
landing_date: "2024-10-17"
departure_date: "2024-10-24"
spacecraft_registration_id: ABC123A
landing_date: "2024-10-11"
departure_date: "2024-10-18"
"401":
description: invalid credentials
components:
securitySchemes:
api_key:
type: apiKey
name: X-API-KEY
in: header
security:
- api_key: []
servers:
- url: https://{defaultHost}
variables:
Expand Down

0 comments on commit 93c3929

Please sign in to comment.