agent token changes

This commit is contained in:
Graham McIntire 2026-01-13 13:43:43 -06:00
parent aa62cc7349
commit b8bd952681
No known key found for this signature in database
7 changed files with 111 additions and 51 deletions

View file

@ -114,10 +114,10 @@ defmodule Towerops.Agents do
{:error, :invalid_token}
"""
def verify_agent_token(encoded_token) do
with {:ok, token_hash} <- AgentToken.verify_token(encoded_token),
def verify_agent_token(token) do
with {:ok, token_string} <- AgentToken.verify_token(token),
%AgentToken{} = agent_token <-
Repo.get_by(AgentToken, token_hash: token_hash, enabled: true) do
Repo.get_by(AgentToken, token: token_string, enabled: true) do
{:ok, agent_token}
else
_ -> {:error, :invalid_token}

View file

@ -3,20 +3,20 @@ defmodule Towerops.Agents.AgentToken do
Schema for agent authentication tokens.
Agent tokens are used to authenticate remote SNMP polling agents that run on customer networks.
Tokens are generated with cryptographically secure random bytes, and only the SHA256 hash is
stored in the database for security.
Tokens are generated with cryptographically secure random bytes (48 bytes = 384 bits) and
stored in plaintext in the database. Tokens can be retrieved and viewed by authorized users.
"""
use Ecto.Schema
import Ecto.Changeset
@hash_algorithm :sha256
@rand_size 32
# 48 bytes = 384 bits of cryptographically secure randomness
@rand_size 48
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "agent_tokens" do
field :token_hash, :binary
field :token, :string
field :name, :string
field :last_seen_at, :utc_datetime
field :last_ip, :string
@ -29,22 +29,24 @@ defmodule Towerops.Agents.AgentToken do
end
@doc """
Builds a new agent token with secure random bytes.
Builds a new agent token with cryptographically secure random bytes.
Returns a tuple of {encoded_token, changeset}. The encoded_token should be
returned to the user ONCE and never stored in plaintext.
Returns a tuple of {token_string, changeset}. The token_string is stored in the database
and can be retrieved later for display to authorized users.
## Examples
iex> {token, changeset} = AgentToken.build_token(org_id, "My Agent")
iex> is_binary(token)
true
iex> byte_size(Base.url_decode64!(token, padding: false))
48
"""
def build_token(organization_id, name) do
# Generate 48 bytes (384 bits) of cryptographically secure random data
token = :crypto.strong_rand_bytes(@rand_size)
encoded_token = Base.url_encode64(token, padding: false)
hashed_token = :crypto.hash(@hash_algorithm, token)
changeset =
%__MODULE__{}
@ -52,38 +54,33 @@ defmodule Towerops.Agents.AgentToken do
%{
organization_id: organization_id,
name: name,
token_hash: hashed_token
token: encoded_token
},
[:organization_id, :name, :token_hash]
[:organization_id, :name, :token]
)
|> validate_required([:organization_id, :name, :token_hash])
|> validate_required([:organization_id, :name, :token])
|> unique_constraint(:token)
{encoded_token, changeset}
end
@doc """
Verifies an encoded token by hashing it.
Verifies a token by checking if it exists in the database.
Returns {:ok, token_hash} if the token can be decoded and hashed,
otherwise {:error, :invalid_token}.
Returns {:ok, token} if valid, otherwise {:error, :invalid_token}.
## Examples
iex> AgentToken.verify_token("valid_base64_token")
{:ok, <<hashed_bytes>>}
iex> AgentToken.verify_token("valid_token")
{:ok, "valid_token"}
iex> AgentToken.verify_token("invalid")
{:error, :invalid_token}
"""
def verify_token(encoded_token) do
case Base.url_decode64(encoded_token, padding: false) do
{:ok, token} ->
hashed = :crypto.hash(@hash_algorithm, token)
{:ok, hashed}
_ ->
{:error, :invalid_token}
end
def verify_token(token) when is_binary(token) do
{:ok, token}
end
def verify_token(_), do: {:error, :invalid_token}
end

View file

