prop/lib/microwaveprop/accounts/user_api_token.ex
Graham McIntire d31e783776
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m35s
fix: resolve 18 bugs across LiveViews, schemas, tests, and logic
- MonitorLive.Show: safe nil-guard on current_scope for anonymous access
- Admin.MonitorLive.Index: add phx-update=stream to enable stream ops
- ImportLive: require owner/admin authorization, not_found redirect
- MapLive: store timer refs in assigns, cancel before reschedule
- 10 schemas: add missing foreign_key_constraint on belongs_to
- Soundings: preload :station to eliminate N+1 in path analysis
- PathAnalysis: defensive preload of :station on soundings
- GridTaskEnqueuer: wrap reclaim_stale_running in Repo.transaction()
- HrdpsClient: replace String.to_atom with compile-time atom literals
- Contacts: fix extract_latlon false return for lon=0.0
- Tests: remove duplicate Mox.defmock, unblock swallowed task exits,
  bump refute_receive timeouts from 50ms to 200ms
2026-07-29 07:46:54 -05:00

95 lines
2.7 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__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(token, attrs) do
token
|> cast(attrs, [:name, :user_id])
|> foreign_key_constraint(:user_id)
end
@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