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 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) } }