Files
at-container-registry/pkg/hold/admin/auth.go
T

165 lines
5.2 KiB
Go

package admin
import (
"context"
"log/slog"
"net/http"
"strings"
)
// requireOwner middleware ensures the request is from the hold owner.
// Enforces three layers:
// 1. Session token exists and has not expired (24h absolute TTL).
// 2. Session's DID still matches captain.Owner in the PDS — if ownership
// changes mid-session, the old session is torn down on the next hit.
// 3. Browser User-Agent and network prefix match what was captured at
// login. A mismatch means the cookie is being replayed from a
// different browser or network and the session is invalidated.
func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get session cookie
token, ok := getSessionCookie(r)
if !ok {
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
return
}
// Validate session
session := ui.getSession(token)
if session == nil {
clearSessionCookie(w)
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
return
}
// Double-check DID still matches captain.Owner
_, captain, err := ui.pds.GetCaptainRecord(r.Context())
if err != nil {
slog.Error("Failed to get captain record for admin auth", "error", err)
http.Error(w, "Failed to verify ownership", http.StatusInternalServerError)
return
}
if session.DID != captain.Owner {
slog.Warn("Admin session DID doesn't match captain owner",
"sessionDID", session.DID,
"captainOwner", captain.Owner)
ui.deleteSession(token)
clearSessionCookie(w)
http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden)
return
}
// User-Agent / IP-prefix binding. Empty bound values (e.g. from
// clients with no UA, requests over Unix sockets) are not
// compared — binding opts out rather than locking the user out.
if session.UserAgent != "" && session.UserAgent != r.UserAgent() {
slog.Warn("Admin session User-Agent mismatch — suspected token replay",
"did", session.DID)
ui.deleteSession(token)
clearSessionCookie(w)
http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
return
}
if session.IPPrefix != "" {
if now := clientIPPrefix(r); now != "" && now != session.IPPrefix {
slog.Warn("Admin session IP prefix mismatch — suspected token replay",
"did", session.DID,
"sessionPrefix", session.IPPrefix,
"requestPrefix", now)
ui.deleteSession(token)
clearSessionCookie(w)
http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
return
}
}
// Add session to context for handlers
ctx := context.WithValue(r.Context(), adminContextKey{}, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// getSessionFromContext retrieves the admin session from context
func getSessionFromContext(ctx context.Context) *AdminSession {
session, ok := ctx.Value(adminContextKey{}).(*AdminSession)
if !ok {
return nil
}
return session
}
// sessionDIDFromContext returns the authenticated DID for log annotation,
// empty if there is no session (e.g. an early-rejected request).
func sessionDIDFromContext(ctx context.Context) string {
if s := getSessionFromContext(ctx); s != nil {
return s.DID
}
return ""
}
// PageData contains common data for all admin pages
type PageData struct {
Title string
ActivePage string
User *AdminSession
HoldDID string
Flash *Flash
// CSRFToken is the per-session token that forms and htmx must echo back
// on state-mutating requests. Threaded to <body hx-headers=...> at the
// layout level; plain forms emit it via {{ csrfInput .CSRFToken }}.
CSRFToken string
}
// Flash represents a flash message
type Flash struct {
Category string // "success", "error", "warning", "info"
Message string
}
// newPageData creates PageData with common values
func (ui *AdminUI) newPageData(r *http.Request, title, activePage string) PageData {
session := getSessionFromContext(r.Context())
flash := getFlash(r, ui)
var csrf string
if session != nil {
csrf = session.CSRFToken
}
return PageData{
Title: title,
ActivePage: activePage,
User: session,
HoldDID: ui.pds.DID(),
Flash: flash,
CSRFToken: csrf,
}
}
// renderTemplate renders a template with the given data.
// Layout pages (pages/* except login and error) are rendered via "admin-layout"
// which uses {{block}} overrides. Partials and standalone pages execute directly.
func (ui *AdminUI) renderTemplate(w http.ResponseWriter, name string, data any) {
tmpl, ok := ui.templates[name]
if !ok {
slog.Error("Template not found", "template", name)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Pages that use the shared layout are rendered via the layout entry point.
// login.html and error.html are standalone (different layout).
execName := name
if strings.HasPrefix(name, "pages/") && name != "pages/login.html" && name != "pages/error.html" {
execName = "admin-layout"
}
if err := tmpl.ExecuteTemplate(w, execName, data); err != nil {
slog.Error("Failed to render template", "template", name, "exec", execName, "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}