142 lines
4.1 KiB
Elixir
142 lines
4.1 KiB
Elixir
defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
|
|
@moduledoc """
|
|
Webhook endpoint for receiving real-time updates from Gaiia.
|
|
|
|
Each organization has its own webhook secret stored in the integration
|
|
credentials. The signature is verified using HMAC-SHA256 against the
|
|
raw request body.
|
|
|
|
Route: POST /api/v1/webhooks/gaiia/:organization_id
|
|
"""
|
|
use ToweropsWeb, :controller
|
|
|
|
alias Towerops.Gaiia.Webhooks
|
|
alias Towerops.Integrations
|
|
|
|
def create(conn, %{"organization_id" => organization_id}) do
|
|
with {:ok, integration} <- get_gaiia_integration(organization_id),
|
|
{:ok, secret} <- get_webhook_secret(integration),
|
|
{:ok, raw_body} <- get_raw_body(conn),
|
|
:ok <- verify_signature(conn, raw_body, secret),
|
|
{:ok, event} <- get_event(conn.body_params) do
|
|
data = Map.get(conn.body_params, "data", %{})
|
|
payload = %{"entity_id" => Map.get(data, "id"), "data" => data}
|
|
|
|
Webhooks.process_event(organization_id, event, payload)
|
|
|
|
json(conn, %{status: "ok"})
|
|
end
|
|
end
|
|
|
|
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
|
|
|
|
defp get_webhook_secret(integration) do
|
|
case get_in(integration.credentials, ["webhook_secret"]) do
|
|
nil -> {:error, :no_secret}
|
|
secret -> {:ok, secret}
|
|
end
|
|
end
|
|
|
|
defp get_raw_body(conn) do
|
|
case conn.private[:raw_body] do
|
|
nil -> {:error, :no_body}
|
|
body -> {:ok, body}
|
|
end
|
|
end
|
|
|
|
defp verify_signature(conn, raw_body, secret) do
|
|
case get_req_header(conn, "x-gaiia-webhook-signature") do
|
|
[header] ->
|
|
with {:ok, timestamp, signature} <- parse_signature_header(header),
|
|
:ok <- check_timestamp(timestamp) do
|
|
check_signature(timestamp, raw_body, secret, signature)
|
|
end
|
|
|
|
_ ->
|
|
{:error, :missing_signature}
|
|
end
|
|
end
|
|
|
|
defp parse_signature_header(header) do
|
|
parts =
|
|
header
|
|
|> String.split(",")
|
|
|> Enum.map(&String.split(&1, "=", parts: 2))
|
|
|> Map.new(fn [k, v] -> {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, :missing_signature}
|
|
end
|
|
rescue
|
|
_ -> {:error, :missing_signature}
|
|
end
|
|
|
|
@max_age_seconds 300
|
|
|
|
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}
|
|
end
|
|
|
|
_ ->
|
|
{:error, :invalid_signature}
|
|
end
|
|
end
|
|
|
|
defp check_signature(timestamp, raw_body, secret, expected) do
|
|
signed_payload = timestamp <> "." <> raw_body
|
|
computed = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower)
|
|
|
|
if Plug.Crypto.secure_compare(computed, expected) do
|
|
:ok
|
|
else
|
|
{:error, :invalid_signature}
|
|
end
|
|
end
|
|
|
|
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, :no_secret} ->
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(%{error: "Webhook secret not configured"})
|
|
|
|
{:error, :missing_signature} ->
|
|
conn |> put_status(:unauthorized) |> json(%{error: "Missing signature header"})
|
|
|
|
{:error, :invalid_signature} ->
|
|
conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"})
|
|
|
|
{:error, :no_body} ->
|
|
conn |> put_status(:bad_request) |> json(%{error: "Missing request body"})
|
|
|
|
{:error, :missing_event} ->
|
|
conn |> put_status(:bad_request) |> json(%{error: "Missing event field"})
|
|
|
|
other ->
|
|
other
|
|
end
|
|
end
|
|
end
|