@ -58,6 +58,44 @@ defmodule ToweropsWeb.AgentLive.Index do
|> assign(:new_token, nil)}
end
@impl true
def handle_event("show_setup", %{"id" => id}, socket) do
agent_token = Agents.get_agent_token!(id)
{:noreply,
socket
|> assign(:new_token, %{agent_token: agent_token, token: agent_token.token})
|> assign(:show_token_modal, true)}
end
@impl true
def handle_event("regenerate_token", %{"id" => id}, socket) do
agent_token = Agents.get_agent_token!(id)
organization = socket.assigns.current_organization
# Revoke the old token and create a new one
case Agents.revoke_agent_token(id) do
{:ok, _} ->
case Agents.create_agent_token(organization.id, agent_token.name) do
{:ok, new_agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:new_token, %{agent_token: new_agent_token, token: token})
|> assign(:show_token_modal, true)
|> put_flash(:info, "New token generated successfully")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to generate new token")}
end
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to revoke old token")}
end
end
@impl true
def handle_event("revoke_agent", %{"id" => id}, socket) do
case Agents.revoke_agent_token(id) do

View file

@ -106,15 +106,25 @@
<:action :let={agent}>
<%= if agent.enabled do %>
<button
type="button"
phx-click="revoke_agent"
phx-value-id={agent.id}
data-confirm="Are you sure you want to revoke this agent? It will no longer be able to authenticate."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Revoke
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
View Setup
</button>
<button
type="button"
phx-click="revoke_agent"
phx-value-id={agent.id}
data-confirm="Are you sure you want to revoke this agent? It will no longer be able to authenticate."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Revoke
</button>
</div>
<% else %>
<span class="text-xs text-zinc-500 dark:text-zinc-400">Revoked</span>
<% end %>
@ -128,21 +138,23 @@
<div
id="token-modal"
class="fixed inset-0 z-50 overflow-y-auto"
phx-click="close_token_modal"
phx-mounted={JS.show()}
>
<div class="flex min-h-screen items-center justify-center p-4">
<div class="fixed inset-0 bg-zinc-500/75 dark:bg-zinc-950/75 transition-opacity"></div>
<div
class="fixed inset-0 bg-zinc-500/75 dark:bg-zinc-950/75 transition-opacity"
phx-click="close_token_modal"
></div>
<div class="relative bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl w-full">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Agent Created Successfully
Agent Setup Instructions
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Copy this token now - it will never be shown again!
Use the token and Docker Compose configuration below to deploy this agent.
</p>
</div>
<button
@ -193,10 +205,9 @@
<h4 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
Docker Compose Example
</h4>
<pre class="text-xs font-mono text-zinc-600 dark:text-zinc-400 overflow-x-auto"><code>version: '3.8'
services:
<pre class="text-xs font-mono text-zinc-600 dark:text-zinc-400 overflow-x-auto"><code>services:
towerops-agent:
image: towerops/agent:latest
image: gmcintire/towerops-agent:latest
restart: unless-stopped
environment:
- TOWEROPS_API_URL={url(@socket, ~p"/")}
@ -210,10 +221,9 @@ services:
>
Copy to clipboard
</button>
<textarea id="docker-compose-example" class="sr-only">version: '3.8'
services:
<textarea id="docker-compose-example" class="sr-only">services:
towerops-agent:
image: towerops/agent:latest
image: gmcintire/towerops-agent:latest
restart: unless-stopped
environment:
- TOWEROPS_API_URL={url(@socket, ~p"/")}

View file

@ -0,0 +1,14 @@
defmodule Towerops.Repo.Migrations.ChangeAgentTokenToPlaintext do
use Ecto.Migration
def change do
# Drop existing tokens since we can't migrate hashed tokens to plaintext
execute "DELETE FROM agent_tokens", "DELETE FROM agent_tokens"
# Rename column and change type
alter table(:agent_tokens) do
remove :token_hash
add :token, :text, null: false
end
end
end

View file

@ -24,7 +24,8 @@ defmodule Towerops.AgentsTest do
assert agent_token.organization_id == org.id
assert agent_token.name == "Test Agent"
assert agent_token.enabled == true
assert is_binary(agent_token.token_hash)
assert is_binary(agent_token.token)
assert agent_token.token == token
end
test "returns error with invalid data" do

View file

@ -39,9 +39,9 @@ defmodule ToweropsWeb.AgentLiveTest do
|> form("#new-agent-form form", %{name: "Test Agent"})
|> render_submit()
assert html =~ "Agent Created Successfully"
assert html =~ "Agent Setup Instructions"
assert html =~ "Test Agent"
assert html =~ "Copy this token now"
assert html =~ "Authentication Token"
end
test "revokes agent", %{conn: conn, organization: organization} do