Skip to content

Commit

Permalink
Standard Webhooks: Take 1.
Browse files Browse the repository at this point in the history
  • Loading branch information
cpursley committed Dec 27, 2023
1 parent 57c16d2 commit 4b53354
Show file tree
Hide file tree
Showing 7 changed files with 358 additions and 5 deletions.
2 changes: 0 additions & 2 deletions lib/webhoox/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,4 @@ defmodule Webhoox.Adapter do
| {:ok, conn :: Plug.Conn.t(), map()}
| {:error, conn :: Plug.Conn.t(), String.t()}
| {:error, conn :: Plug.Conn.t(), String.t(), map()}

@callback normalize_params(payload :: any()) :: struct() | nil
end
53 changes: 53 additions & 0 deletions lib/webhoox/adapters/standard_webhook.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule Webhoox.Adapter.StandardWebhook do
@moduledoc """
Standard Webhook Adapter
"""
import Plug.Conn
import Webhoox.Utility.Response

alias Webhoox.Authentication.StandardWebhook, as: Authentication

@behaviour Webhoox.Adapter

def handle_webhook(conn = %Plug.Conn{body_params: params}, handler, opts) do
secret = Keyword.fetch!(opts, :secret)

if Authentication.verify(conn, params, secret) do
authorized_request(conn, params, handler)
else
unauthorized_request(conn)
end
end

defp authorized_request(conn, params, handler) do
response =
params
|> normalize_params(conn)
|> handler.process()

case response do
{_, %Webhoox.Webhook.StandardWebhook{} = resp} ->
{:ok, conn, resp}

{:ok, resp} ->
{:ok, conn, resp}

{:error, :bad_request} ->
bad_request(conn)

_ ->
bad_request(conn)
end
end

def normalize_params(payload, conn) do
[id] = get_req_header(conn, "webhook-id")
[timestamp] = get_req_header(conn, "webhook-timestamp")

%Webhoox.Webhook.StandardWebhook{
id: id,
timestamp: String.to_integer(timestamp),
payload: payload
}
end
end
121 changes: 121 additions & 0 deletions lib/webhoox/authentication/standard_webhook.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
defmodule Webhoox.Authentication.StandardWebhook do
@moduledoc false
import Plug.Conn

@secret_prefix "whsec_"
@signature_identifier "v1"
@tolerance 5 * 60
@now :os.system_time(:second)

def verify(conn, payload, secret) do
required_headers?(conn)

[id] = get_req_header(conn, "webhook-id")
[timestamp] = get_req_header(conn, "webhook-timestamp")
signatures = get_req_header(conn, "webhook-signature")

signed_signature =
sign(id, String.to_integer(timestamp), payload, secret)
|> split_signature_from_identifier()

verify_signatures(signatures, signed_signature)
end

defp required_headers?(%{req_headers: req_headers}) do
required_headers = ["webhook-id", "webhook-timestamp", "webhook-signature"]
filtered_headers = filter_headers(req_headers, required_headers)

unless missing_headers?(filtered_headers, required_headers) do
missing_headers = join_missing_headers(filtered_headers, required_headers)

raise ArgumentError, message: "Missing required headers: #{missing_headers}"
end
end

defp filter_headers(req_headers, required_headers) do
req_headers
|> Enum.map(fn {header, _value} -> header end)
|> Enum.filter(&Enum.member?(required_headers, &1))
end

defp missing_headers?(headers, required_headers) do
required_headers
|> Enum.all?(&Enum.member?(headers, &1))
end

defp join_missing_headers(headers, required_headers) do
required_headers
|> Enum.reject(&Enum.member?(headers, &1))
|> Enum.join(", ")
end

defp verify_signatures([], _signed_signature), do: false

defp verify_signatures(signatures, signature) when signature >= 1 do
signatures
|> Enum.map(&split_signature_from_identifier/1)
|> Enum.any?(&Plug.Crypto.secure_compare(&1, signature))
end

defp split_signature_from_identifier(signature) do
signature
|> String.split(",")
|> List.last()
end

def sign(id, _timestamp, _payload, _secret) when not is_binary(id) do
raise ArgumentError, message: "Message id must be a string"
end

def sign(_id, timestamp, _payload, _secret) when not is_integer(timestamp) do
raise ArgumentError, message: "Message timestamp must be an integer"
end

