Files
at-container-registry/pkg/hold/admin/handlers_auth.go
T
Evan Jarrett e3843db9d8 Implement did:plc support for holds with the ability to import/export CARs.
did:plc Identity Support (pkg/hold/pds/did.go, pkg/hold/config.go, pkg/hold/server.go)

  The big feature — holds can now use did:plc identities instead of only did:web. This adds:
  - LoadOrCreateDID() — resolves hold DID by priority: config DID > did.txt on disk > create new
  - CreatePLCIdentity() — builds a genesis operation, signs with rotation key, submits to PLC directory
  - EnsurePLCCurrent() — on boot, compares local signing key + URL against PLC directory and auto-updates if they've drifted (requires rotation key)
  - New config fields: did_method (web/plc), did, plc_directory_url, rotation_key_path
  - GenerateDIDDocument() now uses the stored DID instead of always deriving did:web from URL
  - NewHoldServer wired up to call LoadOrCreateDID instead of GenerateDIDFromURL

  CAR Export/Import (pkg/hold/pds/export.go, pkg/hold/pds/import.go, cmd/hold/repo.go)

  New CLI subcommands for repo backup/restore:
  - atcr-hold repo export — streams the hold's repo as a CAR file to stdout
  - atcr-hold repo import <file>... — reads CAR files, upserts all records in a single atomic commit. Uses a bulkImportRecords method that opens a delta session, checks each record for
  create vs update, commits once, and fires repo events.
  - openHoldPDS() helper to spin up a HoldPDS from config for offline CLI operations

  Admin UI Fixes (pkg/hold/admin/)

  - Logout changed from GET to POST — nav template now uses a <form method=POST> instead of an <a> link (prevents CSRF on logout)
  - Removed return_to parameter from login flow — simplified redirect logic, auth middleware now redirects to /admin/auth/login without query params

  Config/Deploy

  - config-hold.example.yaml and deploy/upcloud/configs/hold.yaml.tmpl updated with the four new did:plc config fields
  - go.mod / go.sum — added github.com/did-method-plc/go-didplc dependency
2026-02-14 15:17:53 -06:00

134 lines
3.8 KiB
Go

package admin
import (
"log/slog"
"net/http"
"strings"
"atcr.io/pkg/atproto"
)
// handleLogin renders the login page
func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to dashboard
if token, ok := getSessionCookie(r); ok {
if session := ui.getSession(token); session != nil {
// Verify still owner
if _, captain, err := ui.pds.GetCaptainRecord(r.Context()); err == nil && session.DID == captain.Owner {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
}
}
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "Login",
ActivePage: "login",
HoldDID: ui.pds.DID(),
},
Error: r.URL.Query().Get("error"),
}
ui.renderTemplate(w, "pages/login.html", data)
}
// handleAuthorize starts the OAuth flow
func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) {
handle := strings.TrimSpace(r.URL.Query().Get("handle"))
if handle == "" {
http.Redirect(w, r, "/admin/auth/login?error=Handle+is+required", http.StatusFound)
return
}
// Normalize handle
handle = strings.TrimPrefix(handle, "@")
// Resolve handle to DID
did, _, _, err := atproto.ResolveIdentity(r.Context(), handle)
if err != nil {
slog.Warn("Failed to resolve handle for admin login", "handle", handle, "error", err)
http.Redirect(w, r, "/admin/auth/login?error=Could+not+resolve+handle", http.StatusFound)
return
}
slog.Info("Starting admin OAuth flow", "handle", handle, "did", did)
// Start OAuth flow
authURL, err := ui.clientApp.StartAuthFlow(r.Context(), did)
if err != nil {
slog.Error("Failed to start OAuth flow", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=OAuth+initialization+failed", http.StatusFound)
return
}
http.Redirect(w, r, authURL, http.StatusFound)
}
// handleCallback processes the OAuth callback
func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Process OAuth callback
sessionData, err := ui.clientApp.ProcessCallback(ctx, r.URL.Query())
if err != nil {
slog.Error("OAuth callback failed", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=OAuth+authentication+failed", http.StatusFound)
return
}
did := sessionData.AccountDID.String()
// Resolve handle from DID
_, handle, _, err := atproto.ResolveIdentity(ctx, did)
if err != nil {
slog.Warn("Failed to resolve handle from DID", "did", did, "error", err)
handle = did // Fallback to DID
}
slog.Info("OAuth callback successful", "did", did, "handle", handle)
// Get captain record to check owner
_, captain, err := ui.pds.GetCaptainRecord(ctx)
if err != nil {
slog.Error("Failed to get captain record during OAuth callback", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=Failed+to+verify+ownership", http.StatusFound)
return
}
// CRITICAL: Only allow the hold owner
if did != captain.Owner {
slog.Warn("Non-owner attempted admin access",
"did", did,
"handle", handle,
"owner", captain.Owner)
http.Redirect(w, r, "/admin/auth/login?error=Access+denied:+Only+the+hold+owner+can+access+the+admin+panel", http.StatusFound)
return
}
// Create session and set cookie
token, err := ui.createSession(did, handle)
if err != nil {
slog.Error("failed to create session token", "error", err, "path", r.URL.Path)
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
ui.setSessionCookie(w, r, token)
slog.Info("Admin login successful", "did", did, "handle", handle)
http.Redirect(w, r, "/admin", http.StatusFound)
}
// handleLogout clears the session and redirects to login
func (ui *AdminUI) handleLogout(w http.ResponseWriter, r *http.Request) {
if token, ok := getSessionCookie(r); ok {
ui.deleteSession(token)
}
clearSessionCookie(w)
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
}