prop/lib/microwaveprop_web/api/auth.ex
Graham McInitre d30b6d3022 refactor: DRY extract_bearer, reuse validation helpers, add query limits
- Extract shared extract_bearer/1 from api/monitor_auth.ex → api/auth.ex
- Refactor admin_changeset to pipeline through validate_callsign/validate_name/validate_email
- Add limit: 500 to list_beacons/0 and candidate_rover_locations/1
- Combine get_mission_with_paths preloads into single round-trip
2026-07-21 10:06:12 -05:00

97 lines
3.2 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
@doc false
@spec extract_bearer(Plug.Conn.t()) :: {:ok, String.t()} | {:error, :missing_token | :invalid_authorization_header}
def 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