189 lines
6.1 KiB
Elixir
189 lines
6.1 KiB
Elixir
defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
|
|
@moduledoc """
|
|
Webhook endpoint for receiving real-time updates from Gaiia.
|
|
|
|
Implements signature verification per Gaiia's webhook security docs:
|
|
https://app.gaiia.com/docs/webhooks/security
|
|
|
|
## Gaiia's verification algorithm:
|
|
|
|
1. **Extract** timestamp (`t`) and signature (`v1`) from the
|
|
`X-Gaiia-Webhook-Signature` header. Format: `t=<unix>,v1=<hex>`
|
|
|
|
2. **Prepare** the signed payload: `<timestamp>.<raw_request_body>`
|
|
|
|
3. **Compute** HMAC-SHA256 of the signed payload using the endpoint's
|
|
secret key. Hex-encode the result.
|
|
|
|
4. **Compare** the computed signature with `v1` from the header.
|
|
Also check that the timestamp is within acceptable tolerance.
|
|
|
|
Route: POST /api/v1/webhooks/gaiia/:organization_id
|
|
"""
|
|
use ToweropsWeb, :controller
|
|
|
|
alias Towerops.Gaiia.Webhooks
|
|
alias Towerops.Integrations
|
|
|
|
require Logger
|
|
|
|
@max_age_seconds 300
|
|
|
|
# ── Main action ──
|
|
|
|
def create(conn, %{"organization_id" => organization_id}) do
|
|
with {:ok, integration} <- get_gaiia_integration(organization_id),
|
|
:ok <- verify_webhook(conn, integration) do
|
|
process_webhook(conn, organization_id)
|
|
else
|
|
{:error, :not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"})
|
|
|
|
{:error, :no_secret_configured} ->
|
|
conn |> put_status(:forbidden) |> json(%{error: "Webhook secret not configured"})
|
|
|
|
{:error, :invalid_signature} ->
|
|
conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"})
|
|
|
|
{:error, reason} ->
|
|
conn |> put_status(:unauthorized) |> json(%{error: "Webhook verification failed: #{reason}"})
|
|
end
|
|
end
|
|
|
|
defp process_webhook(conn, organization_id) do
|
|
event = Map.get(conn.body_params, "eventName") || Map.get(conn.body_params, "event")
|
|
|
|
if is_nil(event) do
|
|
Logger.warning("Gaiia webhook missing eventName: #{inspect(Map.keys(conn.body_params))}")
|
|
conn |> put_status(:bad_request) |> json(%{error: "Missing eventName field"})
|
|
else
|
|
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"))
|
|
|
|
Webhooks.process_event(organization_id, event, %{
|
|
"entity_id" => entity_id,
|
|
"data" => data
|
|
})
|
|
|
|
json(conn, %{status: "ok"})
|
|
end
|
|
end
|
|
|
|
# ── Integration lookup ──
|
|
|
|
defp get_gaiia_integration(organization_id) do
|
|
case Integrations.get_integration(organization_id, "gaiia") do
|
|
{:ok, integration} -> {:ok, integration}
|
|
{:error, :not_found} -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
# ── Signature verification per Gaiia docs ──
|
|
|
|
defp verify_webhook(conn, integration) do
|
|
secret = get_in(integration.credentials, ["webhook_secret"])
|
|
sig_headers = Plug.Conn.get_req_header(conn, "x-gaiia-webhook-signature")
|
|
|
|
cond do
|
|
is_nil(secret) or secret == "" ->
|
|
Logger.warning("Gaiia webhook: rejected - no webhook secret configured")
|
|
{:error, :no_secret_configured}
|
|
|
|
sig_headers == [] ->
|
|
Logger.warning("Gaiia webhook: secret configured but no signature header received")
|
|
{:error, :missing_signature}
|
|
|
|
true ->
|
|
raw_body = conn.private[:raw_body] || ""
|
|
verify_gaiia_signature(hd(sig_headers), raw_body, secret)
|
|
end
|
|
end
|
|
|
|
# Step 1: Parse the header to extract t and v1
|
|
# Step 2-4: Build signed payload, compute HMAC, compare
|
|
defp verify_gaiia_signature(header, raw_body, secret) do
|
|
with {:ok, timestamp, v1_signature} <- parse_signature_header(header),
|
|
:ok <- check_timestamp(timestamp) do
|
|
# Step 2: signedPayload = "<timestamp>.<raw_body>"
|
|
signed_payload = timestamp <> "." <> raw_body
|
|
|
|
# Step 3: HMAC-SHA256, hex-encoded lowercase
|
|
expected =
|
|
:hmac
|
|
|> :crypto.mac(:sha256, secret, signed_payload)
|
|
|> Base.encode16(case: :lower)
|
|
|
|
# Step 4: Constant-time comparison
|
|
if Plug.Crypto.secure_compare(expected, v1_signature) do
|
|
:ok
|
|
else
|
|
Logger.warning(
|
|
"Gaiia webhook signature mismatch — " <>
|
|
"ts=#{timestamp} body_len=#{byte_size(raw_body)}"
|
|
)
|
|
|
|
{:error, :invalid_signature}
|
|
end
|
|
else
|
|
{:error, :malformed} ->
|
|
Logger.warning("Gaiia webhook: malformed signature header: #{inspect(header)}")
|
|
{:error, :invalid_signature}
|
|
|
|
{:error, :expired} ->
|
|
Logger.warning("Gaiia webhook: timestamp expired (>#{@max_age_seconds}s old)")
|
|
{:error, :invalid_signature}
|
|
end
|
|
end
|
|
|
|
# Parse "t=1492774577,v1=5257a869..." into {:ok, "1492774577", "5257a869..."}
|
|
defp parse_signature_header(header) do
|
|
parts =
|
|
header
|
|
|> String.split(",")
|
|
|> Enum.reduce(%{}, fn element, acc ->
|
|
case String.split(element, "=", parts: 2) do
|
|
[key, value] -> Map.put(acc, String.trim(key), value)
|
|
_ -> acc
|
|
end
|
|
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, :malformed}
|
|
end
|
|
end
|
|
|
|
# Step 4 (from docs): "calculate the difference between the current
|
|
# timestamp and the received timestamp, then decide if the difference
|
|
# is within your tolerance"
|
|
defp check_timestamp(timestamp_str) do
|
|
case Integer.parse(timestamp_str) do
|
|
{ts, ""} ->
|
|
# Gaiia sends timestamps in milliseconds — normalize to seconds
|
|
ts_seconds = if ts > 9_999_999_999, do: div(ts, 1000), else: ts
|
|
age = abs(System.system_time(:second) - ts_seconds)
|
|
if age <= @max_age_seconds, do: :ok, else: {:error, :expired}
|
|
|
|
_ ->
|
|
{:error, :malformed}
|
|
end
|
|
end
|
|
|
|
# ── Error handling ──
|
|
|
|
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"})
|
|
|
|
%Plug.Conn{} = conn ->
|
|
conn
|
|
|
|
other ->
|
|
other
|
|
end
|
|
end
|
|
end
|