Gaiia webhook: proper HMAC verification per docs, optional when no header
This commit is contained in:
parent
370ee35ff7
commit
be5fa8f4d7
1 changed files with 82 additions and 9 deletions
|
|
@ -2,13 +2,13 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
|
|||
@moduledoc """
|
||||
Webhook endpoint for receiving real-time updates from Gaiia.
|
||||
|
||||
Gaiia doesn't sign webhook requests. Instead, a shared secret is verified
|
||||
via a `secret` query parameter in the webhook URL. When configuring the
|
||||
webhook in Gaiia, use:
|
||||
Gaiia signs webhooks with `X-Gaiia-Webhook-Signature` header using HMAC-SHA256.
|
||||
Format: `t=<unix_timestamp>,v1=<hex_signature>`
|
||||
Signed payload: `<timestamp>.<raw_body>`
|
||||
|
||||
https://towerops.net/api/v1/webhooks/gaiia/<org_id>?secret=<webhook_secret>
|
||||
|
||||
If no webhook_secret is configured in the integration, all requests are accepted.
|
||||
If a webhook_secret is configured AND the signature header is present,
|
||||
we verify it. Otherwise we accept the request (allows setup before
|
||||
configuring the secret in Gaiia).
|
||||
|
||||
Route: POST /api/v1/webhooks/gaiia/:organization_id
|
||||
"""
|
||||
|
|
@ -19,10 +19,12 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
|
|||
|
||||
require Logger
|
||||
|
||||
@max_age_seconds 300
|
||||
|
||||
def create(conn, %{"organization_id" => organization_id}) do
|
||||
with {:ok, _integration} <- get_gaiia_integration(organization_id),
|
||||
with {:ok, integration} <- get_gaiia_integration(organization_id),
|
||||
:ok <- maybe_verify_signature(conn, integration),
|
||||
{:ok, event} <- get_event(conn.body_params) do
|
||||
# Gaiia sends object data in "payload" field
|
||||
data = Map.get(conn.body_params, "payload", Map.get(conn.body_params, "data", %{}))
|
||||
entity_id = Map.get(conn.body_params, "objectId", Map.get(data, "id"))
|
||||
payload = %{"entity_id" => entity_id, "data" => data}
|
||||
|
|
@ -40,16 +42,87 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
|
|||
end
|
||||
end
|
||||
|
||||
defp maybe_verify_signature(conn, integration) do
|
||||
secret = get_in(integration.credentials, ["webhook_secret"])
|
||||
sig_header = get_req_header(conn, "x-gaiia-webhook-signature")
|
||||
|
||||
case {secret, sig_header} do
|
||||
# No secret configured — accept without verification
|
||||
{nil, _} ->
|
||||
:ok
|
||||
|
||||
{"", _} ->
|
||||
:ok
|
||||
|
||||
# Secret configured but no signature header — accept (Gaiia may not have secret set yet)
|
||||
{_secret, []} ->
|
||||
Logger.warning("Gaiia webhook received without signature header, skipping verification")
|
||||
:ok
|
||||
|
||||
# Both present — verify
|
||||
{secret, [header]} ->
|
||||
raw_body = conn.private[:raw_body] || ""
|
||||
verify_signature(header, raw_body, secret)
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_signature(header, raw_body, secret) do
|
||||
with {:ok, timestamp, signature} <- parse_signature_header(header),
|
||||
:ok <- check_timestamp(timestamp) do
|
||||
signed_payload = timestamp <> "." <> raw_body
|
||||
|
||||
computed =
|
||||
:hmac
|
||||
|> :crypto.mac(:sha256, secret, signed_payload)
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
if Plug.Crypto.secure_compare(computed, signature) do
|
||||
:ok
|
||||
else
|
||||
Logger.warning("Gaiia webhook signature mismatch")
|
||||
{:error, :invalid_signature}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_signature_header(header) do
|
||||
parts =
|
||||
header
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.split(&1, "=", parts: 2))
|
||||
|> Map.new(fn [k, v] -> {String.trim(k), v} end)
|
||||
|
||||
case {Map.get(parts, "t"), Map.get(parts, "v1")} do
|
||||
{t, v1} when is_binary(t) and is_binary(v1) -> {:ok, t, v1}
|
||||
_ -> {:error, :invalid_signature}
|
||||
end
|
||||
rescue
|
||||
_ -> {:error, :invalid_signature}
|
||||
end
|
||||
|
||||
defp check_timestamp(timestamp) do
|
||||
case Integer.parse(timestamp) do
|
||||
{ts, ""} ->
|
||||
now = System.system_time(:second)
|
||||
if abs(now - ts) <= @max_age_seconds, do: :ok, else: {:error, :invalid_signature}
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_signature}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_event(%{"eventName" => event}) when is_binary(event), do: {:ok, event}
|
||||
defp get_event(%{"event" => event}) when is_binary(event), do: {:ok, event}
|
||||
defp get_event(_), do: {:error, :missing_event}
|
||||
|
||||
# Override action/2 to handle with clause failures
|
||||
def action(conn, _opts) do
|
||||
case apply(__MODULE__, action_name(conn), [conn, conn.params]) do
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"})
|
||||
|
||||
{:error, :invalid_signature} ->
|
||||
conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"})
|
||||
|
||||
{:error, :missing_event} ->
|
||||
conn |> put_status(:bad_request) |> json(%{error: "Missing event field"})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue