Complete WebAuthn web handlers and download real htmx

- Real htmx.min.js (v2.0.4, 50KB) replaces stub
- WebAuthn registration handlers (begin/finish) for adding security keys
- WebAuthn login handlers (begin/finish) for passwordless login
- Key management page (list/delete registered keys)
- Login page updated with "Login with Security Key" button + JS
- Session store for WebAuthn ceremonies (mutex-protected map)
- WebAuthn config passed from server command through to webserver
- Added LookupUserID helper for username-based WebAuthn login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 21:33:45 -07:00
parent 49de9269d6
commit 710fcfcd34
6 changed files with 413 additions and 17 deletions

View File

@@ -2,7 +2,9 @@ package webserver
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
@@ -10,6 +12,7 @@ import (
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
"git.wntrmute.dev/kyle/eng-pad-server/internal/share"
"github.com/go-chi/chi/v5"
"github.com/go-webauthn/webauthn/protocol"
)
func (ws *WebServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
@@ -256,3 +259,256 @@ func (ws *WebServer) render(w http.ResponseWriter, name string, data any) {
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
// --- WebAuthn handlers ---
func (ws *WebServer) 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})
}
func (ws *WebServer) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
if ws.webauthn == nil {
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
return
}
userID := r.Context().Value(userIDKey).(int64)
user, err := auth.LoadWebAuthnUser(ws.db, userID)
if err != nil {
slog.Error("webauthn register begin: load user", "error", err)
ws.jsonError(w, "Failed to load user", http.StatusInternalServerError)
return
}
options, session, err := ws.webauthn.BeginRegistration(user)
if err != nil {
slog.Error("webauthn register begin", "error", err)
ws.jsonError(w, "Registration failed", http.StatusInternalServerError)
return
}
sessionKey := fmt.Sprintf("reg:%d", userID)
ws.mu.Lock()
ws.sessions[sessionKey] = session
ws.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(options)
}
func (ws *WebServer) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) {
if ws.webauthn == nil {
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
return
}
userID := r.Context().Value(userIDKey).(int64)
user, err := auth.LoadWebAuthnUser(ws.db, userID)
if err != nil {
slog.Error("webauthn register finish: load user", "error", err)
ws.jsonError(w, "Failed to load user", http.StatusInternalServerError)
return
}
sessionKey := fmt.Sprintf("reg:%d", userID)
ws.mu.Lock()
session, ok := ws.sessions[sessionKey]
if ok {
delete(ws.sessions, sessionKey)
}
ws.mu.Unlock()
if !ok {
ws.jsonError(w, "No registration in progress", http.StatusBadRequest)
return
}
parsedResponse, err := protocol.ParseCredentialCreationResponseBody(r.Body)
if err != nil {
slog.Error("webauthn register finish: parse response", "error", err)
ws.jsonError(w, "Invalid response", http.StatusBadRequest)
return
}
credential, err := ws.webauthn.CreateCredential(user, *session, parsedResponse)
if err != nil {
slog.Error("webauthn register finish: create credential", "error", err)
ws.jsonError(w, "Registration failed", http.StatusInternalServerError)
return
}
keyName := r.URL.Query().Get("name")
if keyName == "" {
keyName = "Security Key"
}
if err := auth.StoreWebAuthnCredential(ws.db, userID, keyName, credential); err != nil {
slog.Error("webauthn register finish: store credential", "error", err)
ws.jsonError(w, "Failed to save credential", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (ws *WebServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
if ws.webauthn == nil {
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
return
}
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" {
ws.jsonError(w, "Username is required", http.StatusBadRequest)
return
}
userID, err := auth.LookupUserID(ws.db, req.Username)
if err != nil {
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
user, err := auth.LoadWebAuthnUser(ws.db, userID)
if err != nil {
slog.Error("webauthn login begin: load user", "error", err)
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
if len(user.WebAuthnCredentials()) == 0 {
ws.jsonError(w, "No security keys registered", http.StatusBadRequest)
return
}
options, session, err := ws.webauthn.BeginLogin(user)
if err != nil {
slog.Error("webauthn login begin", "error", err)
ws.jsonError(w, "Login failed", http.StatusInternalServerError)
return
}
sessionKey := fmt.Sprintf("login:%s", req.Username)
ws.mu.Lock()
ws.sessions[sessionKey] = session
ws.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(options)
}
func (ws *WebServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
if ws.webauthn == nil {
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
return
}
username := r.URL.Query().Get("username")
if username == "" {
ws.jsonError(w, "Username is required", http.StatusBadRequest)
return
}
userID, err := auth.LookupUserID(ws.db, username)
if err != nil {
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
user, err := auth.LoadWebAuthnUser(ws.db, userID)
if err != nil {
slog.Error("webauthn login finish: load user", "error", err)
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
sessionKey := fmt.Sprintf("login:%s", username)
ws.mu.Lock()
session, ok := ws.sessions[sessionKey]
if ok {
delete(ws.sessions, sessionKey)
}
ws.mu.Unlock()
if !ok {
ws.jsonError(w, "No login in progress", http.StatusBadRequest)
return
}
parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body)
if err != nil {
slog.Error("webauthn login finish: parse response", "error", err)
ws.jsonError(w, "Invalid response", http.StatusBadRequest)
return
}
credential, err := ws.webauthn.ValidateLogin(user, *session, parsedResponse)
if err != nil {
slog.Error("webauthn login finish: validate", "error", err)
ws.jsonError(w, "Authentication failed", http.StatusUnauthorized)
return
}
// Update sign count to detect cloned authenticators.
_ = auth.UpdateWebAuthnSignCount(ws.db, credential.ID, credential.Authenticator.SignCount)
// Create session token.
token, err := auth.CreateToken(ws.db, userID, 24*time.Hour)
if err != nil {
slog.Error("webauthn login finish: create token", "error", err)
ws.jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400,
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (ws *WebServer) handleKeysList(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(userIDKey).(int64)
keys, err := auth.ListWebAuthnCredentials(ws.db, userID)
if err != nil {
slog.Error("list keys", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
ws.render(w, "keys.html", map[string]any{
"Keys": keys,
"WebAuthnEnabled": ws.webauthn != nil,
})
}
func (ws *WebServer) handleKeyDelete(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(userIDKey).(int64)
credID, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
http.Error(w, "Invalid key ID", http.StatusBadRequest)
return
}
if err := auth.DeleteWebAuthnCredential(ws.db, credID, userID); err != nil {
slog.Error("delete key", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/keys", http.StatusFound)
}