adminui: an admin interface, behind authentication without exception
Replaces the unauthenticated admin mux. Everything but /healthz requires a session, and that is the point: the previous arrangement relied on binding to loopback, which worked exactly until the address changed and then failed silently and publicly. A binding address is a deployment detail, not an access control, and this package does not treat it as one. Two ways in. OIDC through the confidential client, with state and PKCE - PKCE even here, because it costs one hash and closes code interception independently of the secret. And the break-glass password, throttled, for when the IdP is the thing that is broken. Signing in without the admin group is refused with the group named, because "you are not an admin" is a different problem from "your password is wrong" and the remedy is elsewhere. Sessions are MAC-checked cookies: HttpOnly, SameSite=Lax, Secure when TLS is on. CSRF tokens are derived from the session rather than stored, so there is no server-side table to keep in sync, and they are required on every state-changing POST - SameSite already blocks cross-site posts in current browsers, but this is the control that does not depend on the browser being current. Server-rendered with html/template and no JavaScript: the pages are lists and forms, and a framework would add a build step, a dependency tree and an update treadmill to a program that has none of those. The CSP is default-src 'none' accordingly. Pages: overview, devices (with revocation and enrolment-link minting), uploaded runs and a run viewer. Revocations and deletions are logged with who did them. Runs are shown exactly as uploaded, at the privacy level their uploader chose - nothing in the UI can un-redact one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7bb54e1ec8
commit
0eaba6150b
@@ -0,0 +1,346 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package adminui serves the operator's web interface.
|
||||
//
|
||||
// Everything here is behind authentication, without exception. The previous arrangement — an
|
||||
// unauthenticated listener kept safe by binding to loopback — worked exactly until the address
|
||||
// changed, and then failed silently and publicly. Binding address is a deployment detail; it is
|
||||
// not an access control, and this package does not treat it as one.
|
||||
//
|
||||
// Rendered server-side with html/template and no JavaScript. The pages are lists and forms; a
|
||||
// framework would add a build step, a dependency tree and an update treadmill to a program that
|
||||
// currently has none of those.
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/adminauth"
|
||||
"echo-lot.app/server/internal/oidc"
|
||||
"echo-lot.app/server/internal/runs"
|
||||
"echo-lot.app/server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "echolot_admin"
|
||||
stateCookie = "echolot_oidc"
|
||||
csrfField = "csrf"
|
||||
)
|
||||
|
||||
// Server is the admin interface.
|
||||
type Server struct {
|
||||
Store *store.Store
|
||||
Runs *runs.Store
|
||||
OIDC *oidc.Verifier // admin client; nil when no IdP is configured
|
||||
Sessions *adminauth.Sessions
|
||||
Throttle *adminauth.Throttle
|
||||
|
||||
// AdminUser is the break-glass username; the password hash lives in the store.
|
||||
AdminUser string
|
||||
// BaseURL is where this UI is reachable, for building the OIDC redirect. Must match the URI
|
||||
// registered at the IdP exactly.
|
||||
BaseURL string
|
||||
// ClientSecret authenticates the confidential admin client at the token endpoint.
|
||||
ClientSecret string
|
||||
// Secure marks cookies Secure. Off only for loopback HTTP, where there is no network to
|
||||
// intercept and browsers refuse Secure cookies over plaintext anyway.
|
||||
Secure bool
|
||||
|
||||
// EnrollLink builds the §2.1 bootstrap link for a token. Injected rather than rebuilt here,
|
||||
// so the SPKI pin and public URL stay owned by the control server that actually knows them.
|
||||
EnrollLink func(token string) string
|
||||
// SelfTest and Version render on the dashboard.
|
||||
SelfTest func() any
|
||||
Version string
|
||||
}
|
||||
|
||||
// Handler builds the routes. Only /healthz is reachable without a session.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Unauthenticated: a health check that required a session would be no use to a monitor, and
|
||||
// it discloses nothing beyond "the process is up".
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /login", s.loginForm)
|
||||
mux.HandleFunc("POST /login", s.loginSubmit)
|
||||
mux.HandleFunc("GET /auth/start", s.oidcStart)
|
||||
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
|
||||
mux.HandleFunc("POST /logout", s.logout)
|
||||
|
||||
mux.HandleFunc("GET /", s.guard(s.dashboard))
|
||||
mux.HandleFunc("GET /devices", s.guard(s.devices))
|
||||
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.revokeDevice))
|
||||
mux.HandleFunc("POST /enroll-tokens", s.guard(s.mintToken))
|
||||
mux.HandleFunc("GET /runs", s.guard(s.runsList))
|
||||
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
|
||||
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// guard requires a valid session, and checks CSRF on anything that changes state.
|
||||
func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Session)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.session(r)
|
||||
if sess == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
// SameSite=Lax already blocks cross-site form posts in current browsers, but this
|
||||
// is the control that does not depend on the browser being current.
|
||||
if !s.csrfOK(r, sess) {
|
||||
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
h(w, r, sess)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) session(r *http.Request) *adminauth.Session {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sess, err := s.Sessions.Parse(c.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
// csrfToken derives a per-session token. Derived rather than stored so it needs no server-side
|
||||
// state and cannot drift out of sync with the session it belongs to.
|
||||
func (s *Server) csrfToken(sess *adminauth.Session) string {
|
||||
sum := sha256.Sum256([]byte("csrf|" + sess.Subject + "|" + sess.Expires.String()))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func (s *Server) csrfOK(r *http.Request, sess *adminauth.Session) bool {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return false
|
||||
}
|
||||
return r.PostFormValue(csrfField) == s.csrfToken(sess)
|
||||
}
|
||||
|
||||
func (s *Server) setSession(w http.ResponseWriter, subject, display string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: s.Sessions.Issue(subject, display),
|
||||
Path: "/",
|
||||
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
|
||||
Secure: s.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---- local password ---------------------------------------------------------------------
|
||||
|
||||
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// The delay is applied before the answer, so a wrong guess costs time whether or not the
|
||||
// username exists — the timing carries no information either way.
|
||||
if d := s.Throttle.Delay(); d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
user := r.PostFormValue("username")
|
||||
pass := r.PostFormValue("password")
|
||||
|
||||
cred := s.Store.LocalAdmin()
|
||||
if cred == nil || !cred.Verify(user, pass) {
|
||||
s.Throttle.Failed()
|
||||
slog.Info("admin login failed", "user", user, "from", clientIP(r))
|
||||
s.render(w, r, "login", map[string]any{
|
||||
"Error": "Incorrect username or password.",
|
||||
"OIDC": s.oidcAvailable(),
|
||||
})
|
||||
return
|
||||
}
|
||||
s.Throttle.Succeeded()
|
||||
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
|
||||
s.setSession(w, "local:"+cred.Username, cred.Username)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---- OIDC -------------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) oidcAvailable() bool {
|
||||
return s.OIDC != nil && s.OIDC.Config().Enabled() && s.BaseURL != ""
|
||||
}
|
||||
|
||||
// oidcStart redirects to the IdP with state and PKCE.
|
||||
//
|
||||
// PKCE even though this is a confidential client: it costs one hash and closes code interception
|
||||
// independently of the secret, which is worth having when the redirect crosses a browser.
|
||||
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.oidcAvailable() {
|
||||
http.Error(w, "no identity provider is configured on this server", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
d, err := s.OIDC.Discover(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "identity provider unreachable: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
state, verifier := randomToken(), randomToken()
|
||||
challenge := sha256.Sum256([]byte(verifier))
|
||||
|
||||
// state and the PKCE verifier ride in one short-lived cookie: the callback must prove it
|
||||
// belongs to the browser that started the flow, or an attacker can feed us their own code.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookie, Value: state + "." + verifier, Path: "/",
|
||||
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
|
||||
})
|
||||
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {s.OIDC.Config().ClientID},
|
||||
"redirect_uri": {s.redirectURI()},
|
||||
"scope": {"openid profile email"},
|
||||
"state": {state},
|
||||
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
|
||||
"code_challenge_method": {"S256"},
|
||||
}
|
||||
http.Redirect(w, r, d.AuthorizationEndpoint+"?"+q.Encode(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) redirectURI() string {
|
||||
return strings.TrimRight(s.BaseURL, "/") + "/admin/callback"
|
||||
}
|
||||
|
||||
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.oidcAvailable() {
|
||||
http.Error(w, "no identity provider configured", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(stateCookie)
|
||||
if err != nil {
|
||||
http.Error(w, "sign-in did not start here — try again from the login page", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
|
||||
|
||||
state, verifier, ok := strings.Cut(c.Value, ".")
|
||||
if !ok || state == "" || r.URL.Query().Get("state") != state {
|
||||
http.Error(w, "sign-in state did not match — start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "no authorization code returned: "+r.URL.Query().Get("error"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := s.exchange(r.Context(), code, verifier)
|
||||
if err != nil {
|
||||
slog.Info("admin oidc exchange failed", "err", err, "from", clientIP(r))
|
||||
http.Error(w, "could not complete sign-in", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
claims, err := s.OIDC.Verify(r.Context(), idToken)
|
||||
if err != nil {
|
||||
slog.Info("admin oidc token rejected", "err", err, "from", clientIP(r))
|
||||
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !s.OIDC.IsAdmin(claims) {
|
||||
// Named explicitly: "you signed in but you are not an admin" is a different problem from
|
||||
// "your password is wrong", and the group is the thing to go and check.
|
||||
slog.Info("admin access denied: not in group", "account", claims.AccountID(),
|
||||
"want_group", s.OIDC.Config().AdminGroup, "have", claims.Groups)
|
||||
http.Error(w, fmt.Sprintf(
|
||||
"Signed in as %s, but that account is not in the %q group, so it cannot administer "+
|
||||
"this server.", claims.Display(), s.OIDC.Config().AdminGroup), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
slog.Info("admin login", "account", claims.AccountID(), "method", "oidc", "from", clientIP(r))
|
||||
s.setSession(w, claims.AccountID(), claims.Display())
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// exchange trades the authorization code for tokens at the IdP.
|
||||
func (s *Server) exchange(ctx context.Context, code, verifier string) (string, error) {
|
||||
d, err := s.OIDC.Discover(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {s.redirectURI()},
|
||||
"client_id": {s.OIDC.Config().ClientID},
|
||||
"code_verifier": {verifier},
|
||||
}
|
||||
if s.ClientSecret != "" {
|
||||
form.Set("client_secret", s.ClientSecret)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var tok struct {
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tok); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tok.IDToken == "" {
|
||||
return "", fmt.Errorf("token endpoint returned no id_token")
|
||||
}
|
||||
return tok.IDToken, nil
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// clientIP is for logs only. X-Forwarded-For is deliberately ignored: nothing is meant to sit in
|
||||
// front of this listener, so a header claiming otherwise is a caller's assertion about itself.
|
||||
func clientIP(r *http.Request) string {
|
||||
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
|
||||
return r.RemoteAddr[:i]
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
Reference in New Issue
Block a user