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
164 lines
3.8 KiB
Elixir
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
|