summaryrefslogtreecommitdiff
path: root/internal/auth/handlers.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-05 15:35:01 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-05 15:35:01 -1000
commit0a1001eb0bd2d1f7c0624ae1ef8ae7ccdb3447d4 (patch)
treecaf04d4f505bb12751579e2f0f1730ead7a9e2e2 /internal/auth/handlers.go
parent1eab4d59454fa5999675d51b99e77ac6580aba95 (diff)
Add passkey (WebAuthn) authentication support
Enable passwordless login via passkeys as an alternative to password auth. Users register passkeys from Settings; the login page offers both options. WebAuthn is optional — only active when WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN env vars are set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/auth/handlers.go')
-rw-r--r--internal/auth/handlers.go259
1 files changed, 250 insertions, 9 deletions
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})
+}