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.
132 lines
3.7 KiB
Go
132 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
)
|
|
|
|
func TestEnrollTokenRoundTrip(t *testing.T) {
|
|
key := []byte("0123456789abcdef0123456789abcdef")
|
|
tok, err := mintEnrollToken(key, "alice", 10*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("mint: %v", err)
|
|
}
|
|
claims, err := verifyEnrollToken(key, tok)
|
|
if err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
if claims.Name != "alice" || claims.JTI == "" {
|
|
t.Fatalf("claims = %+v", claims)
|
|
}
|
|
|
|
// wrong key rejected
|
|
if _, err := verifyEnrollToken([]byte("wrongwrongwrongwrongwrongwrongwr"), tok); err == nil {
|
|
t.Error("expected wrong-key rejection")
|
|
}
|
|
// tampered signature rejected
|
|
if _, err := verifyEnrollToken(key, tok[:len(tok)-2]+"xx"); err == nil {
|
|
t.Error("expected tamper rejection")
|
|
}
|
|
// expired rejected
|
|
exp, _ := mintEnrollToken(key, "bob", -time.Minute)
|
|
if _, err := verifyEnrollToken(key, exp); err == nil {
|
|
t.Error("expected expiry rejection")
|
|
}
|
|
}
|
|
|
|
func TestSessionMiddleware(t *testing.T) {
|
|
s := &server{cfg: config{}, sessions: newSessionStore(time.Hour)} // adminInsecure=false
|
|
id, csrf := s.sessions.create("alice")
|
|
|
|
var reached string
|
|
guarded := s.requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
reached = adminName(r)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// no cookie -> 401
|
|
rw := httptest.NewRecorder()
|
|
guarded.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/hosts", nil))
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("no cookie: got %d want 401", rw.Code)
|
|
}
|
|
|
|
withCookie := func(method string, csrfHdr string) *http.Request {
|
|
r := httptest.NewRequest(method, "/api/x", nil)
|
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: id})
|
|
if csrfHdr != "" {
|
|
r.Header.Set(csrfHeader, csrfHdr)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// valid GET -> reaches handler as alice
|
|
reached = ""
|
|
rw = httptest.NewRecorder()
|
|
guarded.ServeHTTP(rw, withCookie(http.MethodGet, ""))
|
|
if rw.Code != http.StatusOK || reached != "alice" {
|
|
t.Fatalf("valid GET: code=%d admin=%q", rw.Code, reached)
|
|
}
|
|
|
|
// mutating POST without CSRF -> 403
|
|
rw = httptest.NewRecorder()
|
|
guarded.ServeHTTP(rw, withCookie(http.MethodPost, ""))
|
|
if rw.Code != http.StatusForbidden {
|
|
t.Fatalf("POST no CSRF: got %d want 403", rw.Code)
|
|
}
|
|
|
|
// mutating POST with correct CSRF -> reaches handler
|
|
reached = ""
|
|
rw = httptest.NewRecorder()
|
|
guarded.ServeHTTP(rw, withCookie(http.MethodPost, csrf))
|
|
if rw.Code != http.StatusOK || reached != "alice" {
|
|
t.Fatalf("POST with CSRF: code=%d admin=%q", rw.Code, reached)
|
|
}
|
|
|
|
// unknown cookie -> 401
|
|
rw = httptest.NewRecorder()
|
|
bad := httptest.NewRequest(http.MethodGet, "/api/x", nil)
|
|
bad.AddCookie(&http.Cookie{Name: sessionCookie, Value: "nope"})
|
|
guarded.ServeHTTP(rw, bad)
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("bad cookie: got %d want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestCredStorePersistence(t *testing.T) {
|
|
path := t.TempDir() + "/admins.json"
|
|
s, err := newCredStore(path)
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
u := s.getOrCreate("alice")
|
|
handle := u.IDb
|
|
if err := s.addCredential("alice", webauthn.Credential{ID: []byte("cred-1"), PublicKey: []byte("pk")}); err != nil {
|
|
t.Fatalf("add: %v", err)
|
|
}
|
|
s.markTokenUsed("jti-1", time.Now().Add(time.Hour).Unix())
|
|
|
|
// reload from disk
|
|
s2, err := newCredStore(path)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if s2.count() != 1 {
|
|
t.Fatalf("admins after reload = %d", s2.count())
|
|
}
|
|
got, ok := s2.byHandle(handle)
|
|
if !ok || got.Name != "alice" || len(got.Creds) != 1 {
|
|
t.Fatalf("byHandle after reload: ok=%v %+v", ok, got)
|
|
}
|
|
if !s2.tokenUsed("jti-1") {
|
|
t.Error("used-token ledger did not survive reload")
|
|
}
|
|
if s2.tokenUsed("jti-unknown") {
|
|
t.Error("unknown token reported used")
|
|
}
|
|
}
|