Files
diskless-workstation/serving/fleet-manager/session.go
T
Audiobuzz c40dd5ac93 fleet-manager: checkpoint 5 — admin WebAuthn/passkey auth (replaces browser mTLS)
The human->UI hop now authenticates with a hardware passkey (YubiKey), not an
mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker
stays mTLS — service-to-service.)

- webauthn.go: credential store (registered admins + passkeys + a one-time-token
  jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup.
- cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed
  enrolment link over a shared enrol key; the browser completes the WebAuthn
  create ceremony. No anyone-can-register endpoint; single-use via the ledger.
- authapi.go: register/login ceremonies (usernameless discoverable login,
  userVerification required) + logout + whoami; short-lived in-memory ceremony
  store between begin/finish.
- session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie)
  + requireSession middleware gating /api/*, with a CSRF header check on mutations.
  adminName now comes from the session (audit attribution).
- main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH
  (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt
  DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org.
  /api/* mounted behind requireSession; /auth/* + /healthz public.

Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE
cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01
can't validate; DNS-01 proves control of bitasylum.org via TXT + supports
wildcards). External ACME client issues+renews to the cert path; manager reloads.

Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token
mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store
persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony
(virtual authenticator / real YubiKey) is a bench item.
2026-07-04 04:26:30 +00:00

150 lines
4.1 KiB
Go

// session.go — post-passkey sessions + the middleware that gates the admin API.
//
// WebAuthn authenticates a login CEREMONY; from there we carry an opaque,
// server-side session (revocable, no secrets in the cookie). Cookie is
// Secure + HttpOnly + SameSite=Strict; state-changing requests additionally must
// echo the session's CSRF token in a header (belt-and-braces beyond same-origin).
package main
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"sync"
"time"
)
const (
sessionCookie = "fleet_session"
csrfHeader = "X-Fleet-CSRF"
)
type ctxKey int
const adminCtxKey ctxKey = 0
type session struct {
admin string
csrf string
expires time.Time
}
type sessionStore struct {
mu sync.Mutex
ttl time.Duration
byID map[string]session
}
func newSessionStore(ttl time.Duration) *sessionStore {
return &sessionStore{ttl: ttl, byID: map[string]session{}}
}
func randToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// create mints a session for a just-authenticated admin, returning the session id
// (cookie value) and the CSRF token (returned to the SPA in the login response).
func (s *sessionStore) create(admin string) (id, csrf string) {
id, csrf = randToken(), randToken()
s.mu.Lock()
s.byID[id] = session{admin: admin, csrf: csrf, expires: time.Now().Add(s.ttl)}
s.mu.Unlock()
return id, csrf
}
func (s *sessionStore) get(id string) (session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.byID[id]
if !ok {
return session{}, false
}
if time.Now().After(sess.expires) {
delete(s.byID, id)
return session{}, false
}
return sess, true
}
func (s *sessionStore) delete(id string) {
s.mu.Lock()
delete(s.byID, id)
s.mu.Unlock()
}
// requireSession wraps the admin API. It rejects requests without a valid session
// cookie, and (for mutating methods) without the matching CSRF header. The
// authenticated admin name is injected into the request context for audit.
func (s *server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Insecure dev mode has no passkey auth — open, and adminName reports the
// dev sentinel. (main refuses insecure unless explicitly enabled.)
if s.cfg.adminInsecure {
next.ServeHTTP(w, r)
return
}
c, err := r.Cookie(sessionCookie)
if err != nil {
writeErr(w, http.StatusUnauthorized, "not signed in")
return
}
sess, ok := s.sessions.get(c.Value)
if !ok {
writeErr(w, http.StatusUnauthorized, "session expired")
return
}
if isMutating(r.Method) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get(csrfHeader)), []byte(sess.csrf)) != 1 {
writeErr(w, http.StatusForbidden, "missing or bad CSRF token")
return
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), adminCtxKey, sess)))
})
}
// sessionFrom returns the authenticated session injected by requireSession.
func sessionFrom(r *http.Request) (session, bool) {
sess, ok := r.Context().Value(adminCtxKey).(session)
return sess, ok
}
func isMutating(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
}
return false
}
// setSessionCookie writes the session cookie. Secure+HttpOnly+SameSite=Strict.
func setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: id,
Path: "/",
MaxAge: int(ttl.Seconds()),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode})
}
// adminName returns the authenticated admin for audit attribution — the session
// identity in secure mode, or the dev sentinel in insecure mode.
func adminName(r *http.Request) string {
if sess, ok := sessionFrom(r); ok && sess.admin != "" {
return sess.admin
}
return "dev-insecure"
}