towerops/lib/towerops_web/controllers/api/v1/stripe_webhook_controller.ex

103 lines
2.6 KiB
Elixir

defmodule ToweropsWeb.Api.V1.StripeWebhookController do
use ToweropsWeb, :controller
alias Towerops.Billing.WebhookProcessor
require Logger
@doc """
Stripe webhook endpoint.
Receives events like payment_intent.succeeded, customer.subscription.updated.
Verifies signature before processing.
"""
def create(conn, _params) do
signature = conn |> get_req_header("stripe-signature") |> List.first()
raw_body = conn.private[:raw_body] || ""
case verify_signature(raw_body, signature) do
:ok ->
event = Jason.decode!(raw_body)
case WebhookProcessor.process(event) do
:ok ->
json(conn, %{received: true})
{:error, reason} ->
Logger.error("Webhook processing failed: #{inspect(reason)}")
json(conn, %{received: true})
end
{:error, :invalid_signature} ->
Logger.warning("Invalid webhook signature")
conn
|> put_status(401)
|> json(%{error: "Invalid signature"})
{:error, reason} ->
Logger.error("Webhook verification failed: #{inspect(reason)}")
conn
|> put_status(400)
|> json(%{error: "Verification failed"})
end
end
defp verify_signature(payload, signature_header) do
webhook_secret = Application.fetch_env!(:towerops, :stripe_webhook_secret)
with {:ok, timestamp, signature} <- parse_signature_header(signature_header),
:ok <- check_timestamp_tolerance(timestamp, 300) do
signed_payload = "#{timestamp}.#{payload}"
expected_signature =
:hmac
|> :crypto.mac(:sha256, webhook_secret, signed_payload)
|> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(signature, expected_signature) do
:ok
else
{:error, :invalid_signature}
end
end
end
defp parse_signature_header(header) do
parts = String.split(header, ",")
timestamp =
Enum.find_value(parts, fn part ->
case String.split(part, "=") do
["t", t] -> t
_ -> nil
end
end)
signature =
Enum.find_value(parts, fn part ->
case String.split(part, "=") do
["v1", sig] -> sig
_ -> nil
end
end)
if timestamp && signature do
{:ok, timestamp, signature}
else
{:error, :malformed_header}
end
end
defp check_timestamp_tolerance(timestamp_str, max_age_seconds) do
timestamp = String.to_integer(timestamp_str)
now = System.system_time(:second)
if abs(now - timestamp) <= max_age_seconds do
:ok
else
{:error, :timestamp_too_old}
end
end
end