towerops/lib/towerops/api_tokens.ex
Graham McIntire c4760ca0dc
Add API v1 endpoints with organization-scoped API tokens
Created API token system for programmatic access:
- API tokens table with organization scoping
- ApiTokens context for token management
- ApiAuth plug for Bearer token authentication
- Tokens shown once on creation, then hashed for security

Implemented RESTful API v1 endpoints:
- SitesController: CRUD operations for sites
- DevicesController: CRUD operations for devices
- All operations scoped to authenticated organization
- Proper authorization checks via site ownership

Technical details:
- Tokens prefixed with "towerops_" for easy identification
- SHA-256 hashing for token storage
- Last used timestamp tracking (async update)
- Optional token expiration support
- Standard JSON error responses (40x status codes)

Routes:
- /api/v1/sites (GET, POST, PATCH, DELETE)
- /api/v1/devices (GET, POST, PATCH, DELETE)

Authentication:
- Authorization: Bearer towerops_xxxxx header required
- Returns 401 for invalid/expired tokens
- Returns 403 for unauthorized resource access
2026-01-17 16:53:31 -06:00

164 lines
3.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 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{}}
"""
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{}, ...]
"""
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 """
Gets a single API token by ID.
## Examples
iex> get_api_token!(id)
%ApiToken{}
iex> get_api_token!("nonexistent")
** (Ecto.NoResultsError)
"""
def get_api_token!(id), do: Repo.get!(ApiToken, id)
@doc """
Verifies an API token and returns the associated organization ID.
Also updates the last_used_at timestamp.
Returns {:ok, organization_id} if valid, {:error, :invalid_token} otherwise.
## Examples
iex> verify_token("valid_token")
{:ok, organization_id}
iex> verify_token("invalid")
{: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)
{:ok, token.organization_id}
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{}}
"""
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{}}
"""
def update_api_token(%ApiToken{} = api_token, attrs) do
api_token
|> ApiToken.changeset(attrs)
|> Repo.update()
end
# Private functions
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
defp hash_token(token) do
:sha256
|> :crypto.hash(token)
|> Base.encode16(case: :lower)
end
defp update_last_used(token) do
# Use Task.start to avoid blocking the request
# We don't care if this fails
Task.start(fn ->
token
|> Ecto.Changeset.change(last_used_at: DateTime.utc_now())
|> Repo.update()
end)
end
end