prop/lib/microwaveprop/accounts/user_api_token.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

88 lines
2.5 KiB
Elixir

defmodule Microwaveprop.Accounts.UserApiToken do
@moduledoc """
Long-lived bearer token for `/api/v1` access. Distinct from the
short-lived `users_tokens` rows used by the browser session and
email-confirmation flows so revoking an API token does not also
log the user out of the website.
The plaintext token is shown to the user exactly once at creation.
Only the SHA-256 hash is persisted, mirroring the pattern used by
`Microwaveprop.Accounts.UserToken` for email-delivered tokens.
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Accounts.User
@hash_algorithm :sha256
@rand_size 32
@prefix "mwp_"
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_api_tokens" do
field :name, :string
field :token_hash, :binary
field :last_used_at, :utc_datetime
field :expires_at, :utc_datetime
field :revoked_at, :utc_datetime
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@doc "Returns the bearer-token prefix (e.g. `mwp_`)."
@spec token_prefix() :: String.t()
def token_prefix, do: @prefix
@doc """
Builds a `{plaintext_token, %UserApiToken{}}` tuple. The struct is
unsaved — callers persist it via `Repo.insert/1`.
"""
@spec build(User.t(), map()) :: {:ok, {String.t(), Ecto.Changeset.t()}} | {:error, Ecto.Changeset.t()}
def build(%User{id: user_id}, attrs) do
raw = :crypto.strong_rand_bytes(@rand_size)
plaintext = @prefix <> Base.url_encode64(raw, padding: false)
hash = hash_token(plaintext)
changeset =
%__MODULE__{}
|> cast(attrs, [:name, :expires_at])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> put_change(:token_hash, hash)
|> put_change(:user_id, user_id)
|> validate_future_expiry()
if changeset.valid? do
{:ok, {plaintext, changeset}}
else
{:error, %{changeset | action: :insert}}
end
end
@doc "SHA-256 hash of the plaintext bearer token."
@spec hash_token(String.t()) :: binary()
def hash_token(plaintext) when is_binary(plaintext) do
:crypto.hash(@hash_algorithm, plaintext)
end
defp validate_future_expiry(changeset) do
case get_field(changeset, :expires_at) do
nil ->
changeset
%DateTime{} = dt ->
if DateTime.after?(dt, DateTime.utc_now()) do
changeset
else
add_error(changeset, :expires_at, "must be in the future")
end
end
end
end