From dfb9305e3f4928503ed384b28b9f31a2610a16f9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 20 Jun 2026 22:30:59 -0500 Subject: [PATCH] feat: render account control pages in LiveView * admin control panel --- README.md | 24 +- docs/tasks/18-account-management.md | 24 +- lib/tempest/admin_auth.ex | 177 +++- .../controllers/admin_session_controller.ex | 29 +- .../admin_session_html/new.html.heex | 22 +- lib/tempest_web/live/account_control_live.ex | 761 +++++++++++++++++- lib/tempest_web/live/admin_control_live.ex | 479 ++++++++++- lib/tempest_web/router.ex | 2 + .../controllers/admin_controller_test.exs | 157 +++- .../operator_account_controller_test.exs | 2 +- .../live/account_control_live_test.exs | 146 ++++ 11 files changed, 1765 insertions(+), 58 deletions(-) create mode 100644 test/tempest_web/live/account_control_live_test.exs diff --git a/README.md b/README.md index 1a40c94..d66afdf 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,24 @@ TEMPEST_DATA_DIR=/absolute/path/to/tempest/priv/tempest_dev TEMPEST_BLOB_MAX_BYTES=10000000 TEMPEST_HOSTED_DID_METHOD=plc TEMPEST_CRAWLERS=https://bsky.network,https://vsky.network +TEMPEST_ADMIN_DID=did:plc:... +TEMPEST_ADMIN_TOKEN_HASH="$argon2id$v=19$..." ``` Server boot creates `account.sqlite`, `sequencer.sqlite`, and local storage directories inside `TEMPEST_DATA_DIR`. +## Admin Configuration + +Browser admin access is anchored to `TEMPEST_ADMIN_DID`. + +When that DID belongs to a local Tempest account, `/admin/login` accepts the +account handle or DID plus the account password and stores only a server-side +admin session reference in the browser session. + +`TEMPEST_ADMIN_TOKEN_HASH` is optional and is reserved for bootstrap and +automation paths such as `/xrpc/_admin/status`. It should be stored as an Argon2 hash. + ## Development Tools Account operator UI: @@ -50,16 +63,23 @@ Account operator UI: /account/firehose ``` -Admin UI and status, using `Authorization: Bearer $ADMIN_TOKEN`: +Admin UI: ```text +/admin/login +/admin/logout /admin +/admin/accounts +/admin/accounts/:did +/admin/invites +/admin/repo +/admin/backups /admin/storage /admin/compatibility /xrpc/_admin/status ``` -Generate a TOTP code for a base32 secret: +To generate a TOTP code for a base32 secret: ```bash mix tempest.totp.code diff --git a/docs/tasks/18-account-management.md b/docs/tasks/18-account-management.md index 4e76c70..8c5bd38 100644 --- a/docs/tasks/18-account-management.md +++ b/docs/tasks/18-account-management.md @@ -49,37 +49,37 @@ External personal backups become an admin-only account-management feature. ## User Account Management -- [ ] T18-12: Convert the existing `/account` dashboard into a LiveView Control +- [x] T18-12: Convert the existing `/account` dashboard into a LiveView Control Panel page with identity, repository, blob, access, security, migration, sequencer, and firehose navigation. -- [ ] T18-13: Convert `/account/repo` to LiveView using existing repo-storage +- [x] T18-13: Convert `/account/repo` to LiveView using existing repo-storage context helpers for collections, recent records, latest commit, and CAR download links. -- [ ] T18-14: Convert `/account/blobs` to LiveView using existing blob context +- [x] T18-14: Convert `/account/blobs` to LiveView using existing blob context helpers for temp/public blob state and public download links. -- [ ] T18-15: Convert `/account/access` and `/account/security` to LiveView +- [x] T18-15: Convert `/account/access` and `/account/security` to LiveView inventory pages that never render token, app-password, OAuth, backup-code, or recovery secrets. -- [ ] T18-16: Convert `/account/migration`, `/account/sequencer`, and +- [x] T18-16: Convert `/account/migration`, `/account/sequencer`, and `/account/firehose` to LiveView pages with scoped account data. -- [ ] T18-17: Add account-management ConnCase or LiveView tests for login, +- [x] T18-17: Add account-management ConnCase or LiveView tests for login, logout, route auth, key element IDs, and redacted secret output. ## Admin Control Panel -- [ ] T18-18: Convert the existing `/admin` dashboard into a LiveView Control +- [x] T18-18: Convert the existing `/admin` dashboard into a LiveView Control Panel page for service status, hosted accounts, sequencer status, storage, and compatibility warnings. -- [ ] T18-19: Add `/admin/accounts` and `/admin/accounts/:did` for hosted +- [x] T18-19: Add `/admin/accounts` and `/admin/accounts/:did` for hosted account inspection, using admin auth only. -- [ ] T18-20: Convert `/admin/storage`, `/admin/repo`, `/admin/backups`, and +- [x] T18-20: Convert `/admin/storage`, `/admin/repo`, `/admin/backups`, and `/admin/compatibility` to LiveView or keep thin controller actions where file downloads/forms make that simpler. -- [ ] T18-21: Keep admin operations backed by context modules rather than +- [x] T18-21: Keep admin operations backed by context modules rather than calling Tempest's own XRPC HTTP endpoints internally. -- [ ] T18-22: Add confirmations and CSRF-protected forms for admin mutations +- [x] T18-22: Add confirmations and CSRF-protected forms for admin mutations such as repo import, backup create, restore dry-run, prune, and delete. -- [ ] T18-23: Add admin tests for local-admin login, external-admin OAuth login +- [x] T18-23: Add admin tests for local-admin login, external-admin OAuth login with a fixture auth server, bearer-token automation access, account-token rejection, route rendering, and mutation confirmation flows. diff --git a/lib/tempest/admin_auth.ex b/lib/tempest/admin_auth.ex index 347be49..3396dba 100644 --- a/lib/tempest/admin_auth.ex +++ b/lib/tempest/admin_auth.ex @@ -9,8 +9,11 @@ defmodule Tempest.AdminAuth do import Ecto.Query alias Tempest.Accounts.{Account, Password} + alias Tempest.Identity.SsrfProtection alias Tempest.{Accounts, Config, Identity, Repo} + @external_session_ttl_seconds 60 * 60 * 12 + @doc """ Hashes a plaintext admin token for configuration. """ @@ -84,7 +87,46 @@ defmodule Tempest.AdminAuth do end end + def create_external_oauth_browser_session(access_token) when is_binary(access_token) and access_token != "" do + with {:ok, admin_did} <- configured_did(), + {:ok, document} <- Identity.external_did_document_for_did(admin_did), + {:ok, pds_url} <- atproto_pds_url(document), + {:ok, introspection_endpoint} <- discover_introspection_endpoint(pds_url), + {:ok, %{"active" => true, "sub" => ^admin_did}} <- introspect_token(introspection_endpoint, access_token) do + create_external_session(admin_did) + else + {:error, reason} -> {:error, reason} + _other -> {:error, :invalid_oauth_token} + end + end + + def create_external_oauth_browser_session(_access_token), do: {:error, :missing_oauth_token} + def authenticate_browser_session(session_id, family_id, did) when is_binary(family_id) and is_binary(did) do + case authenticate_local_browser_session(session_id, family_id, did) do + {:ok, auth} -> {:ok, auth} + {:error, _reason} -> authenticate_external_browser_session(session_id, family_id, did) + end + end + + def authenticate_browser_session(_session_id, _family_id, _did), do: {:error, :invalid_admin_session} + + def revoke_browser_session(session_id, family_id, did) do + case authenticate_local_browser_session(session_id, family_id, did) do + {:ok, _auth} -> + Accounts.revoke_browser_session(session_id, family_id) + + {:error, _reason} -> + revoke_external_session(session_id, family_id, did) + end + end + + @doc """ + Returns true when an admin token hash is configured. + """ + def configured?, do: is_binary(configured_hash()) and configured_hash() != "" + + defp authenticate_local_browser_session(session_id, family_id, did) do with {:ok, admin_did} <- configured_did(), ^admin_did <- did, {:ok, auth} <- Accounts.authenticate_browser_session(session_id, family_id), @@ -96,20 +138,137 @@ defmodule Tempest.AdminAuth do end end - def authenticate_browser_session(_session_id, _family_id, _did), do: {:error, :invalid_admin_session} + defp authenticate_external_browser_session(session_id, family_id, did) + when is_binary(session_id) and is_binary(family_id) and is_binary(did) do + with {:ok, admin_did} <- configured_did(), + ^admin_did <- did, + {:ok, session} <- fetch_external_session(session_id, family_id, did) do + {:ok, %{did: admin_did, session: session, token_type: :admin_external_oauth_session}} + else + {:error, reason} -> {:error, reason} + _other -> {:error, :invalid_admin_session} + end + end - def revoke_browser_session(session_id, family_id, did) do - with {:ok, _auth} <- authenticate_browser_session(session_id, family_id, did) do - Accounts.revoke_browser_session(session_id, family_id) + defp authenticate_external_browser_session(_session_id, _family_id, _did), do: {:error, :invalid_admin_session} + + defp atproto_pds_url(%{"service" => services}) when is_list(services) do + services + |> Enum.find(fn + %{"id" => "#atproto_pds", "serviceEndpoint" => endpoint} when is_binary(endpoint) -> true + %{"type" => "AtprotoPersonalDataServer", "serviceEndpoint" => endpoint} when is_binary(endpoint) -> true + _service -> false + end) + |> case do + %{"serviceEndpoint" => endpoint} -> + endpoint = String.trim_trailing(endpoint, "/") + + with :ok <- SsrfProtection.validate_url(endpoint) do + {:ok, endpoint} + end + + _missing -> + {:error, :pds_not_found} + end + end + + defp atproto_pds_url(_document), do: {:error, :pds_not_found} + + defp discover_introspection_endpoint(pds_url) do + protected_resource_url = pds_url <> "/.well-known/oauth-protected-resource" + + with {:ok, %{"authorization_servers" => [authorization_server | _]}} <- get_json(protected_resource_url), + :ok <- SsrfProtection.validate_url(authorization_server), + {:ok, %{"introspection_endpoint" => introspection_endpoint}} <- + get_json(String.trim_trailing(authorization_server, "/") <> "/.well-known/oauth-authorization-server"), + :ok <- SsrfProtection.validate_url(introspection_endpoint) do + {:ok, introspection_endpoint} else - {:error, _reason} -> :ok + {:error, reason} -> {:error, reason} + _other -> {:error, :oauth_metadata_not_found} end end - @doc """ - Returns true when an admin token hash is configured. - """ - def configured?, do: is_binary(configured_hash()) and configured_hash() != "" + defp introspect_token(introspection_endpoint, access_token) do + opts = + [ + url: introspection_endpoint, + form: %{"client_id" => Config.load!().public_url <> "/admin/oauth-client", "token" => access_token}, + redirect: false, + retry: false, + receive_timeout: 2_000, + connect_options: [timeout: 1_000] + ] + |> Keyword.merge(oauth_req_options()) + + case Req.post(opts) do + {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body} + {:ok, %{status: 200, body: body}} when is_binary(body) -> Jason.decode(body) + {:ok, _response} -> {:error, :invalid_oauth_token} + {:error, _reason} -> {:error, :oauth_introspection_failed} + end + end + + defp get_json(url) do + opts = + [ + url: url, + redirect: false, + retry: false, + receive_timeout: 2_000, + connect_options: [timeout: 1_000] + ] + |> Keyword.merge(oauth_req_options()) + + case Req.get(opts) do + {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body} + {:ok, %{status: 200, body: body}} when is_binary(body) -> Jason.decode(body) + {:ok, _response} -> {:error, :oauth_metadata_not_found} + {:error, _reason} -> {:error, :oauth_metadata_not_found} + end + end + + defp create_external_session(did) do + table = external_session_table() + now = System.system_time(:second) + id = random_token(32) + family_id = Ecto.UUID.generate() + expires_at = now + @external_session_ttl_seconds + session = %{id: id, family_id: family_id, did: did, expires_at: expires_at} + + :ets.insert(table, {{id, family_id, did}, session}) + {:ok, %{did: did, session: session, family_id: family_id}} + end + + defp fetch_external_session(session_id, family_id, did) do + case :ets.lookup(external_session_table(), {session_id, family_id, did}) do + [{_key, %{expires_at: expires_at} = session}] -> + if expires_at > System.system_time(:second), do: {:ok, session}, else: {:error, :expired_admin_session} + + [] -> + {:error, :invalid_admin_session} + end + end + + defp revoke_external_session(session_id, family_id, did) do + :ets.delete(external_session_table(), {session_id, family_id, did}) + :ok + end + + defp external_session_table do + case :ets.whereis(__MODULE__.ExternalSessions) do + :undefined -> :ets.new(__MODULE__.ExternalSessions, [:named_table, :public, read_concurrency: true]) + table -> table + end + end + + defp oauth_req_options do + :tempest + |> Application.get_env(__MODULE__, []) + |> Keyword.get(:oauth_req_options, []) + end + + defp random_token(bytes), do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) defp local_admin_account?(did) do Account diff --git a/lib/tempest_web/controllers/admin_session_controller.ex b/lib/tempest_web/controllers/admin_session_controller.ex index fc0daae..dea5be1 100644 --- a/lib/tempest_web/controllers/admin_session_controller.ex +++ b/lib/tempest_web/controllers/admin_session_controller.ex @@ -13,9 +13,7 @@ defmodule TempestWeb.AdminSessionController do create_local_session(conn, admin_params, params) {:ok, %{method: :oauth}} -> - conn - |> put_status(:not_implemented) - |> render_login(params, "This admin DID is hosted externally and requires AT Protocol OAuth.") + create_external_oauth_session(conn, admin_params, params) {:error, reason} -> conn @@ -59,6 +57,26 @@ defmodule TempestWeb.AdminSessionController do end end + defp create_external_oauth_session(conn, admin_params, params) do + access_token = Map.get(admin_params, "access_token", "") + return_to = return_to(params) + + case AdminAuth.create_external_oauth_browser_session(access_token) do + {:ok, admin_session} -> + conn + |> renew_session() + |> put_session(:admin_session_id, admin_session.session.id) + |> put_session(:admin_session_family_id, admin_session.family_id) + |> put_session(:admin_did, admin_session.did) + |> redirect(to: safe_return_to(return_to, ~p"/admin")) + + {:error, reason} -> + conn + |> put_status(:unauthorized) + |> render_login(params, login_error(reason)) + end + end + defp render_login(conn, params, error) do auth_method = case AdminAuth.auth_method() do @@ -100,6 +118,11 @@ defmodule TempestWeb.AdminSessionController do defp login_error(:admin_did_not_found), do: "The configured admin DID could not be resolved." defp login_error(:not_admin_account), do: "This account is not the configured admin DID." defp login_error(:inactive_account), do: "This account is not active." + defp login_error(:missing_oauth_token), do: "OAuth access token is required for this external admin DID." + defp login_error(:invalid_oauth_token), do: "OAuth access token is invalid for this admin DID." + defp login_error(:pds_not_found), do: "The configured admin DID does not advertise an AT Protocol PDS." + defp login_error(:oauth_metadata_not_found), do: "Admin OAuth metadata could not be discovered." + defp login_error(:oauth_introspection_failed), do: "Admin OAuth token introspection failed." defp login_error(:rate_limited), do: "Too many attempts. Try again later." defp login_error(_reason), do: "The user name or password is incorrect." diff --git a/lib/tempest_web/controllers/admin_session_html/new.html.heex b/lib/tempest_web/controllers/admin_session_html/new.html.heex index 5efb1bc..073b59a 100644 --- a/lib/tempest_web/controllers/admin_session_html/new.html.heex +++ b/lib/tempest_web/controllers/admin_session_html/new.html.heex @@ -8,7 +8,12 @@