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.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// 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"
|
||||
}
|
||||
Reference in New Issue
Block a user