- 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>
515 lines
14 KiB
Go
515 lines
14 KiB
Go
package webserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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) {
|
|
ws.render(w, "login.html", nil)
|
|
}
|
|
|
|
func (ws *WebServer) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
|
|
userID, err := auth.AuthenticateUser(ws.db, username, password)
|
|
if err != nil {
|
|
ws.render(w, "login.html", map[string]string{"Error": "Invalid credentials"})
|
|
return
|
|
}
|
|
|
|
token, err := auth.CreateToken(ws.db, userID, 24*time.Hour)
|
|
if err != nil {
|
|
http.Error(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,
|
|
})
|
|
|
|
http.Redirect(w, r, "/notebooks", http.StatusFound)
|
|
}
|
|
|
|
func (ws *WebServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if cookie, err := r.Cookie("session"); err == nil {
|
|
_ = auth.DeleteToken(ws.db, cookie.Value)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
}
|
|
|
|
func (ws *WebServer) handleNotebooks(w http.ResponseWriter, r *http.Request) {
|
|
userID := r.Context().Value(userIDKey).(int64)
|
|
|
|
rows, err := ws.db.QueryContext(r.Context(),
|
|
`SELECT n.id, n.title, n.page_size, n.synced_at,
|
|
(SELECT COUNT(*) FROM pages WHERE notebook_id = n.id)
|
|
FROM notebooks n WHERE n.user_id = ? ORDER BY n.synced_at DESC`, userID)
|
|
if err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
type notebook struct {
|
|
ID int64
|
|
Title string
|
|
PageSize string
|
|
SyncedAt string
|
|
PageCount int
|
|
}
|
|
var notebooks []notebook
|
|
for rows.Next() {
|
|
var nb notebook
|
|
var syncedAt int64
|
|
if err := rows.Scan(&nb.ID, &nb.Title, &nb.PageSize, &syncedAt, &nb.PageCount); err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
nb.SyncedAt = time.UnixMilli(syncedAt).Format("2006-01-02 15:04")
|
|
notebooks = append(notebooks, nb)
|
|
}
|
|
|
|
ws.render(w, "notebooks.html", map[string]any{"Notebooks": notebooks})
|
|
}
|
|
|
|
func (ws *WebServer) handleNotebook(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
|
|
var title, pageSize string
|
|
err := ws.db.QueryRow("SELECT title, page_size FROM notebooks WHERE id = ?", id).Scan(&title, &pageSize)
|
|
if err != nil {
|
|
http.Error(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
rows, err := ws.db.Query("SELECT page_number FROM pages WHERE notebook_id = ? ORDER BY page_number", id)
|
|
if err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
type pageInfo struct {
|
|
Number int
|
|
SVGLink string
|
|
ViewLink string
|
|
}
|
|
var pages []pageInfo
|
|
for rows.Next() {
|
|
var num int
|
|
if err := rows.Scan(&num); err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
pages = append(pages, pageInfo{
|
|
Number: num,
|
|
SVGLink: fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, num),
|
|
ViewLink: fmt.Sprintf("/notebooks/%d/pages/%d", id, num),
|
|
})
|
|
}
|
|
|
|
ws.render(w, "notebook.html", map[string]any{
|
|
"Title": title,
|
|
"Pages": pages,
|
|
"PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id),
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) handlePage(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
num, _ := strconv.Atoi(chi.URLParam(r, "num"))
|
|
|
|
var title string
|
|
err := ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", id).Scan(&title)
|
|
if err != nil {
|
|
http.Error(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
ws.render(w, "page.html", map[string]any{
|
|
"NotebookTitle": title,
|
|
"PageNumber": num,
|
|
"BackLink": fmt.Sprintf("/notebooks/%d", id),
|
|
"SVGLink": fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, num),
|
|
"JPGLink": fmt.Sprintf("/v1/notebooks/%d/pages/%d/jpg", id, num),
|
|
"PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id),
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) handleShareNotebook(w http.ResponseWriter, r *http.Request) {
|
|
token := chi.URLParam(r, "token")
|
|
notebookID, err := share.ValidateLink(ws.db, token)
|
|
if err != nil {
|
|
http.Error(w, "Link not found or expired", http.StatusGone)
|
|
return
|
|
}
|
|
|
|
var title string
|
|
_ = ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", notebookID).Scan(&title)
|
|
|
|
rows, err := ws.db.Query("SELECT page_number FROM pages WHERE notebook_id = ? ORDER BY page_number", notebookID)
|
|
if err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
type pageInfo struct {
|
|
Number int
|
|
SVGLink string
|
|
ViewLink string
|
|
}
|
|
var pages []pageInfo
|
|
for rows.Next() {
|
|
var num int
|
|
if err := rows.Scan(&num); err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
pages = append(pages, pageInfo{
|
|
Number: num,
|
|
SVGLink: fmt.Sprintf("/s/%s/pages/%d/svg", token, num),
|
|
ViewLink: fmt.Sprintf("/s/%s/pages/%d", token, num),
|
|
})
|
|
}
|
|
|
|
ws.render(w, "notebook.html", map[string]any{
|
|
"Title": title,
|
|
"Pages": pages,
|
|
"PDFLink": fmt.Sprintf("/s/%s/pdf", token),
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) handleSharePage(w http.ResponseWriter, r *http.Request) {
|
|
token := chi.URLParam(r, "token")
|
|
notebookID, err := share.ValidateLink(ws.db, token)
|
|
if err != nil {
|
|
http.Error(w, "Link not found or expired", http.StatusGone)
|
|
return
|
|
}
|
|
|
|
num, _ := strconv.Atoi(chi.URLParam(r, "num"))
|
|
|
|
var title string
|
|
_ = ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", notebookID).Scan(&title)
|
|
|
|
ws.render(w, "page.html", map[string]any{
|
|
"NotebookTitle": title,
|
|
"PageNumber": num,
|
|
"BackLink": fmt.Sprintf("/s/%s", token),
|
|
"SVGLink": fmt.Sprintf("/s/%s/pages/%d/svg", token, num),
|
|
"JPGLink": fmt.Sprintf("/s/%s/pages/%d/jpg", token, num),
|
|
"PDFLink": fmt.Sprintf("/s/%s/pdf", token),
|
|
})
|
|
}
|
|
|
|
// --- auth middleware ---
|
|
|
|
type ctxKey string
|
|
|
|
const userIDKey ctxKey = "user_id"
|
|
|
|
func (ws *WebServer) authMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
userID, err := auth.ValidateToken(ws.db, cookie.Value)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
ctx = context.WithValue(ctx, userIDKey, userID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) render(w http.ResponseWriter, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := ws.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
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)
|
|
}
|