summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go29
-rw-r--r--go.mod11
-rw-r--r--go.sum16
-rw-r--r--internal/auth/auth.go147
-rw-r--r--internal/auth/handlers.go259
-rw-r--r--internal/auth/handlers_test.go4
-rw-r--r--internal/config/config.go8
-rw-r--r--migrations/014_webauthn_credentials.sql12
-rw-r--r--test/acceptance_test.go2
-rw-r--r--web/templates/login.html90
-rw-r--r--web/templates/passkeys_list.html24
-rw-r--r--web/templates/settings.html114
12 files changed, 701 insertions, 15 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index db1f66d..0553041 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -23,6 +23,8 @@ import (
"task-dashboard/internal/handlers"
appmiddleware "task-dashboard/internal/middleware"
"task-dashboard/internal/store"
+
+ "github.com/go-webauthn/webauthn/webauthn"
)
func main() {
@@ -78,8 +80,23 @@ func main() {
log.Printf("Warning: failed to parse auth templates: %v", err)
}
+ // Initialize WebAuthn (optional - only if configured)
+ var wa *webauthn.WebAuthn
+ if cfg.WebAuthnRPID != "" && cfg.WebAuthnOrigin != "" {
+ var err error
+ wa, err = webauthn.New(&webauthn.Config{
+ RPDisplayName: "Task Dashboard",
+ RPID: cfg.WebAuthnRPID,
+ RPOrigins: []string{cfg.WebAuthnOrigin},
+ })
+ if err != nil {
+ log.Fatalf("Failed to initialize WebAuthn: %v", err)
+ }
+ log.Printf("WebAuthn initialized (RP ID: %s, Origin: %s)", cfg.WebAuthnRPID, cfg.WebAuthnOrigin)
+ }
+
// Initialize auth handlers
- authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates)
+ authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates, wa)
// Initialize API clients
todoistClient := api.NewTodoistClient(cfg.TodoistAPIKey)
@@ -146,6 +163,10 @@ func main() {
r.With(authRateLimiter.Limit).Post("/login", authHandlers.HandleLogin)
r.Post("/logout", authHandlers.HandleLogout)
+ // WebAuthn public routes (rate-limited)
+ r.With(authRateLimiter.Limit).Post("/passkeys/login/begin", authHandlers.HandlePasskeyLoginBegin)
+ r.With(authRateLimiter.Limit).Post("/passkeys/login/finish", authHandlers.HandlePasskeyLoginFinish)
+
// Serve static files (public)
fileServer := http.FileServer(http.Dir(cfg.StaticDir))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
@@ -235,6 +256,12 @@ func main() {
r.Post("/shopping/mode/{store}/toggle", h.HandleShoppingModeToggle)
r.Post("/shopping/mode/{store}/complete", h.HandleShoppingModeComplete)
+ // Passkey management (WebAuthn)
+ r.Get("/settings/passkeys", authHandlers.HandleListPasskeys)
+ r.Post("/passkeys/register/begin", authHandlers.HandlePasskeyRegisterBegin)
+ r.Post("/passkeys/register/finish", authHandlers.HandlePasskeyRegisterFinish)
+ r.Delete("/passkeys/{id}", authHandlers.HandleDeletePasskey)
+
// Settings
r.Get("/settings", h.HandleSettingsPage)
r.Post("/settings/sync", h.HandleSyncSources)
diff --git a/go.mod b/go.mod
index df85101..9775436 100644
--- a/go.mod
+++ b/go.mod
@@ -9,8 +9,11 @@ require (
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
+ github.com/PuerkitoBio/goquery v1.11.0
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
+ github.com/go-webauthn/webauthn v0.15.0
+ github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.47.0
google.golang.org/api v0.262.0
@@ -20,17 +23,21 @@ require (
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
- github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/go-webauthn/x v0.1.26 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
+ github.com/google/go-tpm v0.9.6 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
- github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
diff --git a/go.sum b/go.sum
index b34a475..f82d427 100644
--- a/go.sum
+++ b/go.sum
@@ -20,6 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -27,11 +29,21 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
+github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
+github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
+github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
+github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -52,6 +64,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
@@ -67,6 +81,8 @@ go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index a602dad..ce62fa4 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -2,9 +2,11 @@ package auth
import (
"database/sql"
+ "encoding/base64"
"errors"
"time"
+ "github.com/go-webauthn/webauthn/webauthn"
"golang.org/x/crypto/bcrypt"
)
@@ -140,3 +142,148 @@ func (s *Service) EnsureDefaultUser(username, password string) error {
return nil
}
+
+// WebAuthnUser wraps User to implement the webauthn.User interface
+type WebAuthnUser struct {
+ *User
+ credentials []webauthn.Credential
+}
+
+func (u *WebAuthnUser) WebAuthnID() []byte {
+ b := make([]byte, 8)
+ id := u.ID
+ for i := range 8 {
+ b[i] = byte(id >> (i * 8))
+ }
+ return b
+}
+
+func (u *WebAuthnUser) WebAuthnName() string { return u.Username }
+func (u *WebAuthnUser) WebAuthnDisplayName() string { return u.Username }
+func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
+
+// SaveWebAuthnCredential stores a new WebAuthn credential for a user
+func (s *Service) SaveWebAuthnCredential(userID int64, cred *webauthn.Credential, name string) error {
+ credID := base64.RawURLEncoding.EncodeToString(cred.ID)
+ _, err := s.db.Exec(
+ `INSERT INTO webauthn_credentials (id, user_id, public_key, attestation_type, aaguid, sign_count, name)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ credID, userID, cred.PublicKey, cred.AttestationType, cred.Authenticator.AAGUID, cred.Authenticator.SignCount, name,
+ )
+ return err
+}
+
+// GetWebAuthnCredentials returns all WebAuthn credentials for a user
+func (s *Service) GetWebAuthnCredentials(userID int64) ([]webauthn.Credential, error) {
+ rows, err := s.db.Query(
+ `SELECT id, public_key, attestation_type, aaguid, sign_count FROM webauthn_credentials WHERE user_id = ?`,
+ userID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var creds []webauthn.Credential
+ for rows.Next() {
+ var (
+ credIDStr string
+ publicKey []byte
+ attestationType string
+ aaguid []byte
+ signCount uint32
+ )
+ if err := rows.Scan(&credIDStr, &publicKey, &attestationType, &aaguid, &signCount); err != nil {
+ return nil, err
+ }
+ credID, err := base64.RawURLEncoding.DecodeString(credIDStr)
+ if err != nil {
+ return nil, err
+ }
+ creds = append(creds, webauthn.Credential{
+ ID: credID,
+ PublicKey: publicKey,
+ AttestationType: attestationType,
+ Authenticator: webauthn.Authenticator{
+ AAGUID: aaguid,
+ SignCount: signCount,
+ },
+ })
+ }
+ return creds, rows.Err()
+}
+
+// WebAuthnCredentialInfo holds display info for a stored passkey
+type WebAuthnCredentialInfo struct {
+ ID string
+ Name string
+ CreatedAt time.Time
+}
+
+// GetWebAuthnCredentialInfos returns display info for a user's passkeys
+func (s *Service) GetWebAuthnCredentialInfos(userID int64) ([]WebAuthnCredentialInfo, error) {
+ rows, err := s.db.Query(
+ `SELECT id, name, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC`,
+ userID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var infos []WebAuthnCredentialInfo
+ for rows.Next() {
+ var info WebAuthnCredentialInfo
+ if err := rows.Scan(&info.ID, &info.Name, &info.CreatedAt); err != nil {
+ return nil, err
+ }
+ infos = append(infos, info)
+ }
+ return infos, rows.Err()
+}
+
+// DeleteWebAuthnCredential removes a WebAuthn credential by its ID
+func (s *Service) DeleteWebAuthnCredential(credID string) error {
+ result, err := s.db.Exec(`DELETE FROM webauthn_credentials WHERE id = ?`, credID)
+ if err != nil {
+ return err
+ }
+ n, _ := result.RowsAffected()
+ if n == 0 {
+ return errors.New("credential not found")
+ }
+ return nil
+}
+
+// UpdateWebAuthnCredentialSignCount updates the sign count after a successful assertion
+func (s *Service) UpdateWebAuthnCredentialSignCount(credID string, signCount uint32) error {
+ _, err := s.db.Exec(`UPDATE webauthn_credentials SET sign_count = ? WHERE id = ?`, signCount, credID)
+ return err
+}
+
+// GetUserWithCredentials returns a WebAuthnUser with loaded credentials
+func (s *Service) GetUserWithCredentials(userID int64) (*WebAuthnUser, error) {
+ user, err := s.GetUserByID(userID)
+ if err != nil {
+ return nil, err
+ }
+ creds, err := s.GetWebAuthnCredentials(userID)
+ if err != nil {
+ return nil, err
+ }
+ return &WebAuthnUser{User: user, credentials: creds}, nil
+}
+
+// FindUserByCredentialID finds the user who owns a given credential ID (for discoverable login)
+func (s *Service) FindUserByCredentialID(credID []byte) (*WebAuthnUser, error) {
+ credIDStr := base64.RawURLEncoding.EncodeToString(credID)
+ var userID int64
+ err := s.db.QueryRow(`SELECT user_id FROM webauthn_credentials WHERE id = ?`, credIDStr).Scan(&userID)
+ if err == sql.ErrNoRows {
+ return nil, ErrUserNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return s.GetUserWithCredentials(userID)
+}
diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go
index c690d29..78595d0 100644
--- a/internal/auth/handlers.go
+++ b/internal/auth/handlers.go
@@ -1,11 +1,16 @@
package auth
import (
+ "encoding/base64"
+ "encoding/json"
"html/template"
"log"
"net/http"
"github.com/alexedwards/scs/v2"
+ "github.com/go-chi/chi/v5"
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
)
// Handlers provides HTTP handlers for authentication
@@ -14,15 +19,17 @@ type Handlers struct {
sessions *scs.SessionManager
middleware *Middleware
templates *template.Template
+ webauthn *webauthn.WebAuthn // nil if WebAuthn is not configured
}
// NewHandlers creates new auth handlers
-func NewHandlers(service *Service, sessions *scs.SessionManager, templates *template.Template) *Handlers {
+func NewHandlers(service *Service, sessions *scs.SessionManager, templates *template.Template, wa *webauthn.WebAuthn) *Handlers {
return &Handlers{
service: service,
sessions: sessions,
middleware: NewMiddleware(sessions),
templates: templates,
+ webauthn: wa,
}
}
@@ -40,11 +47,13 @@ func (h *Handlers) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
}
data := struct {
- Error string
- CSRFToken string
+ Error string
+ CSRFToken string
+ WebAuthnEnabled bool
}{
- Error: "",
- CSRFToken: h.middleware.GetCSRFToken(r),
+ Error: "",
+ CSRFToken: h.middleware.GetCSRFToken(r),
+ WebAuthnEnabled: h.webauthn != nil,
}
if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil {
@@ -100,11 +109,13 @@ func (h *Handlers) HandleLogout(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) renderLoginError(w http.ResponseWriter, r *http.Request, errorMsg string) {
data := struct {
- Error string
- CSRFToken string
+ Error string
+ CSRFToken string
+ WebAuthnEnabled bool
}{
- Error: errorMsg,
- CSRFToken: h.middleware.GetCSRFToken(r),
+ Error: errorMsg,
+ CSRFToken: h.middleware.GetCSRFToken(r),
+ WebAuthnEnabled: h.webauthn != nil,
}
w.WriteHeader(http.StatusUnauthorized)
@@ -113,3 +124,233 @@ func (h *Handlers) renderLoginError(w http.ResponseWriter, r *http.Request, erro
log.Printf("Error rendering login template: %v", err)
}
}
+
+// HandleListPasskeys returns the passkeys list partial for the settings page
+func (h *Handlers) HandleListPasskeys(w http.ResponseWriter, r *http.Request) {
+ if h.webauthn == nil {
+ http.Error(w, "WebAuthn not configured", http.StatusNotFound)
+ return
+ }
+ userID := h.middleware.GetUserID(r)
+ infos, err := h.service.GetWebAuthnCredentialInfos(userID)
+ if err != nil {
+ log.Printf("Error getting passkeys: %v", err)
+ http.Error(w, "Failed to load passkeys", http.StatusInternalServerError)
+ return
+ }
+
+ data := struct {
+ Passkeys []WebAuthnCredentialInfo
+ CSRFToken string
+ }{
+ Passkeys: infos,
+ CSRFToken: h.middleware.GetCSRFToken(r),
+ }
+
+ if err := h.templates.ExecuteTemplate(w, "passkeys_list.html", data); err != nil {
+ log.Printf("Error rendering passkeys list: %v", err)
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ }
+}
+
+// HandlePasskeyRegisterBegin starts the WebAuthn registration ceremony
+func (h *Handlers) HandlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
+ if h.webauthn == nil {
+ jsonError(w, "WebAuthn not configured", http.StatusNotFound)
+ return
+ }
+
+ userID := h.middleware.GetUserID(r)
+ user, err := h.service.GetUserWithCredentials(userID)
+ if err != nil {
+ log.Printf("Error getting user for passkey registration: %v", err)
+ jsonError(w, "Failed to get user", http.StatusInternalServerError)
+ return
+ }
+
+ options, session, err := h.webauthn.BeginRegistration(user,
+ webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
+ )
+ if err != nil {
+ log.Printf("Error beginning passkey registration: %v", err)
+ jsonError(w, "Failed to start registration", http.StatusInternalServerError)
+ return
+ }
+
+ // Store session data for the finish step
+ sessionBytes, err := json.Marshal(session)
+ if err != nil {
+ jsonError(w, "Failed to store session", http.StatusInternalServerError)
+ return
+ }
+ h.sessions.Put(r.Context(), "webauthn_registration", string(sessionBytes))
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(options)
+}
+
+// HandlePasskeyRegisterFinish completes the WebAuthn registration ceremony
+func (h *Handlers) HandlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
+ if h.webauthn == nil {
+ jsonError(w, "WebAuthn not configured", http.StatusNotFound)
+ return
+ }
+
+ userID := h.middleware.GetUserID(r)
+ user, err := h.service.GetUserWithCredentials(userID)
+ if err != nil {
+ log.Printf("Error getting user for passkey registration finish: %v", err)
+ jsonError(w, "Failed to get user", http.StatusInternalServerError)
+ return
+ }
+
+ // Recover session data
+ sessionJSON := h.sessions.GetString(r.Context(), "webauthn_registration")
+ if sessionJSON == "" {
+ jsonError(w, "No registration session found", http.StatusBadRequest)
+ return
+ }
+ h.sessions.Remove(r.Context(), "webauthn_registration")
+
+ var session webauthn.SessionData
+ if err := json.Unmarshal([]byte(sessionJSON), &session); err != nil {
+ jsonError(w, "Invalid session data", http.StatusBadRequest)
+ return
+ }
+
+ cred, err := h.webauthn.FinishRegistration(user, session, r)
+ if err != nil {
+ log.Printf("Error finishing passkey registration: %v", err)
+ jsonError(w, "Registration failed", http.StatusBadRequest)
+ return
+ }
+
+ // Get the friendly name from query param or use default
+ name := r.URL.Query().Get("name")
+ if name == "" {
+ name = "Passkey"
+ }
+
+ if err := h.service.SaveWebAuthnCredential(userID, cred, name); err != nil {
+ log.Printf("Error saving passkey: %v", err)
+ jsonError(w, "Failed to save passkey", http.StatusInternalServerError)
+ return
+ }
+
+ log.Printf("Passkey registered for user %d: %s", userID, name)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+// HandleDeletePasskey removes a passkey
+func (h *Handlers) HandleDeletePasskey(w http.ResponseWriter, r *http.Request) {
+ if h.webauthn == nil {
+ http.Error(w, "WebAuthn not configured", http.StatusNotFound)
+ return
+ }
+
+ // Extract passkey ID from URL path
+ credID := chi.URLParam(r, "id")
+ if credID == "" {
+ http.Error(w, "Missing passkey ID", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.service.DeleteWebAuthnCredential(credID); err != nil {
+ log.Printf("Error deleting passkey: %v", err)
+ http.Error(w, "Failed to delete passkey", http.StatusInternalServerError)
+ return
+ }
+
+ log.Printf("Passkey deleted: %s", credID)
+ w.WriteHeader(http.StatusOK)
+}
+
+// HandlePasskeyLoginBegin starts the WebAuthn authentication ceremony (discoverable credentials)
+func (h *Handlers) HandlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
+ if h.webauthn == nil {
+ jsonError(w, "WebAuthn not configured", http.StatusNotFound)
+ return
+ }
+
+ options, session, err := h.webauthn.BeginDiscoverableLogin()
+ if err != nil {
+ log.Printf("Error beginning passkey login: %v", err)
+ jsonError(w, "Failed to start login", http.StatusInternalServerError)
+ return
+ }
+
+ sessionBytes, err := json.Marshal(session)
+ if err != nil {
+ jsonError(w, "Failed to store session", http.StatusInternalServerError)
+ return
+ }
+ h.sessions.Put(r.Context(), "webauthn_login", string(sessionBytes))
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(options)
+}
+
+// HandlePasskeyLoginFinish completes the WebAuthn authentication ceremony
+func (h *Handlers) HandlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
+ if h.webauthn == nil {
+ jsonError(w, "WebAuthn not configured", http.StatusNotFound)
+ return
+ }
+
+ sessionJSON := h.sessions.GetString(r.Context(), "webauthn_login")
+ if sessionJSON == "" {
+ jsonError(w, "No login session found", http.StatusBadRequest)
+ return
+ }
+ h.sessions.Remove(r.Context(), "webauthn_login")
+
+ var session webauthn.SessionData
+ if err := json.Unmarshal([]byte(sessionJSON), &session); err != nil {
+ jsonError(w, "Invalid session data", http.StatusBadRequest)
+ return
+ }
+
+ // User discovery handler - called by the library to find the user from the credential
+ userHandler := func(rawID, userHandle []byte) (webauthn.User, error) {
+ return h.service.FindUserByCredentialID(rawID)
+ }
+
+ cred, err := h.webauthn.FinishDiscoverableLogin(userHandler, session, r)
+ if err != nil {
+ log.Printf("Error finishing passkey login: %v", err)
+ jsonError(w, "Login failed", http.StatusUnauthorized)
+ return
+ }
+
+ // Find the user who owns this credential to create a session
+ credIDStr := base64.RawURLEncoding.EncodeToString(cred.ID)
+ user, err := h.service.FindUserByCredentialID(cred.ID)
+ if err != nil {
+ log.Printf("Error finding user for credential: %v", err)
+ jsonError(w, "Login failed", http.StatusUnauthorized)
+ return
+ }
+
+ // Update sign count
+ if err := h.service.UpdateWebAuthnCredentialSignCount(credIDStr, cred.Authenticator.SignCount); err != nil {
+ log.Printf("Error updating sign count: %v", err)
+ }
+
+ // Create session (same as password login)
+ if err := h.sessions.RenewToken(r.Context()); err != nil {
+ jsonError(w, "Failed to create session", http.StatusInternalServerError)
+ return
+ }
+ h.middleware.SetUserID(r, user.ID)
+
+ log.Printf("User %s logged in via passkey", user.Username)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok", "redirect": "/"})
+}
+
+func jsonError(w http.ResponseWriter, msg string, code int) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
diff --git a/internal/auth/handlers_test.go b/internal/auth/handlers_test.go
index 128ae80..aed0e90 100644
--- a/internal/auth/handlers_test.go
+++ b/internal/auth/handlers_test.go
@@ -26,7 +26,7 @@ func TestHandleLogin(t *testing.T) {
sessionManager := scs.New()
templates := template.Must(template.New("login.html").Parse("{{.Error}}"))
- handlers := NewHandlers(service, sessionManager, templates)
+ handlers := NewHandlers(service, sessionManager, templates, nil)
// Setup mock user
password := "password"
@@ -74,7 +74,7 @@ func TestHandleLogin_InvalidCredentials(t *testing.T) {
sessionManager := scs.New()
templates := template.Must(template.New("login.html").Parse("{{.Error}}"))
- handlers := NewHandlers(service, sessionManager, templates)
+ handlers := NewHandlers(service, sessionManager, templates, nil)
mock.ExpectQuery("SELECT id, username, password_hash, created_at FROM users WHERE username = ?").
WithArgs("testuser").
diff --git a/internal/config/config.go b/internal/config/config.go
index 2d77025..86d0d5b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -35,6 +35,10 @@ type Config struct {
// Display
Timezone string // IANA timezone name (e.g., "Pacific/Honolulu")
+
+ // WebAuthn
+ WebAuthnRPID string // Relying Party ID (domain, e.g., "doot.terst.org")
+ WebAuthnOrigin string // Expected origin (e.g., "https://doot.terst.org")
}
// Load reads configuration from environment variables
@@ -67,6 +71,10 @@ func Load() (*Config, error) {
// Display
Timezone: getEnvWithDefault("TIMEZONE", "Pacific/Honolulu"),
+
+ // WebAuthn
+ WebAuthnRPID: os.Getenv("WEBAUTHN_RP_ID"),
+ WebAuthnOrigin: os.Getenv("WEBAUTHN_ORIGIN"),
}
// Validate required fields
diff --git a/migrations/014_webauthn_credentials.sql b/migrations/014_webauthn_credentials.sql
new file mode 100644
index 0000000..d1abefc
--- /dev/null
+++ b/migrations/014_webauthn_credentials.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS webauthn_credentials (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ public_key BLOB NOT NULL,
+ attestation_type TEXT NOT NULL,
+ aaguid BLOB,
+ sign_count INTEGER DEFAULT 0,
+ name TEXT DEFAULT '',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
diff --git a/test/acceptance_test.go b/test/acceptance_test.go
index c93090b..fb55be4 100644
--- a/test/acceptance_test.go
+++ b/test/acceptance_test.go
@@ -67,7 +67,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *store.Store, *http.Client
authService := auth.NewService(db.DB())
// Create a dummy template for auth handlers
authTemplates := template.Must(template.New("login.html").Parse(""))
- authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates)
+ authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates, nil)
// Ensure default user
_ = authService.EnsureDefaultUser("admin", "password")
diff --git a/web/templates/login.html b/web/templates/login.html
index bda6364..7d40a6b 100644
--- a/web/templates/login.html
+++ b/web/templates/login.html
@@ -58,6 +58,96 @@
Sign In
</button>
</form>
+
+ {{if .WebAuthnEnabled}}
+ <div id="passkey-section" class="mt-6 pt-6 border-t border-white/20" style="display: none;">
+ <button
+ id="passkey-login-btn"
+ type="button"
+ class="w-full bg-white/10 hover:bg-white/20 text-white font-medium py-3 px-4 rounded-lg transition-colors border border-white/20">
+ Sign in with Passkey
+ </button>
+ <p id="passkey-error" class="mt-2 text-red-300 text-sm" style="display: none;"></p>
+ </div>
+ <script>
+ (function() {
+ if (!window.PublicKeyCredential) return;
+ document.getElementById('passkey-section').style.display = 'block';
+
+ document.getElementById('passkey-login-btn').addEventListener('click', async function() {
+ const btn = this;
+ const errEl = document.getElementById('passkey-error');
+ errEl.style.display = 'none';
+ btn.disabled = true;
+ btn.textContent = 'Waiting for passkey...';
+
+ try {
+ const csrfToken = document.querySelector('input[name="csrf_token"]').value;
+ const beginResp = await fetch('/passkeys/login/begin', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': csrfToken}
+ });
+ if (!beginResp.ok) throw new Error('Failed to start login');
+ const options = await beginResp.json();
+
+ options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
+ if (options.publicKey.allowCredentials) {
+ options.publicKey.allowCredentials = options.publicKey.allowCredentials.map(c => ({
+ ...c, id: base64urlToBuffer(c.id)
+ }));
+ }
+
+ const assertion = await navigator.credentials.get(options);
+
+ const body = JSON.stringify({
+ id: assertion.id,
+ rawId: bufferToBase64url(assertion.rawId),
+ type: assertion.type,
+ response: {
+ authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
+ clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
+ signature: bufferToBase64url(assertion.response.signature),
+ userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : ''
+ }
+ });
+
+ const finishResp = await fetch('/passkeys/login/finish', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
+ body: body
+ });
+ const result = await finishResp.json();
+ if (!finishResp.ok) throw new Error(result.error || 'Login failed');
+
+ window.location.href = result.redirect || '/';
+ } catch (e) {
+ if (e.name === 'NotAllowedError') {
+ errEl.textContent = 'Passkey request was cancelled.';
+ } else {
+ errEl.textContent = e.message || 'Passkey login failed.';
+ }
+ errEl.style.display = 'block';
+ btn.disabled = false;
+ btn.textContent = 'Sign in with Passkey';
+ }
+ });
+
+ function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
+ const binary = atob(base64 + pad);
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
+ }
+
+ function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+ }
+ })();
+ </script>
+ {{end}}
</div>
</div>
</body>
diff --git a/web/templates/passkeys_list.html b/web/templates/passkeys_list.html
new file mode 100644
index 0000000..4e05461
--- /dev/null
+++ b/web/templates/passkeys_list.html
@@ -0,0 +1,24 @@
+{{define "passkeys_list.html"}}
+<div class="items-list">
+ {{if .Passkeys}}
+ {{range .Passkeys}}
+ <div class="item-row" id="passkey-{{.ID}}">
+ <div class="item-name">
+ <strong>{{if .Name}}{{.Name}}{{else}}Passkey{{end}}</strong>
+ <div class="item-desc">Added {{.CreatedAt.Format "Jan 2, 2006"}}</div>
+ </div>
+ <button class="btn btn-danger btn-sm"
+ hx-delete="/passkeys/{{.ID}}"
+ hx-target="#passkey-{{.ID}}"
+ hx-swap="outerHTML"
+ hx-headers='{"X-CSRF-Token": "{{$.CSRFToken}}"}'
+ hx-confirm="Delete this passkey?">
+ Delete
+ </button>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="empty-state">No passkeys registered. Register one to enable passwordless login.</div>
+ {{end}}
+</div>
+{{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
index db84860..50569e4 100644
--- a/web/templates/settings.html
+++ b/web/templates/settings.html
@@ -204,6 +204,120 @@
</form>
</div>
+ <!-- Passkeys Section -->
+ <div class="card" id="passkeys-card">
+ <div class="card-header">
+ <div class="card-title">Passkeys</div>
+ </div>
+ <div id="passkeys-list" hx-get="/settings/passkeys" hx-trigger="load" hx-swap="innerHTML">
+ <div class="empty-state">Loading passkeys...</div>
+ </div>
+ <div class="add-form" id="passkey-register-form">
+ <input type="text" id="passkey-name" placeholder="Passkey name (e.g., MacBook Touch ID)" maxlength="100">
+ <button type="button" class="btn" id="register-passkey-btn">Register New Passkey</button>
+ </div>
+ <p id="passkey-status" style="color: var(--text-secondary); font-size: 0.85em; margin-top: 8px; display: none;"></p>
+ </div>
+ <script>
+ (function() {
+ if (!window.PublicKeyCredential) {
+ document.getElementById('passkeys-card').style.display = 'none';
+ return;
+ }
+
+ document.getElementById('register-passkey-btn').addEventListener('click', async function() {
+ const btn = this;
+ const statusEl = document.getElementById('passkey-status');
+ const nameInput = document.getElementById('passkey-name');
+ const name = nameInput.value.trim() || 'Passkey';
+
+ btn.disabled = true;
+ statusEl.style.display = 'block';
+ statusEl.style.color = 'var(--text-secondary)';
+ statusEl.textContent = 'Starting registration...';
+
+ try {
+ const csrfMeta = document.querySelector('input[name="csrf_token"]') || document.querySelector('[name="csrf_token"]');
+ let csrfToken = '';
+ // Get CSRF token from the feature toggle form's hidden fields or cookie
+ const forms = document.querySelectorAll('form');
+ for (const f of forms) {
+ const input = f.querySelector('input[name="csrf_token"]');
+ if (input) { csrfToken = input.value; break; }
+ }
+ // Fallback: fetch from HTMX headers
+ if (!csrfToken) {
+ const resp = await fetch('/settings/passkeys');
+ // Try to extract from response
+ }
+
+ const beginResp = await fetch('/passkeys/register/begin', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': csrfToken}
+ });
+ if (!beginResp.ok) throw new Error('Failed to start registration');
+ const options = await beginResp.json();
+
+ options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
+ options.publicKey.user.id = base64urlToBuffer(options.publicKey.user.id);
+ if (options.publicKey.excludeCredentials) {
+ options.publicKey.excludeCredentials = options.publicKey.excludeCredentials.map(c => ({
+ ...c, id: base64urlToBuffer(c.id)
+ }));
+ }
+
+ statusEl.textContent = 'Waiting for passkey...';
+ const credential = await navigator.credentials.create(options);
+
+ const body = JSON.stringify({
+ id: credential.id,
+ rawId: bufferToBase64url(credential.rawId),
+ type: credential.type,
+ response: {
+ attestationObject: bufferToBase64url(credential.response.attestationObject),
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
+ }
+ });
+
+ const finishResp = await fetch('/passkeys/register/finish?name=' + encodeURIComponent(name), {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
+ body: body
+ });
+ if (!finishResp.ok) throw new Error('Registration failed');
+
+ statusEl.style.color = 'var(--success)';
+ statusEl.textContent = 'Passkey registered successfully!';
+ nameInput.value = '';
+ // Reload passkeys list
+ htmx.trigger('#passkeys-list', 'load');
+ } catch (e) {
+ statusEl.style.color = 'var(--danger)';
+ if (e.name === 'NotAllowedError') {
+ statusEl.textContent = 'Registration was cancelled.';
+ } else {
+ statusEl.textContent = e.message || 'Registration failed.';
+ }
+ }
+ btn.disabled = false;
+ });
+
+ function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
+ const binary = atob(base64 + pad);
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
+ }
+
+ function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+ }
+ })();
+ </script>
+
<!-- Source Configuration Section -->
<h2>Data Sources</h2>
<form hx-post="/settings/sync" hx-swap="outerHTML" hx-target="#sources-container" hx-indicator=".sync-indicator">