Skip to content

Commit

Permalink
Add webhooks support
Browse files Browse the repository at this point in the history
  • Loading branch information
jayjun committed Jun 12, 2017
1 parent 981ff45 commit f4fd456
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 0 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions lib/stripe/util.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,35 @@ defmodule Stripe.Util do

Module.concat("Stripe", module_name)
end

@doc """
Compare two strings in constant-time, useful against timing attacks.
Returns as soon as possible if string lengths don't match.
iex> Stripe.Util.secure_equals?("abcdef", "abcdef")
true
iex> Stripe.Util.secure_equals?("abcdef", "123456")
false
iex> Stripe.Util.secure_equals?("abcdef", "abc")
false
"""
@spec secure_equals?(String.t, String.t) :: boolean
def secure_equals?(input, expected) when byte_size(input) == byte_size(expected) do
input = String.to_charlist(input)
expected = String.to_charlist(expected)
do_secure_equals?(input, expected)
end
def secure_equals?(_, _), do: false

defp do_secure_equals?(acc \\ 0, input, expected)
defp do_secure_equals?(acc, [], []), do: acc == 0
defp do_secure_equals?(acc, [input_char | input], [expected_char | expected]) do
import Bitwise
acc
|> bor(input_char ^^^ expected_char)
|> do_secure_equals?(input, expected)
end
end
112 changes: 112 additions & 0 deletions lib/stripe/webhook.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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
"""
alias Stripe.Util

@type webhook_error :: :invalid_payload | :invalid_signature | :missing_signature | :missing_timestamp | :expired_timestamp | :invalid_timestamp

@default_tolerance 300
@expected_scheme "v1"

@spec construct_event(String.t, String.t, String.t, integer) :: {:ok, Stripe.Event.t} | {:error, webhook_error}
def construct_event(payload, signature_header, secret, tolerance \\ @default_tolerance) do
case verify_signature(payload, signature_header, secret, tolerance) do
:ok ->
case convert_to_event(payload) do
%Stripe.Event{} = event ->
{:ok, event}
_ ->
{:error, :invalid_payload}
end

error ->
error
end
end

defp verify_signature(payload, signature_header, secret, tolerance) do
with {:ok, timestamp} <- get_timestamp(signature_header, tolerance),
{:ok, signatures} <- get_signatures(signature_header) do
signed_payload = "#{timestamp}.#{payload}"
expected_signature = compute_signature(signed_payload, secret)
if Enum.any?(signatures, & Util.secure_equals?(&1, expected_signature)) do
:ok
else
{:error, :invalid_signature}
end
end
end

defp get_timestamp(signature_header, tolerance) do
timestamp =
signature_header
|> String.split(",")
|> Enum.find_value(:not_found, fn
"t=" <> timestamp ->
timestamp
_ ->
false
end)

with {:ok, timestamp} <- parse_timestamp(timestamp) do
if timestamp < (System.system_time(:seconds) - tolerance) do
{:error, :expired_timestamp}
else
{:ok, timestamp}
end
end
end

defp parse_timestamp(:not_found), do: {:error, :missing_timestamp}
defp parse_timestamp(timestamp) do
case Integer.parse(timestamp) do
{timestamp, _} ->
{:ok, timestamp}
:error ->
{:error, :invalid_timestamp}
end
end

defp get_signatures(signature_header, scheme \\ @expected_scheme) do
signatures =
signature_header
|> String.split(",")
|> Enum.reduce([], fn item, acc ->
case String.split(item, "=") do
[^scheme, value] ->
[value | acc]
_ ->
acc
end
end)

case signatures do
[] ->
{:error, :missing_signature}
signatures ->
{:ok, signatures}
end
end

defp compute_signature(payload, secret) do
:crypto.hmac(:sha256, secret, payload)
|> Base.encode16(case: :lower)
end

defp convert_to_event(payload) do
payload
|> Poison.decode!()
|> Stripe.Converter.convert_result()
end
end
15 changes: 15 additions & 0 deletions test/stripe/util_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,19 @@ defmodule Stripe.UtilTest do
assert object_name_to_module("token") == Stripe.Token
end
end

describe "secure_equals?/2" do
test "identical strings should equal" do
assert secure_equals?("abcdef", "abcdef") == true
end

test "non-identical strings should not equal" do
assert secure_equals?("abcdef", "123456") == false
end

test "strings of different lengths should not equal" do
assert secure_equals?("abcdef", "abc") == false
assert secure_equals?("abc", "abcdef") == false
end
end
end
67 changes: 67 additions & 0 deletions test/stripe/webhook_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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 construct_event(payload, signature_header, @secret) == {:error, :invalid_signature}
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 construct_event(payload, signature_header, @secret) == {:error, :invalid_signature}
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 construct_event(payload, signature_header, @secret) == {:error, :missing_signature}
end

test "invalid payload with a valid signature should fail" do
timestamp = System.system_time(:seconds)
payload = @invalid_payload
signature = generate_signature(timestamp, payload)
signature_header = create_signature_header(timestamp, @valid_scheme, signature)

assert construct_event(payload, signature_header, @secret) == {:error, :invalid_payload}
end
end

0 comments on commit f4fd456

Please sign in to comment.