0358efe95b
- SQLite busy_timeout(5000) + SetMaxOpenConns(1) on all 4 DSNs - secrets file flock (concurrent set on same ns no longer loses data) - upgrade lock file (refuse concurrent orca upgrade) - backup lock file (refuse concurrent backup) - cache invalidation by writes (read-after-write consistency) - Executor.Run mutex scope fix (hold only for DB inserts) - ns create/inherit/set-constraint atomic writeNSMdAtomic - writeCurrentLead + rotateSSHKeys atomic - consolidate 3 writeAtomic impls onto security.WriteAtomic - WebAuthn session stores guarded with sync.Mutex Tests: concurrent secrets set, upgrade lock rejection, cache read-after-write, WebAuthn session thread-safety (pass under -race). ---ci--- project: orca phase: 7 milestone: v0.13 status: complete requirements: covered: [156] ---/ci---
417 lines
14 KiB
Go
417 lines
14 KiB
Go
// Package webauthn: connector.go implements the WebAuthn ceremony
|
|
// handler for the bundled Dex (REQ-148, D-240, C-38). It serves
|
|
// registration + login endpoints at /orca/webauthn/{register,login}
|
|
// behind Traefik (R-017, step-ca cert, HTTPS secure context).
|
|
//
|
|
// The connector uses github.com/go-webauthn/webauthn for the
|
|
// cryptographic ceremony logic. Credential storage is in store.go
|
|
// (SQLite, 0600, public keys only).
|
|
package webauthn
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
)
|
|
|
|
// Connector is the WebAuthn ceremony handler. It is mounted behind
|
|
// Traefik and called by the bundled Dex.
|
|
type Connector struct {
|
|
w *webauthn.WebAuthn
|
|
store *Store
|
|
rpID string
|
|
origin string
|
|
|
|
// authFunc validates an authenticated session for registration
|
|
// (P04, T9, C-45). When non-nil, BeginRegistration and
|
|
// FinishRegistration require a valid session (cookie or bearer
|
|
// token) before proceeding; a nil/error result yields 401. When
|
|
// nil (fail-closed for new deployments), registration is rejected
|
|
// with 401 — the operator MUST wire an authFunc before enabling
|
|
// registration. This fixes C-45's unauthenticated-registration
|
|
// hole: previously anyone could register a credential for any
|
|
// username.
|
|
authFunc func(r *http.Request) (authenticated bool, existingUser string, err error)
|
|
}
|
|
|
|
// NewConnector builds a WebAuthn connector with the given RP ID
|
|
// (the cluster's Traefik-served domain, C-38) and origin (the full
|
|
// HTTPS URL).
|
|
func NewConnector(store *Store, rpID, rpOrigin string) (*Connector, error) {
|
|
return NewConnectorWithAuth(store, rpID, rpOrigin, nil)
|
|
}
|
|
|
|
// NewConnectorWithAuth builds a Connector with an explicit auth
|
|
// function for registration (P04, T9). The authFunc returns whether
|
|
// the request carries a valid authenticated session and, optionally,
|
|
// the existing user identity (so registration can be scoped to the
|
|
// authenticated user). When authFunc is nil, registration is fail-
|
|
// closed (401).
|
|
func NewConnectorWithAuth(store *Store, rpID, rpOrigin string, authFunc func(r *http.Request) (bool, string, error)) (*Connector, error) {
|
|
wconfig := &webauthn.Config{
|
|
RPDisplayName: "Orca",
|
|
RPID: rpID,
|
|
RPOrigins: []string{rpOrigin},
|
|
}
|
|
w, err := webauthn.New(wconfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webauthn: new: %w", err)
|
|
}
|
|
return &Connector{
|
|
w: w,
|
|
store: store,
|
|
rpID: rpID,
|
|
origin: rpOrigin,
|
|
authFunc: authFunc,
|
|
}, nil
|
|
}
|
|
|
|
// RegistrationSession holds the in-flight registration challenge.
|
|
type RegistrationSession struct {
|
|
UserID string
|
|
Challenge *webauthn.SessionData
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// sessionStore holds in-flight sessions (registration). In production
|
|
// this would be a Redis/shared cache; for the bundled single-lead
|
|
// Dex, an in-memory map with TTL is sufficient.
|
|
//
|
|
// REQ-156 / P07 T10: the session maps are accessed from HTTP handler
|
|
// goroutines (one goroutine per request) and were previously plain
|
|
// maps with no synchronization. Concurrent BeginRegistration calls
|
|
// for the same username would race on map writes (detected by go
|
|
// test -race in T11). A sync.Mutex now guards all access.
|
|
type sessionStore struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*RegistrationSession
|
|
}
|
|
|
|
// regSessions is the global in-flight registration session store.
|
|
var regSessions = &sessionStore{sessions: make(map[string]*RegistrationSession)}
|
|
|
|
// loginSessionStore holds in-flight login sessions (T10). Same
|
|
// mutex pattern as sessionStore.
|
|
type loginSessionStore struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*LoginSession
|
|
}
|
|
|
|
// loginSessions is the global in-flight login session store.
|
|
var loginSessions = &loginSessionStore{sessions: make(map[string]*LoginSession)}
|
|
|
|
// sessionTTL is the max time a registration/login session is valid.
|
|
const sessionTTL = 5 * time.Minute
|
|
|
|
// cleanSessions removes expired registration + login sessions.
|
|
// Called under each store's lock by the Begin* handlers.
|
|
func cleanSessions() {
|
|
now := time.Now()
|
|
regSessions.mu.Lock()
|
|
for id, sess := range regSessions.sessions {
|
|
if now.Sub(sess.CreatedAt) > sessionTTL {
|
|
delete(regSessions.sessions, id)
|
|
}
|
|
}
|
|
regSessions.mu.Unlock()
|
|
loginSessions.mu.Lock()
|
|
for id, sess := range loginSessions.sessions {
|
|
if now.Sub(sess.CreatedAt) > sessionTTL {
|
|
delete(loginSessions.sessions, id)
|
|
}
|
|
}
|
|
loginSessions.mu.Unlock()
|
|
}
|
|
|
|
// requireAuth checks the request for an authenticated session. When
|
|
// c.authFunc is nil, registration is fail-closed (401). When the
|
|
// authFunc returns false or an error, the request is rejected with
|
|
// 401 Unauthorized. Returns true when the request is authenticated.
|
|
//
|
|
// The authFunc may also return the existing user identity so
|
|
// registration can be scoped (a user can only register credentials
|
|
// for their own account); the existing-user scoping is enforced by
|
|
// the caller via the username query param match (a future phase will
|
|
// wire the authenticated user as the registration target instead of
|
|
// accepting a free-form username).
|
|
func (c *Connector) requireAuth(w http.ResponseWriter, r *http.Request) bool {
|
|
if c.authFunc == nil {
|
|
http.Error(w, "registration requires authentication (no auth function configured)", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
ok, _, err := c.authFunc(r)
|
|
if err != nil || !ok {
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// SetAuthFunc sets the registration auth function (P04, T9). Allows
|
|
// callers to wire the auth check after construction (e.g. when the
|
|
// session store is initialized later).
|
|
func (c *Connector) SetAuthFunc(f func(r *http.Request) (bool, string, error)) {
|
|
c.authFunc = f
|
|
}
|
|
|
|
// BeginRegistration starts the WebAuthn registration ceremony.
|
|
// GET /orca/webauthn/register?username=<name>
|
|
// Returns the creation options (challenge) for the browser.
|
|
func (c *Connector) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
|
// P04 (T9, C-45): require an authenticated session before
|
|
// allowing registration. Without this, anyone could register a
|
|
// credential for any username. When authFunc is nil, fail-closed.
|
|
if !c.requireAuth(w, r) {
|
|
return
|
|
}
|
|
username := r.URL.Query().Get("username")
|
|
if username == "" {
|
|
http.Error(w, "username required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
userID := []byte(username)
|
|
existing, _ := c.store.GetCredential(username)
|
|
var creds []webauthn.Credential
|
|
if existing != nil {
|
|
creds = append(creds, webauthn.Credential{
|
|
ID: existing.CredentialID,
|
|
PublicKey: existing.PublicKey,
|
|
AttestationType: "none",
|
|
})
|
|
}
|
|
user := &webauthnUser{id: userID, name: username, credentials: creds}
|
|
options, session, err := c.w.BeginRegistration(user)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("begin registration: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sessionID := base64.RawURLEncoding.EncodeToString(userID)
|
|
regSessions.mu.Lock()
|
|
regSessions.sessions[sessionID] = &RegistrationSession{
|
|
UserID: username,
|
|
Challenge: session,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
regSessions.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(options)
|
|
}
|
|
|
|
// FinishRegistration completes the WebAuthn registration ceremony.
|
|
// POST /orca/webauthn/register/finish?username=<name>
|
|
// Body: the attestation response from the browser.
|
|
func (c *Connector) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
|
// P04 (T9): require an authenticated session for finish too.
|
|
if !c.requireAuth(w, r) {
|
|
return
|
|
}
|
|
username := r.URL.Query().Get("username")
|
|
if username == "" {
|
|
http.Error(w, "username required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
sessionID := base64.RawURLEncoding.EncodeToString([]byte(username))
|
|
regSessions.mu.Lock()
|
|
session, ok := regSessions.sessions[sessionID]
|
|
if !ok {
|
|
regSessions.mu.Unlock()
|
|
http.Error(w, "no registration session; call /register first", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if time.Since(session.CreatedAt) > sessionTTL {
|
|
delete(regSessions.sessions, sessionID)
|
|
regSessions.mu.Unlock()
|
|
http.Error(w, "session expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
regSessions.mu.Unlock()
|
|
parsed, err := protocol.ParseCredentialCreationResponseBody(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("parse attestation: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
user := &webauthnUser{id: []byte(username), name: username}
|
|
cred, err := c.w.CreateCredential(user, *session.Challenge, parsed)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("create credential: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
storeCred := &Credential{
|
|
UserID: username,
|
|
CredentialID: cred.ID,
|
|
PublicKey: cred.PublicKey,
|
|
SignCount: 0,
|
|
AAGUID: "",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if err := c.store.PutCredential(storeCred); err != nil {
|
|
http.Error(w, fmt.Sprintf("store credential: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
regSessions.mu.Lock()
|
|
delete(regSessions.sessions, sessionID)
|
|
regSessions.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "registered", "user_id": username})
|
|
}
|
|
|
|
// LoginSession holds the in-flight login challenge.
|
|
type LoginSession struct {
|
|
UserID string
|
|
Challenge *webauthn.SessionData
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// BeginLogin starts the WebAuthn login ceremony.
|
|
// GET /orca/webauthn/login?username=<name>
|
|
func (c *Connector) BeginLogin(w http.ResponseWriter, r *http.Request) {
|
|
cleanSessions()
|
|
username := r.URL.Query().Get("username")
|
|
if username == "" {
|
|
http.Error(w, "username required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
existing, _ := c.store.GetCredential(username)
|
|
if existing == nil {
|
|
http.Error(w, "user not registered", http.StatusNotFound)
|
|
return
|
|
}
|
|
user := &webauthnUser{
|
|
id: []byte(username),
|
|
name: username,
|
|
credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}},
|
|
}
|
|
options, session, err := c.w.BeginLogin(user)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("begin login: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
loginSessions.mu.Lock()
|
|
loginSessions.sessions[username] = &LoginSession{
|
|
UserID: username,
|
|
Challenge: session,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
loginSessions.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(options)
|
|
}
|
|
|
|
// FinishLogin completes the WebAuthn login ceremony.
|
|
// POST /orca/webauthn/login/finish?username=<name>
|
|
func (c *Connector) FinishLogin(w http.ResponseWriter, r *http.Request) {
|
|
username := r.URL.Query().Get("username")
|
|
if username == "" {
|
|
http.Error(w, "username required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
loginSessions.mu.Lock()
|
|
session, ok := loginSessions.sessions[username]
|
|
if !ok {
|
|
loginSessions.mu.Unlock()
|
|
http.Error(w, "no login session; call /login first", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if time.Since(session.CreatedAt) > sessionTTL {
|
|
delete(loginSessions.sessions, username)
|
|
loginSessions.mu.Unlock()
|
|
http.Error(w, "session expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
loginSessions.mu.Unlock()
|
|
existing, _ := c.store.GetCredential(username)
|
|
if existing == nil {
|
|
http.Error(w, "user not registered", http.StatusNotFound)
|
|
return
|
|
}
|
|
user := &webauthnUser{
|
|
id: []byte(username),
|
|
name: username,
|
|
credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}},
|
|
}
|
|
parsed, err := protocol.ParseCredentialRequestResponseBody(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("parse assertion: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
cred, err := c.w.ValidateLogin(user, *session.Challenge, parsed)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("validate login: %v", err), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_ = c.store.UpdateSignCount(username, cred.Authenticator.SignCount)
|
|
loginSessions.mu.Lock()
|
|
delete(loginSessions.sessions, username)
|
|
loginSessions.mu.Unlock()
|
|
// The OIDC sub is the username (the connector maps credential ID
|
|
// to sub). Dex uses this to issue the ID token.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "authenticated",
|
|
"sub": username,
|
|
})
|
|
}
|
|
|
|
// Routes returns the HTTP handler mux for the WebAuthn connector.
|
|
// Mount under /orca/webauthn/ behind Traefik.
|
|
func (c *Connector) Routes() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/orca/webauthn/register", c.BeginRegistration)
|
|
mux.HandleFunc("/orca/webauthn/register/finish", c.FinishRegistration)
|
|
mux.HandleFunc("/orca/webauthn/login", c.BeginLogin)
|
|
mux.HandleFunc("/orca/webauthn/login/finish", c.FinishLogin)
|
|
mux.HandleFunc("/orca/webauthn/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok","rp_id":"` + c.rpID + `"}`))
|
|
})
|
|
return mux
|
|
}
|
|
|
|
// Serve starts the WebAuthn HTTP handler on the given address. In
|
|
// production this runs behind Traefik (which provides TLS); the bind
|
|
// address is loopback only.
|
|
func (c *Connector) Serve(ctx context.Context, addr string) error {
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: c.Routes(),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx2)
|
|
}()
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
return fmt.Errorf("webauthn: serve: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// webauthnUser implements webauthn.User.
|
|
type webauthnUser struct {
|
|
id []byte
|
|
name string
|
|
credentials []webauthn.Credential
|
|
}
|
|
|
|
func (u *webauthnUser) WebAuthnID() []byte { return u.id }
|
|
func (u *webauthnUser) WebAuthnName() string { return u.name }
|
|
func (u *webauthnUser) WebAuthnDisplayName() string { return u.name }
|
|
func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
|
func (u *webauthnUser) WebAuthnIcon() string { return "" }
|
|
|
|
// RPID returns the configured relying-party ID.
|
|
func (c *Connector) RPID() string { return c.rpID }
|
|
|
|
// Ensure strings import is used (for the healthz handler).
|
|
var _ = strings.Contains
|