prop/lib/microwaveprop_web/api/auth.ex
Graham McIntire c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00

95 lines
3 KiB
Elixir

defmodule MicrowavepropWeb.Api.Auth do
@moduledoc """
Bearer-token authentication for `/api/v1`.
Looks up `Authorization: Bearer <token>` headers, resolves them via
`Microwaveprop.Accounts.get_user_by_api_token/1`, and stashes the
user + token on `conn.assigns`.
Two plugs:
* `optional_auth/2` — never halts, sets `:current_api_user` when a
valid token is present and falls through unauthenticated otherwise.
Use for endpoints with a public read fallback.
* `require_auth/2` — halts with `401 Unauthorized` problem+json when
no valid token is present. Use for any endpoint that mutates state
or returns user-private data.
"""
@behaviour Plug
import Plug.Conn
alias Microwaveprop.Accounts
alias MicrowavepropWeb.Api.ErrorJSON
@impl true
def init(opts), do: Keyword.put_new(opts, :mode, :require)
@impl true
def call(conn, opts) do
case Keyword.fetch!(opts, :mode) do
:require -> require_auth(conn, opts)
:optional -> optional_auth(conn, opts)
end
end
@doc "Plug that halts with 401 when no valid bearer token is supplied."
@spec require_auth(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
def require_auth(conn, _opts \\ []) do
case authenticate(conn) do
{:ok, conn} ->
conn
{:error, reason} ->
conn
|> put_resp_header("www-authenticate", ~s(Bearer realm="api"))
|> ErrorJSON.send_problem(401, "unauthorized", reason_message(reason))
end
end
@doc "Plug that lets unauthenticated requests through but assigns the user when a token is valid."
@spec optional_auth(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
def optional_auth(conn, _opts \\ []) do
case authenticate(conn) do
{:ok, conn} -> conn
{:error, :missing_token} -> assign_anonymous(conn)
{:error, reason} -> halt_with_invalid(conn, reason)
end
end
defp authenticate(conn) do
with {:ok, plaintext} <- extract_bearer(conn),
{:ok, user, token} <- Accounts.get_user_by_api_token(plaintext) do
{:ok,
conn
|> assign(:current_api_user, user)
|> assign(:current_api_token, 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_anonymous(conn) do
conn
|> assign(:current_api_user, nil)
|> assign(:current_api_token, nil)
end
defp halt_with_invalid(conn, reason) do
conn
|> put_resp_header("www-authenticate", ~s(Bearer realm="api"))
|> ErrorJSON.send_problem(401, "unauthorized", reason_message(reason))
end
defp reason_message(:missing_token), do: "Missing bearer token in Authorization header."
defp reason_message(:invalid_authorization_header), do: "Authorization header must be `Bearer <token>`."
defp reason_message(:invalid_token), do: "Bearer token is invalid, expired, or revoked."
end