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 type Handlers struct { service *Service 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, wa *webauthn.WebAuthn) *Handlers { return &Handlers{ service: service, sessions: sessions, middleware: NewMiddleware(sessions), templates: templates, webauthn: wa, } } // Middleware returns the auth middleware for use in routes func (h *Handlers) Middleware() *Middleware { return h.middleware } // HandleLoginPage renders the login form func (h *Handlers) HandleLoginPage(w http.ResponseWriter, r *http.Request) { // If already logged in, redirect to dashboard if h.middleware.IsAuthenticated(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } data := struct { Error string CSRFToken string WebAuthnEnabled bool }{ Error: "", CSRFToken: h.middleware.GetCSRFToken(r), WebAuthnEnabled: h.webauthn != nil, } if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) log.Printf("Error rendering login template: %v", err) } } // HandleLogin processes login form submission func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Failed to parse form", http.StatusBadRequest) return } username := r.FormValue("username") password := r.FormValue("password") if username == "" || password == "" { h.renderLoginError(w, r, "Username and password are required") return } user, err := h.service.Authenticate(username, password) if err != nil { log.Printf("Login failed for user %s: %v", username, err) h.renderLoginError(w, r, "Invalid username or password") return } // Regenerate session token to prevent session fixation if err := h.sessions.RenewToken(r.Context()); err != nil { http.Error(w, "Failed to create session", http.StatusInternalServerError) log.Printf("Failed to renew session token: %v", err) return } // Set user ID in session h.middleware.SetUserID(r, user.ID) log.Printf("User %s logged in successfully", username) http.Redirect(w, r, "/", http.StatusSeeOther) } // HandleLogout processes logout func (h *Handlers) HandleLogout(w http.ResponseWriter, r *http.Request) { if err := h.middleware.ClearSession(r); err != nil { log.Printf("Error clearing session: %v", err) } http.Redirect(w, r, "/login", http.StatusSeeOther) } func (h *Handlers) renderLoginError(w http.ResponseWriter, r *http.Request, errorMsg string) { data := struct { Error string CSRFToken string WebAuthnEnabled bool }{ Error: errorMsg, CSRFToken: h.middleware.GetCSRFToken(r), WebAuthnEnabled: h.webauthn != nil, } w.WriteHeader(http.StatusUnauthorized) if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) 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}) }