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).
88 lines
2.5 KiB
Elixir
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
|