Skip to content

Commit

Permalink
Add handler examples (#16)
Browse files Browse the repository at this point in the history
  • Loading branch information
julik authored Jul 25, 2024
1 parent 61aa64e commit 19b49f7
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 0 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ Mount munster engine in your routes.
mount Munster::Engine, at: "/webhooks"
```

Define a class for your first handler (let's call it `ExampleHandler`) and inherit it from `Munster::BaseHandler`. Place it somewhere where Rails autoloading can find it, and add it to your `munster.rb` config file:

```ruby
config.active_handlers = {
"example" => "ExampleHandler"
}
```

## Example handlers

We provide a number of webhook handlers which demonstrate certain features of Munster. You will find them in `handler-examples`.

## Requirements

This project depends on two dependencies:
Expand Down
36 changes: 36 additions & 0 deletions handler-examples/customer_io_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This is an example handler for Customer.io reporting webhooks. You
# can find more documentation here https://customer.io/docs/api/webhooks/#operation/reportingWebhook
class Webhooks::CustomerIoHandler < Munster::BaseHandler
def process(webhook)
json = JSON.parse(webhook.body, symbolize_names: true)
case json[:metric]
when "subscribed"
# ...
when "unsubscribed"
# ...
when "cio_subscription_preferences_changed"
# ...
end
end

def extract_event_id_from_request(action_dispatch_request)
action_dispatch_request.params.fetch(:event_id)
end

# Verify that request is actually comming from customer.io here
# @see https://customer.io/docs/api/webhooks/#section/Securely-Verifying-Requests
#
# - Should have "X-CIO-Signature", "X-CIO-Timestamp" headers.
# - Combine the version number, timestamp and body delimited by colons to form a string in the form v0:<timestamp>:<body>
# - Using HMAC-SHA256, hash the string using your webhook signing secret as the hash key.
# - Compare this value to the value of the X-CIO-Signature header sent with the request to confirm
def valid?(action_dispatch_request)
signing_key = Rails.application.secrets.customer_io_webhook_signing_key
xcio_signature = action_dispatch_request.headers["HTTP_X_CIO_SIGNATURE"]
xcio_timestamp = action_dispatch_request.headers["HTTP_X_CIO_TIMESTAMP"]
request_body = action_dispatch_request.body.read
string_to_sign = "v0:#{xcio_timestamp}:#{request_body}"
hmac = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
Rack::Utils.secure_compare(hmac, xcio_signature)
end
end
29 changes: 29 additions & 0 deletions handler-examples/revolut_business_v1_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# This is for Revolut V1 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-1-deprecated
class RevolutBusinessV1Handler < Munster::BaseHandler
def valid?(_)
# V1 of Revolut webhooks does not support signatures
true
end

def self.process(webhook)
parsed_payload = JSON.parse(webhook.body)
topic = parsed_payload.fetch("Topic")
case topic
when "tokens" # Account access revocation payload
# ...
when "draftpayments/transfers" # Draft payment transfer notification payload
# ...
else
# ...
end
end

def self.extract_event_id_from_request(action_dispatch_request)
# Since b-tree indices generally divide from the start of the string, place the highest
# entropy component at the start (the EventId)
key_components = %w[EventId Topic Version]
key_components.map do |key|
action_dispatch_request.params.fetch(key)
end.join("-")
end
end
42 changes: 42 additions & 0 deletions handler-examples/revolut_business_v2_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# This is for Revolut V2 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-2
class RevolutBusinessV2Handler < Munster::BaseHandler
def valid?(request)
# 1 - Validate the timestamp of the request. Prevent replay attacks.
# "To validate the event, make sure that the Revolut-Request-Timestamp date-time is within a 5-minute time tolerance of the current universal time (UTC)".
# Their examples list `timestamp = '1683650202360'` as a sample value, so their timestamp is in millis - not in seconds
timestamp_str_from_headers = request.headers["HTTP_REVOLUT_REQUEST_TIMESTAMP"]
delta_t_seconds = (timestamp_str_from_headers / 1000) - Time.now.to_i
return false unless delta_t_seconds.abs < (5 * 60)

# 2 - Validate the signature
# https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/verify-the-payload-signature
string_to_sign = [
"v1",
timestamp_str_from_headers,
request.body.read
].join(".")
computed_signature = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secrets.revolut_business_webhook_signing_key, string_to_sign)
# Note: "This means that in the period when multiple signing secrets remain valid, multiple signatures are sent."
# https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/manage-webhooks#rotate-a-webhook-signing-secret
# https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/about-webhooks#security
# An HTTP header may contain multiple values if it gets sent multiple times. But it does mean we need to test for multiple provided
# signatures in case of rotation.
provided_signatures = request.headers["HTTP_REVOLUT_SIGNATURE"].split(",")
# Use #select instead of `find` to compare all signatures even if only one matches - this to avoid timing leaks.
# Small effort but might be useful.
matches = provided_signatures.select do |provided_signature|
ActiveSupport::SecurityUtils.secure_compare(provided_signature, computed_signature)
end
matches.any?
end

def self.process(webhook)
Rails.logger.info { "Processing Revolut webhook #{webhook.body.inspect}" }
end

def self.extract_event_id_from_request(action_dispatch_request)
# The event ID is only available when you retrieve the failed webhooks, which is sad.
# We can divinate a synthetic ID though by taking a hash of the entire payload though.
Digest::SHA256.hexdigest(action_dispatch_request.body.read)
end
end
25 changes: 25 additions & 0 deletions handler-examples/starling_payments_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

# This handler is an example for Starling Payments API,
# you can find the documentation here https://developer.starlingbank.com/payments/docs#account-and-address-structure-1
class StarlingPaymentsHandler < Munster::BaseHandler
# This method will be used to process webhook by async worker.
def process(received_webhook)
Rails.logger.info { received_webhook.body }
end

# Starling supplies signatures in the form SHA512(secret + request_body)
def valid?(action_dispatch_request)
supplied_signature = action_dispatch_request.headers.fetch("X-Hook-Signature")
supplied_digest_bytes = Base64.strict_decode64(supplied_signature)
sha512 = Digest::SHA2.new(512)
signing_secret = Rails.credentials.starling_payments_webhook_signing_secret
computed_digest_bytes = sha512.digest(signing_secret.b + action_dispatch_request.body.b)
ActiveSupport::SecurityUtils.secure_compare(computed_digest_bytes, supplied_digest_bytes)
end

# Some Starling webhooks do not provide a notification UID, but for those which do we can deduplicate
def extract_event_id_from_request(action_dispatch_request)
action_dispatch_request.params.fetch("notificationUid", SecureRandom.uuid)
end
end

0 comments on commit 19b49f7

Please sign in to comment.