Skip to content

Commit

Permalink
Backport webhooks support
Browse files Browse the repository at this point in the history
  • Loading branch information
jayjun committed Jul 4, 2017
1 parent 1b6a71a commit a24a7c1
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 0 deletions.
109 changes: 109 additions & 0 deletions lib/stripe/webhook.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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, Stripe.Util.string_map_to_atoms(Poison.decode!(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
end
58 changes: 58 additions & 0 deletions test/stripe/webhook_test.exs
Original file line number Diff line number Diff line change
@@ -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, _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

0 comments on commit a24a7c1

Please sign in to comment.