223 lines
5.8 KiB
Elixir
223 lines
5.8 KiB
Elixir
defmodule Towerops.ApiTokens do
|
|
@moduledoc """
|
|
Context for managing API tokens.
|
|
|
|
API tokens provide programmatic access to the Towerops API and are scoped
|
|
to a specific organization.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
alias Ecto.Adapters.SQL.Sandbox
|
|
alias Towerops.ApiTokens.ApiToken
|
|
alias Towerops.Repo
|
|
|
|
@doc """
|
|
Creates an API token for an organization.
|
|
|
|
Returns {:ok, {api_token, raw_token}} where raw_token is the plain text
|
|
token that should be shown to the user once.
|
|
|
|
## Examples
|
|
|
|
iex> create_api_token(%{organization_id: org_id, name: "Production API"})
|
|
{:ok, {%ApiToken{}, "towerops_..."}}
|
|
|
|
iex> create_api_token(%{name: ""})
|
|
{:error, %Ecto.Changeset{}}
|
|
"""
|
|
@spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()}
|
|
def create_api_token(attrs \\ %{}) do
|
|
# Generate a random token
|
|
raw_token = generate_token()
|
|
token_hash = hash_token(raw_token)
|
|
|
|
attrs = Map.put(attrs, :token_hash, token_hash)
|
|
|
|
case %ApiToken{}
|
|
|> ApiToken.changeset(attrs)
|
|
|> Repo.insert() do
|
|
{:ok, api_token} ->
|
|
{:ok, {api_token, raw_token}}
|
|
|
|
{:error, changeset} ->
|
|
{:error, changeset}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Lists all API tokens for an organization.
|
|
|
|
## Examples
|
|
|
|
iex> list_organization_api_tokens(org_id)
|
|
[%ApiToken{}, ...]
|
|
"""
|
|
@spec list_organization_api_tokens(binary()) :: [ApiToken.t()]
|
|
def list_organization_api_tokens(organization_id) do
|
|
ApiToken
|
|
|> where([t], t.organization_id == ^organization_id)
|
|
|> order_by([t], desc: t.inserted_at)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Lists all API tokens created by a user across all organizations.
|
|
|
|
## Examples
|
|
|
|
iex> list_user_api_tokens(user_id)
|
|
[%ApiToken{}, ...]
|
|
"""
|
|
@spec list_user_api_tokens(binary()) :: [ApiToken.t()]
|
|
def list_user_api_tokens(user_id) do
|
|
ApiToken
|
|
|> where([t], t.user_id == ^user_id)
|
|
|> order_by([t], desc: t.inserted_at)
|
|
|> preload(:organization)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Gets a single API token by ID.
|
|
|
|
## Examples
|
|
|
|
iex> get_api_token!(id)
|
|
%ApiToken{}
|
|
|
|
iex> get_api_token!("nonexistent")
|
|
** (Ecto.NoResultsError)
|
|
"""
|
|
@spec get_api_token!(binary()) :: ApiToken.t()
|
|
def get_api_token!(id), do: Repo.get!(ApiToken, id)
|
|
|
|
@doc """
|
|
Verifies an API token and returns the associated organization ID and user.
|
|
|
|
Also updates the last_used_at timestamp.
|
|
|
|
Returns {:ok, organization_id, user} if valid, {:error, :invalid_token} otherwise.
|
|
|
|
## Examples
|
|
|
|
iex> verify_token("valid_token")
|
|
{:ok, organization_id, %User{}}
|
|
|
|
iex> verify_token("invalid")
|
|
{:error, :invalid_token}
|
|
"""
|
|
@spec verify_token(String.t()) ::
|
|
{:ok, binary(), Towerops.Accounts.User.t() | nil} | {:error, :invalid_token}
|
|
def verify_token(raw_token) do
|
|
token_hash = hash_token(raw_token)
|
|
|
|
case Repo.get_by(ApiToken, token_hash: token_hash) do
|
|
nil ->
|
|
{:error, :invalid_token}
|
|
|
|
token ->
|
|
# Check if token is expired
|
|
if token.expires_at && DateTime.before?(token.expires_at, DateTime.utc_now()) do
|
|
{:error, :invalid_token}
|
|
else
|
|
# Update last_used_at
|
|
update_last_used(token)
|
|
|
|
# Load user if present
|
|
token = Repo.preload(token, :user)
|
|
{:ok, token.organization_id, token.user}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Deletes an API token.
|
|
|
|
## Examples
|
|
|
|
iex> delete_api_token(api_token)
|
|
{:ok, %ApiToken{}}
|
|
|
|
iex> delete_api_token(api_token)
|
|
{:error, %Ecto.Changeset{}}
|
|
"""
|
|
@spec delete_api_token(ApiToken.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
|
def delete_api_token(%ApiToken{} = api_token) do
|
|
Repo.delete(api_token)
|
|
end
|
|
|
|
@doc """
|
|
Updates an API token (name, expiration).
|
|
|
|
## Examples
|
|
|
|
iex> update_api_token(api_token, %{name: "New Name"})
|
|
{:ok, %ApiToken{}}
|
|
|
|
iex> update_api_token(api_token, %{name: nil})
|
|
{:error, %Ecto.Changeset{}}
|
|
"""
|
|
@spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
|
def update_api_token(%ApiToken{} = api_token, attrs) do
|
|
api_token
|
|
|> ApiToken.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
# Private functions
|
|
|
|
@spec generate_token() :: String.t()
|
|
defp generate_token do
|
|
# Generate a 32-byte random token and encode it as base64
|
|
# Prefix with "towerops_" for easy identification
|
|
random_bytes = :crypto.strong_rand_bytes(32)
|
|
"towerops_" <> Base.url_encode64(random_bytes, padding: false)
|
|
end
|
|
|
|
@spec hash_token(String.t()) :: String.t()
|
|
defp hash_token(token) do
|
|
:sha256
|
|
|> :crypto.hash(token)
|
|
|> Base.encode16(case: :lower)
|
|
end
|
|
|
|
@spec update_last_used(ApiToken.t()) :: :ok
|
|
defp update_last_used(token) do
|
|
timestamp = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
# In test mode, update synchronously to avoid ownership issues
|
|
# In production, use Task.start to avoid blocking the request
|
|
case Application.get_env(:towerops, :env) do
|
|
:test ->
|
|
update_token_timestamp(token, timestamp)
|
|
|
|
_ ->
|
|
spawn_async_update(token, timestamp)
|
|
end
|
|
end
|
|
|
|
@spec update_token_timestamp(ApiToken.t(), DateTime.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
|
defp update_token_timestamp(token, timestamp) do
|
|
token
|
|
|> Ecto.Changeset.change(last_used_at: timestamp)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@spec spawn_async_update(ApiToken.t(), DateTime.t()) :: :ok
|
|
defp spawn_async_update(token, timestamp) do
|
|
parent = self()
|
|
|
|
_ =
|
|
Task.start(fn ->
|
|
_ = maybe_allow_sandbox(parent)
|
|
update_token_timestamp(token, timestamp)
|
|
end)
|
|
end
|
|
|
|
defp maybe_allow_sandbox(parent) do
|
|
if Application.get_env(:towerops, :sql_sandbox) do
|
|
Sandbox.allow(Repo, parent, self())
|
|
end
|
|
end
|
|
end
|