def sign(_id, timestamp, _payload, _secret)
when is_integer(timestamp) and timestamp < @now - @tolerance do
raise ArgumentError, message: "Message timestamp too old"
end

def sign(_id, timestamp, _payload, _secret)
when is_integer(timestamp) and timestamp > @now + @tolerance do
raise ArgumentError, message: "Message timestamp too new"
end

def sign(_id, _timestamp, payload, _secret) when not is_map(payload) do
raise ArgumentError, message: "Message payload must be a map"
end

def sign(_id, _timestamp, _payload, secret) when not is_binary(secret) do
raise ArgumentError, message: "Secret must be a string"
end

def sign(id, timestamp, payload, @secret_prefix <> secret) do
decoded_secret = Base.decode64!(secret)

sign_with_version(id, timestamp, payload, decoded_secret)
end

def sign(id, timestamp, payload, secret) do
sign_with_version(id, timestamp, payload, secret)
end

defp sign_with_version(id, timestamp, payload, secret) do
signature =
to_sign(id, timestamp, payload)
|> sign_and_encode(secret)

"#{@signature_identifier},#{signature}"
end

defp to_sign(id, timestamp, payload) do
encoded_payload = Jason.encode!(payload)

"#{id}.#{timestamp}.#{encoded_payload}"
end

