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.
178 lines
5.8 KiB
Go
178 lines
5.8 KiB
Go
// authapi.go — the WebAuthn ceremony endpoints (unauthenticated: they ARE the
|
|
// gate) + logout + whoami. register/* is authorised by the one-time enrol token
|
|
// from the CLI; login/* is usernameless (discoverable passkey). A successful
|
|
// login mints the server-side session (session.go).
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
)
|
|
|
|
// ceremony holds the in-flight WebAuthn SessionData between begin and finish,
|
|
// plus the enrol claims for a registration. Short-lived, in-memory.
|
|
type ceremony struct {
|
|
sess *webauthn.SessionData
|
|
name string
|
|
jti string
|
|
exp int64
|
|
expires time.Time
|
|
}
|
|
|
|
type ceremonyStore struct {
|
|
mu sync.Mutex
|
|
m map[string]ceremony
|
|
}
|
|
|
|
func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} }
|
|
|
|
func (c *ceremonyStore) put(sess *webauthn.SessionData, name, jti string, exp int64) string {
|
|
id := randToken()
|
|
c.mu.Lock()
|
|
now := time.Now()
|
|
for k, v := range c.m {
|
|
if now.After(v.expires) {
|
|
delete(c.m, k)
|
|
}
|
|
}
|
|
c.m[id] = ceremony{sess: sess, name: name, jti: jti, exp: exp, expires: now.Add(5 * time.Minute)}
|
|
c.mu.Unlock()
|
|
return id
|
|
}
|
|
|
|
func (c *ceremonyStore) take(id string) (ceremony, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
cer, ok := c.m[id]
|
|
if ok {
|
|
delete(c.m, id)
|
|
}
|
|
if ok && time.Now().After(cer.expires) {
|
|
return ceremony{}, false
|
|
}
|
|
return cer, ok
|
|
}
|
|
|
|
func (s *server) authRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte("ok\n"))
|
|
})
|
|
if s.wa == nil {
|
|
return // insecure/dev mode has no passkey ceremonies
|
|
}
|
|
mux.HandleFunc("POST /auth/register/begin", s.handleRegisterBegin)
|
|
mux.HandleFunc("POST /auth/register/finish", s.handleRegisterFinish)
|
|
mux.HandleFunc("POST /auth/login/begin", s.handleLoginBegin)
|
|
mux.HandleFunc("POST /auth/login/finish", s.handleLoginFinish)
|
|
mux.HandleFunc("POST /auth/logout", s.handleLogout)
|
|
}
|
|
|
|
// register/begin — authorised by the one-time enrol token (?token=).
|
|
func (s *server) handleRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
|
claims, err := verifyEnrollToken(s.enrollKey, r.URL.Query().Get("token"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "invalid enrolment token: "+err.Error())
|
|
return
|
|
}
|
|
if s.creds.tokenUsed(claims.JTI) {
|
|
writeErr(w, http.StatusConflict, "enrolment token already used")
|
|
return
|
|
}
|
|
user := s.creds.getOrCreate(claims.Name)
|
|
sel := protocol.AuthenticatorSelection{
|
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
|
RequireResidentKey: protocol.ResidentKeyRequired(),
|
|
UserVerification: protocol.VerificationRequired,
|
|
}
|
|
options, sessionData, err := s.wa.BeginRegistration(user, webauthn.WithAuthenticatorSelection(sel))
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "begin registration: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, claims.Name, claims.JTI, claims.Exp), "options": options})
|
|
}
|
|
|
|
func (s *server) handleRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
|
cer, ok := s.ceremonies.take(r.URL.Query().Get("cid"))
|
|
if !ok || cer.sess == nil {
|
|
writeErr(w, http.StatusBadRequest, "unknown or expired ceremony")
|
|
return
|
|
}
|
|
if s.creds.tokenUsed(cer.jti) {
|
|
writeErr(w, http.StatusConflict, "enrolment token already used")
|
|
return
|
|
}
|
|
user := s.creds.getOrCreate(cer.name)
|
|
cred, err := s.wa.FinishRegistration(user, *cer.sess, r)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "registration failed: "+err.Error())
|
|
return
|
|
}
|
|
if err := s.creds.addCredential(cer.name, *cred); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
s.creds.markTokenUsed(cer.jti, cer.exp)
|
|
log.Printf("audit ADMIN-ENROL name=%q (passkey registered)", cer.name)
|
|
writeJSON(w, http.StatusOK, map[string]any{"admin": cer.name, "registered": true})
|
|
}
|
|
|
|
// login/begin — usernameless discoverable login.
|
|
func (s *server) handleLoginBegin(w http.ResponseWriter, r *http.Request) {
|
|
options, sessionData, err := s.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "begin login: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, "", "", 0), "options": options})
|
|
}
|
|
|
|
func (s *server) handleLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|
cer, ok := s.ceremonies.take(r.URL.Query().Get("cid"))
|
|
if !ok || cer.sess == nil {
|
|
writeErr(w, http.StatusBadRequest, "unknown or expired ceremony")
|
|
return
|
|
}
|
|
var matched *adminUser
|
|
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
|
|
u, ok := s.creds.byHandle(userHandle)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown passkey")
|
|
}
|
|
matched = u
|
|
return u, nil
|
|
}
|
|
cred, err := s.wa.FinishDiscoverableLogin(handler, *cer.sess, r)
|
|
if err != nil || matched == nil {
|
|
writeErr(w, http.StatusUnauthorized, "login failed")
|
|
return
|
|
}
|
|
s.creds.updateCredential(matched.IDb, *cred) // persist the signature counter
|
|
id, csrf := s.sessions.create(matched.Name)
|
|
setSessionCookie(w, id, s.cfg.sessionTTL)
|
|
log.Printf("audit ADMIN-LOGIN name=%q", matched.Name)
|
|
writeJSON(w, http.StatusOK, map[string]any{"admin": matched.Name, "csrf": csrf})
|
|
}
|
|
|
|
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
s.sessions.delete(c.Value)
|
|
}
|
|
clearSessionCookie(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleWhoami is session-gated (registered under /api): lets the SPA resume an
|
|
// existing session on reload and recover the CSRF token.
|
|
func (s *server) handleWhoami(w http.ResponseWriter, r *http.Request) {
|
|
sess, _ := sessionFrom(r)
|
|
writeJSON(w, http.StatusOK, map[string]any{"admin": adminName(r), "csrf": sess.csrf})
|
|
}
|