prop/lib/microwaveprop_web/api/monitor_auth.ex
Graham McIntire bd731685de
feat(api): ingest endpoint for propmonitor beacon measurements
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.

Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.

MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
2026-05-13 16:07:04 -05:00

71 lines
2.3 KiB
Elixir

defmodule MicrowavepropWeb.Api.MonitorAuth do
@moduledoc """
Bearer-token authentication for propmonitor uploads.
Resolves `Authorization: Bearer <token>` against
`Microwaveprop.BeaconMonitors.get_monitor_by_token/1`, separate from
the user-API-token plug at `MicrowavepropWeb.Api.Auth`. Monitor tokens
are issued via `POST /api/v1/me/beacon-monitors` and identify a
monitor station, not a user session.
On success, assigns:
* `:current_monitor` — the `BeaconMonitor` struct
* `:current_api_token` — `{:monitor, monitor_id}` so the
rate-limiter buckets the monitor by id rather than IP.
On failure, halts with a 401 `application/problem+json` response.
"""
@behaviour Plug
import Plug.Conn
alias Microwaveprop.BeaconMonitors
alias MicrowavepropWeb.Api.ErrorJSON
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
case extract_bearer(conn) do
{:ok, token} ->
case BeaconMonitors.get_monitor_by_token(token) do
nil -> halt_unauthorized(conn, "Bearer token is invalid, expired, or revoked.")
monitor -> assign_monitor(conn, monitor)
end
{:error, :missing_token} ->
halt_unauthorized(conn, "Missing bearer token in Authorization header.")
{:error, :invalid_authorization_header} ->
halt_unauthorized(conn, "Authorization header must be `Bearer <token>`.")
end
end
defp extract_bearer(conn) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] when byte_size(token) > 0 -> {:ok, token}
["bearer " <> token] when byte_size(token) > 0 -> {:ok, token}
[] -> {:error, :missing_token}
_ -> {:error, :invalid_authorization_header}
end
end
defp assign_monitor(conn, monitor) do
# The rate-limiter buckets on `current_api_token.id`. Map the
# monitor onto that shape so retries from one monitor count
# against a per-monitor bucket, not the shared anon-IP bucket
# (which would punish co-located stations behind one NAT).
conn
|> assign(:current_monitor, monitor)
|> assign(:current_api_token, %{id: {:monitor, monitor.id}})
end
defp halt_unauthorized(conn, detail) do
conn
|> put_resp_header("www-authenticate", ~s(Bearer realm="api"))
|> ErrorJSON.send_problem(401, "unauthorized", detail)
end
end