diff options
Diffstat (limited to 'internal/auth/handlers.go')
| -rw-r--r-- | internal/auth/handlers.go | 259 |
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}) +} |