defp sign_and_encode(to_sign, secret) do
:crypto.mac(:hmac, :sha256, secret, to_sign)
|> Base.encode64()
|> String.trim()
end
end
2 changes: 1 addition & 1 deletion lib/webhoox/webhook/standard_webhook.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule Webhoox.Webhook.StandardWebhook do
"""
@type t :: %__MODULE__{
id: String.t(),
timestamp: String.t(),
timestamp: Integer.t(),
payload: map()
}

Expand Down
43 changes: 43 additions & 0 deletions test/adapters/standard_webhook_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
defmodule Webhoox.Adapter.StandardWebhookTest do
use ExUnit.Case
use Plug.Test

alias Webhoox.Authentication.StandardWebhook, as: Authentication
alias Webhoox.Adapter.StandardWebhook, as: Adapter
alias Webhoox.Webhook.StandardWebhook, as: StandardWebhook

@id "msg_p5jXN8AQM9LWM0D4loKWxJek"
@timestamp :os.system_time(:second)
@payload %{"event_type" => "ping"}
@secret "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"

describe "Authorization" do
test "authorized webhook" do
signature = Authentication.sign(@id, @timestamp, @payload, @secret)
conn = setup_webhook(signature)

{:ok, _conn, response = %StandardWebhook{}} =
Adapter.handle_webhook(conn, TestProcessor, secret: @secret)

assert_receive {:webhook, %StandardWebhook{}}
assert response == %StandardWebhook{id: @id, timestamp: @timestamp, payload: @payload}
end

test "unauthorized webhook" do
conn = setup_webhook("signature")

{:error, _conn, resp} =
Adapter.handle_webhook(conn, TestProcessor, secret: "incorrect secret")

refute_receive {:webhook, %StandardWebhook{}}
assert resp == %{body: %{code: "401", message: "Unauthorized"}, code: :unauthorized}
end
end

defp setup_webhook(signature) do
conn(:post, "/_incoming", @payload)
|> put_req_header("webhook-id", @id)
|> put_req_header("webhook-timestamp", to_string(@timestamp))
|> put_req_header("webhook-signature", signature)
end
end
140 changes: 140 additions & 0 deletions test/authentication/standard_webhook_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
defmodule Webhoox.Authentication.StandardWebhookTest do
use ExUnit.Case
use Plug.Test

alias Webhoox.Authentication.StandardWebhook, as: Authentication

@id "msg_p5jXN8AQM9LWM0D4loKWxJek"
# temp, generate this for current time!
# @timestamp 1_674_087_231
@timestamp :os.system_time(:second)
@tolerance 5 * 60
@payload %{"event_type" => "ping"}
@secret_prefix "whsec_"
@secret "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
@encoded_secret @secret_prefix <> Base.encode64(@secret)

describe "sign/4" do
test "raises error when message id is not a String" do
assert_raise ArgumentError, "Message id must be a string", fn ->
Authentication.sign(123, @timestamp, @payload, @secret)
end
end

test "raises error when message timestamp is not an Integer" do
assert_raise ArgumentError, "Message timestamp must be an integer", fn ->
Authentication.sign(@id, to_string(@timestamp), @payload, @secret)
end
end

test "raises error when message timestamp is too old" do
assert_raise ArgumentError, "Message timestamp too old", fn ->
timestamp = :os.system_time(:second) - @tolerance - 5000
Authentication.sign(@id, timestamp, @payload, @secret)
end
end

test "raises error when message timestamp is too new" do
assert_raise ArgumentError, "Message timestamp too new", fn ->
timestamp = :os.system_time(:second) + @tolerance + 5000
Authentication.sign(@id, timestamp, @payload, @secret)
end
end

test "raises error when message payload is not a Map" do
assert_raise ArgumentError, "Message payload must be a map", fn ->
Authentication.sign(@id, @timestamp, [], @secret)
end
end

test "raises error when secret is not a String" do
assert_raise ArgumentError, "Secret must be a string", fn ->
Authentication.sign(@id, @timestamp, @payload, [])
end
end

test "returns valid signature when unencoded secret" do
[signature_identifier, signature] =
Authentication.sign(@id, @timestamp, @payload, @secret) |> String.split(",")

{:ok, decoded_signature} = Base.decode64(signature)

assert "v1" == signature_identifier
assert is_binary(decoded_signature)
end

test "returns valid signature when encoded secret" do
[signature_identifier, signature] =
Authentication.sign(@id, @timestamp, @payload, @encoded_secret) |> String.split(",")

{:ok, decoded_signature} = Base.decode64(signature)

assert "v1" == signature_identifier
assert is_binary(decoded_signature)
end
end

describe "verify/2" do
setup do
signature = Authentication.sign(@id, @timestamp, @payload, @secret)

{:ok, signature: signature}
end

test "return true when valid signature", %{signature: signature} do
conn = setup_webhook(signature)

assert Authentication.verify(conn, @payload, @secret)
end

test "raises error when missing all required headers" do
connection = conn(:post, "/_incoming", @payload)

assert_raise ArgumentError,
"Missing required headers: webhook-id, webhook-timestamp, webhook-signature",
fn ->
Authentication.verify(connection, @payload, @secret)
end
end

test "raises error when missing webhook-id header", %{signature: signature} do
connection =
conn(:post, "/_incoming", @payload)
|> put_req_header("webhook-timestamp", to_string(@timestamp))
|> put_req_header("webhook-signature", signature)

assert_raise ArgumentError, "Missing required headers: webhook-id", fn ->
Authentication.verify(connection, @payload, @secret)
end
end

test "raises error when missing webhook-timestamp header", %{signature: signature} do
connection =
conn(:post, "/_incoming", @payload)
|> put_req_header("webhook-id", @id)
|> put_req_header("webhook-signature", signature)

assert_raise ArgumentError, "Missing required headers: webhook-timestamp", fn ->
Authentication.verify(connection, @payload, @secret)
end
end

test "raises error when missing webhook-signature header" do
connection =
conn(:post, "/_incoming", @payload)
|> put_req_header("webhook-id", @id)
|> put_req_header("webhook-timestamp", to_string(@timestamp))

assert_raise ArgumentError, "Missing required headers: webhook-signature", fn ->
Authentication.verify(connection, @payload, @secret)
end
end
end

defp setup_webhook(signature) do
conn(:post, "/_incoming", @payload)
|> put_req_header("webhook-id", @id)
|> put_req_header("webhook-timestamp", to_string(@timestamp))
|> put_req_header("webhook-signature", signature)
end
end
2 changes: 0 additions & 2 deletions test/webhoox_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ defmodule WebhooxTest do
defmodule TestAdapter do
@behaviour Webhoox.Adapter

@impl true
def handle_webhook(conn, handler, _opts) do
case conn.body_params do
%{"invalid" => "true"} ->
Expand Down Expand Up @@ -35,7 +34,6 @@ defmodule WebhooxTest do
end
end

@impl true
def normalize_params(payload) do
%Webhoox.Webhook.Email{
from: {nil, payload["from"]},
Expand Down

0 comments on commit 4b53354

Please sign in to comment.