-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
358 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters