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

@@ -74,9 +74,12 @@ func runServer(cmd *cobra.Command, args []string) error {
// Start Web UI server // Start Web UI server
webSrv, err := webserver.Start(webserver.Config{ webSrv, err := webserver.Start(webserver.Config{
Addr: cfg.Web.ListenAddr, Addr: cfg.Web.ListenAddr,
DB: database, DB: database,
BaseURL: cfg.Web.BaseURL, BaseURL: cfg.Web.BaseURL,
RPDisplayName: cfg.WebAuthn.RPDisplayName,
RPID: cfg.WebAuthn.RPID,
RPOrigins: cfg.WebAuthn.RPOrigins,
}) })
if err != nil { if err != nil {
return fmt.Errorf("start web: %w", err) return fmt.Errorf("start web: %w", err)

View File

@@ -31,6 +31,16 @@ func CreateUser(database *sql.DB, username, password string, params Argon2Params
return res.LastInsertId() return res.LastInsertId()
} }
// LookupUserID returns the user ID for a username, or an error if not found.
func LookupUserID(database *sql.DB, username string) (int64, error) {
var userID int64
err := database.QueryRow("SELECT id FROM users WHERE username = ?", username).Scan(&userID)
if err != nil {
return 0, fmt.Errorf("user not found: %w", err)
}
return userID, nil
}
// AuthenticateUser verifies username/password and returns the user ID. // AuthenticateUser verifies username/password and returns the user ID.
func AuthenticateUser(database *sql.DB, username, password string) (int64, error) { func AuthenticateUser(database *sql.DB, username, password string) (int64, error) {
var userID int64 var userID int64

View File

@@ -2,7 +2,9 @@ package webserver
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -10,6 +12,7 @@ import (
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth" "git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
"git.wntrmute.dev/kyle/eng-pad-server/internal/share" "git.wntrmute.dev/kyle/eng-pad-server/internal/share"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-webauthn/webauthn/protocol"
) )
func (ws *WebServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { 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) 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)
}

View File

@@ -8,24 +8,33 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"sync"
"time" "time"
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
"git.wntrmute.dev/kyle/eng-pad-server/web" "git.wntrmute.dev/kyle/eng-pad-server/web"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-webauthn/webauthn/webauthn"
) )
type Config struct { type Config struct {
Addr string Addr string
DB *sql.DB DB *sql.DB
BaseURL string BaseURL string
TLSCert string TLSCert string
TLSKey string TLSKey string
RPDisplayName string
RPID string
RPOrigins []string
} }
type WebServer struct { type WebServer struct {
db *sql.DB db *sql.DB
baseURL string baseURL string
tmpl *template.Template tmpl *template.Template
webauthn *webauthn.WebAuthn
mu sync.Mutex
sessions map[string]*webauthn.SessionData
} }
func Start(cfg Config) (*http.Server, error) { func Start(cfg Config) (*http.Server, error) {
@@ -40,9 +49,19 @@ func Start(cfg Config) (*http.Server, error) {
} }
ws := &WebServer{ ws := &WebServer{
db: cfg.DB, db: cfg.DB,
baseURL: cfg.BaseURL, baseURL: cfg.BaseURL,
tmpl: tmpl, tmpl: tmpl,
sessions: make(map[string]*webauthn.SessionData),
}
if cfg.RPID != "" {
wa, err := auth.NewWebAuthn(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins)
if err != nil {
return nil, fmt.Errorf("init webauthn: %w", err)
}
ws.webauthn = wa
slog.Info("WebAuthn enabled", "rpid", cfg.RPID)
} }
r := chi.NewRouter() r := chi.NewRouter()
@@ -55,6 +74,10 @@ func Start(cfg Config) (*http.Server, error) {
r.Get("/login", ws.handleLoginPage) r.Get("/login", ws.handleLoginPage)
r.Post("/login", ws.handleLoginSubmit) r.Post("/login", ws.handleLoginSubmit)
// WebAuthn public routes (login ceremony)
r.Post("/webauthn/login/begin", ws.handleWebAuthnLoginBegin)
r.Post("/webauthn/login/finish", ws.handleWebAuthnLoginFinish)
// Share routes (no auth) // Share routes (no auth)
r.Get("/s/{token}", ws.handleShareNotebook) r.Get("/s/{token}", ws.handleShareNotebook)
r.Get("/s/{token}/pages/{num}", ws.handleSharePage) r.Get("/s/{token}/pages/{num}", ws.handleSharePage)
@@ -67,6 +90,12 @@ func Start(cfg Config) (*http.Server, error) {
r.Get("/notebooks/{id}", ws.handleNotebook) r.Get("/notebooks/{id}", ws.handleNotebook)
r.Get("/notebooks/{id}/pages/{num}", ws.handlePage) r.Get("/notebooks/{id}/pages/{num}", ws.handlePage)
r.Get("/logout", ws.handleLogout) r.Get("/logout", ws.handleLogout)
// WebAuthn authenticated routes (registration + key management)
r.Post("/webauthn/register/begin", ws.handleWebAuthnRegisterBegin)
r.Post("/webauthn/register/finish", ws.handleWebAuthnRegisterFinish)
r.Get("/keys", ws.handleKeysList)
r.Post("/keys/delete", ws.handleKeyDelete)
}) })
srv := &http.Server{ srv := &http.Server{

File diff suppressed because one or more lines are too long

View File

@@ -13,4 +13,104 @@
</div> </div>
<button type="submit" class="btn">Login</button> <button type="submit" class="btn">Login</button>
</form> </form>
<div id="webauthn-login" style="display:none; margin-top:1.5rem;">
<hr style="margin:1rem 0;">
<button type="button" class="btn" id="webauthn-login-btn">Login with Security Key</button>
<p id="webauthn-login-status" style="margin-top:0.5rem;"></p>
</div>
<script>
(function() {
if (!window.PublicKeyCredential) return;
var section = document.getElementById("webauthn-login");
section.style.display = "block";
var btn = document.getElementById("webauthn-login-btn");
var statusEl = document.getElementById("webauthn-login-status");
btn.addEventListener("click", async function() {
var username = document.getElementById("username").value;
if (!username) {
statusEl.style.color = "red";
statusEl.textContent = "Enter your username first.";
return;
}
statusEl.textContent = "";
statusEl.style.color = "";
btn.disabled = true;
btn.textContent = "Waiting for security key...";
try {
var beginResp = await fetch("/webauthn/login/begin", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({username: username})
});
if (!beginResp.ok) {
var err = await beginResp.json();
throw new Error(err.error || "Login failed");
}
var options = await beginResp.json();
options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
if (options.publicKey.allowCredentials) {
options.publicKey.allowCredentials.forEach(function(c) {
c.id = base64urlToBuffer(c.id);
});
}
var assertion = await navigator.credentials.get({publicKey: options.publicKey});
var 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) : ""
}
});
var finishResp = await fetch("/webauthn/login/finish?username=" + encodeURIComponent(username), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: body
});
if (!finishResp.ok) {
var err2 = await finishResp.json();
throw new Error(err2.error || "Login failed");
}
window.location.href = "/notebooks";
} catch(e) {
statusEl.style.color = "red";
statusEl.textContent = e.message || "Login failed";
} finally {
btn.disabled = false;
btn.textContent = "Login with Security Key";
}
});
function base64urlToBuffer(b64url) {
var b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
var binary = atob(b64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buf) {
var bytes = new Uint8Array(buf);
var binary = "";
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
})();
</script>
{{end}} {{end}}