c726a6a9e2
---ci---
project: orca
phase: 5
milestone: v0.12
status: execute
---/ci---
internal/webauthn/store.go: SQLite credential store (0600, public
keys only). Put/Get/List/Delete/UpdateSignCount.
internal/webauthn/connector.go: WebAuthn ceremony handler for the
bundled Dex. BeginRegistration/FinishRegistration/BeginLogin/FinishLogin
at /orca/webauthn/{register,login}. go-webauthn library for crypto.
RP ID = cluster Traefik domain (C-38). Public-key credentials only
(private key never leaves authenticator; R-021 invariant holds).
9 tests pass (4 store + 5 connector). go vet clean. Full build green.
309 lines
9.8 KiB
Go
309 lines
9.8 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"
|
|
"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
|
|
}
|
|
|
|
// 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) {
|
|
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,
|
|
}, 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 + login). In
|
|
// production this would be a Redis/shared cache; for the bundled
|
|
// single-lead Dex, an in-memory map with TTL is sufficient.
|
|
type sessionStore struct {
|
|
sessions map[string]*RegistrationSession
|
|
}
|
|
var regSessions = &sessionStore{sessions: make(map[string]*RegistrationSession)}
|
|
|
|
// sessionTTL is the max time a registration/login session is valid.
|
|
const sessionTTL = 5 * time.Minute
|
|
|
|
// cleanSessions removes expired sessions.
|
|
func cleanSessions() {
|
|
now := time.Now()
|
|
for id, s := range regSessions.sessions {
|
|
if now.Sub(s.CreatedAt) > sessionTTL {
|
|
delete(regSessions.sessions, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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.sessions[sessionID] = &RegistrationSession{
|
|
UserID: username,
|
|
Challenge: session,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
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) {
|
|
username := r.URL.Query().Get("username")
|
|
if username == "" {
|
|
http.Error(w, "username required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
sessionID := base64.RawURLEncoding.EncodeToString([]byte(username))
|
|
session, ok := regSessions.sessions[sessionID]
|
|
if !ok {
|
|
http.Error(w, "no registration session; call /register first", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if time.Since(session.CreatedAt) > sessionTTL {
|
|
delete(regSessions.sessions, sessionID)
|
|
http.Error(w, "session expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
delete(regSessions.sessions, sessionID)
|
|
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
|
|
}
|
|
var loginSessions = map[string]*LoginSession{}
|
|
|
|
// 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[username] = &LoginSession{
|
|
UserID: username,
|
|
Challenge: session,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
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
|
|
}
|
|
session, ok := loginSessions[username]
|
|
if !ok {
|
|
http.Error(w, "no login session; call /login first", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if time.Since(session.CreatedAt) > sessionTTL {
|
|
delete(loginSessions, username)
|
|
http.Error(w, "session expired", 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}},
|
|
}
|
|
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)
|
|
delete(loginSessions, username)
|
|
// 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
|