- 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
97 lines
3.2 KiB
Elixir
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
|