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