diff options
| -rw-r--r-- | cmd/dashboard/main.go | 29 | ||||
| -rw-r--r-- | go.mod | 11 | ||||
| -rw-r--r-- | go.sum | 16 | ||||
| -rw-r--r-- | internal/auth/auth.go | 147 | ||||
| -rw-r--r-- | internal/auth/handlers.go | 259 | ||||
| -rw-r--r-- | internal/auth/handlers_test.go | 4 | ||||
| -rw-r--r-- | internal/config/config.go | 8 | ||||
| -rw-r--r-- | migrations/014_webauthn_credentials.sql | 12 | ||||
| -rw-r--r-- | test/acceptance_test.go | 2 | ||||
| -rw-r--r-- | web/templates/login.html | 90 | ||||
| -rw-r--r-- | web/templates/passkeys_list.html | 24 | ||||
| -rw-r--r-- | web/templates/settings.html | 114 |
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) @@ -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 @@ -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"> |
