From 4723dffaedae7faa6087c686c26a8853a94bedb6 Mon Sep 17 00:00:00 2001 From: Tan Jay Jun Date: Mon, 12 Jun 2017 22:10:33 +0800 Subject: [PATCH] Add webhooks support --- README.md | 13 ++++ lib/stripe/webhook.ex | 115 +++++++++++++++++++++++++++++++++++ test/stripe/webhook_test.exs | 58 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 lib/stripe/webhook.ex create mode 100644 test/stripe/webhook_test.exs diff --git a/README.md b/README.md index 5951e1c8..779d5c1d 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,19 @@ Using the code request parameter, you make the following call: resp[:access_token] ``` +# Webhooks + +Stripe uses webhooks to notify your web app with events. `Stripe.Webhook` +provides `construct_event/3` to authenticate the request and convert the +payload to a `Stripe.Event` struct. + +```ex +payload = # HTTP content body (e.g. from Plug.Conn.read_body/3) +signature = # 'Stripe-Signature' HTTP header (e.g. from Plug.Conn.get_req_header/2) +secret = # Provided by Stripe +{:ok, %Stripe.Event{}} = Stripe.Webhook.construct_event(payload, signature, secret) +``` + ## Contributing Feedback, feature requests, and fixes are welcomed and encouraged. Please make diff --git a/lib/stripe/webhook.ex b/lib/stripe/webhook.ex new file mode 100644 index 00000000..f0b7baa1 --- /dev/null +++ b/lib/stripe/webhook.ex @@ -0,0 +1,115 @@ +defmodule Stripe.Webhook do + @moduledoc """ + Creates a Stripe Event from webhook's payload if signature is valid. + + Use `construct_event/3` to verify the authenticity of a webhook request and + convert its payload into a `Stripe.Event` struct. + + case Stripe.Webhook.construct_event(payload, signature, secret) do + {:ok, %Stripe.Event{} = event} -> + # Return 200 to Stripe and handle event + {:error, reason} -> + # Reject webhook by responding with non-2XX + end + """ + @default_tolerance 300 + @expected_scheme "v1" + + def construct_event(payload, signature_header, secret, tolerance \\ @default_tolerance) do + case verify_header(payload, signature_header, secret, tolerance) do + :ok -> + {:ok, convert_to_event!(payload)} + error -> + error + end + end + + defp verify_header(payload, signature_header, secret, tolerance) do + case get_timestamp_and_signatures(signature_header, @expected_scheme) do + {nil, _} -> + {:error, "Unable to extract timestamp and signatures from header"} + + {_, []} -> + {:error, "No signatures found with expected scheme #{@expected_scheme}"} + + {timestamp, signatures} -> + with {:ok, timestamp} <- check_timestamp(timestamp, tolerance), + {:ok, _signatures} <- check_signatures(signatures, timestamp, payload, secret) do + :ok + else + {:error, error} -> {:error, error} + end + end + end + + defp get_timestamp_and_signatures(signature_header, scheme) do + signature_header + |> String.split(",") + |> Enum.map(& String.split(&1, "=")) + |> Enum.reduce({nil, []}, fn + ["t", timestamp], {nil, signatures} -> + {to_integer(timestamp), signatures} + + [^scheme, signature], {timestamp, signatures} -> + {timestamp, [signature | signatures]} + + _, acc -> + acc + end) + end + + defp to_integer(timestamp) do + case Integer.parse(timestamp) do + {timestamp, _} -> + timestamp + :error -> + nil + end + end + + defp check_timestamp(timestamp, tolerance) do + now = System.system_time(:seconds) + if timestamp < (now - tolerance) do + {:error, "Timestamp outside the tolerance zone (#{now})"} + else + {:ok, timestamp} + end + end + + defp check_signatures(signatures, timestamp, payload, secret) do + signed_payload = "#{timestamp}.#{payload}" + expected_signature = compute_signature(signed_payload, secret) + if Enum.any?(signatures, & secure_equals?(&1, expected_signature)) do + {:ok, signatures} + else + {:error, "No signatures found matching the expected signature for payload"} + end + end + + defp compute_signature(payload, secret) do + :crypto.hmac(:sha256, secret, payload) + |> Base.encode16(case: :lower) + end + + defp secure_equals?(input, expected) when byte_size(input) == byte_size(expected) do + input = String.to_charlist(input) + expected = String.to_charlist(expected) + secure_compare(input, expected) + end + defp secure_equals?(_, _), do: false + + defp secure_compare(acc \\ 0, input, expected) + defp secure_compare(acc, [], []), do: acc == 0 + defp secure_compare(acc, [input_codepoint | input], [expected_codepoint | expected]) do + import Bitwise + acc + |> bor(input_codepoint ^^^ expected_codepoint) + |> secure_compare(input, expected) + end + + def convert_to_event!(payload) do + payload + |> Poison.decode!() + |> Stripe.Converter.convert_result() + end +end diff --git a/test/stripe/webhook_test.exs b/test/stripe/webhook_test.exs new file mode 100644 index 00000000..61289ee5 --- /dev/null +++ b/test/stripe/webhook_test.exs @@ -0,0 +1,58 @@ +defmodule Stripe.WebhookTest do + use ExUnit.Case + + import Stripe.Webhook + + @valid_payload ~S({"object": "event"}) + @invalid_payload "{}" + + @valid_scheme "v1" + @invalid_scheme "v0" + + @secret "secret" + + defp generate_signature(timestamp, payload, secret \\ @secret) do + :crypto.hmac(:sha256, secret, "#{timestamp}.#{payload}") + |> Base.encode16(case: :lower) + end + + defp create_signature_header(timestamp, scheme, signature) do + "t=#{timestamp},#{scheme}=#{signature}" + end + + test "payload with a valid signature should return event" do + timestamp = System.system_time(:seconds) + payload = @valid_payload + signature = generate_signature(timestamp, payload) + signature_header = create_signature_header(timestamp, @valid_scheme, signature) + + assert {:ok, %Stripe.Event{}} = construct_event(payload, signature_header, @secret) + end + + test "payload with an invalid signature should fail" do + timestamp = System.system_time(:seconds) + payload = @valid_payload + signature = generate_signature(timestamp, "random") + signature_header = create_signature_header(timestamp, @valid_scheme, signature) + + assert {:error, _message} = construct_event(payload, signature_header, @secret) + end + + test "payload with wrong secret should fail" do + timestamp = System.system_time(:seconds) + payload = @valid_payload + signature = generate_signature(timestamp, payload, "wrong") + signature_header = create_signature_header(timestamp, @valid_scheme, signature) + + assert {:error, _message} = construct_event(payload, signature_header, @secret) + end + + test "payload with missing signature scheme should fail" do + timestamp = System.system_time(:seconds) + payload = @valid_payload + signature = generate_signature(timestamp, payload) + signature_header = create_signature_header(timestamp, @invalid_scheme, signature) + + assert {:error, _message} = construct_event(payload, signature_header, @secret) + end +end