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).
95 lines
3 KiB
Elixir
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
|