Fix F-02: replace password-in-hidden-field with nonce

- ui/ui.go: add pendingLogin struct and pendingLogins sync.Map
  to UIServer; add issueTOTPNonce (generates 128-bit random nonce,
  stores accountID with 90s TTL) and consumeTOTPNonce (single-use,
  expiry-checked LoadAndDelete); add dummyHash() method
- ui/handlers_auth.go: split handleLoginPost into step 1
  (password verify → issue nonce) and step 2 (handleTOTPStep,
  consume nonce → validate TOTP) via a new finishLogin helper;
  password never transmitted or stored after step 1
- ui/ui_test.go: refactor newTestMux to reuse new
  newTestUIServer; add TestTOTPNonceIssuedAndConsumed,
  TestTOTPNonceUnknownRejected, TestTOTPNonceExpired, and
  TestLoginPostPasswordNotInTOTPForm; 11/11 tests pass
- web/templates/fragments/totp_step.html: replace
  'name=password' hidden field with 'name=totp_nonce'
- db/accounts.go: add GetAccountByID for TOTP step lookup
- AUDIT.md: mark F-02 as fixed
Security: the plaintext password previously survived two HTTP
  round-trips and lived in the browser DOM during the TOTP step.
  The nonce approach means the password is verified once and
  immediately discarded; only an opaque random token tied to an
  account ID (never a credential) crosses the wire on step 2.
  Nonces are single-use and expire after 90 seconds to limit
  the window if one is captured.
This commit is contained in:
2026-03-11 20:33:04 -07:00
parent bf9002a31c
commit d42f51fc83
10 changed files with 1877 additions and 54 deletions

View File

@@ -14,6 +14,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
@@ -25,6 +26,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/ui"
"git.wntrmute.dev/kyle/mcias/web"
)
// Server holds the dependencies injected into all handlers.
@@ -64,6 +66,21 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
// API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml.
// Both are served from the embedded web/static filesystem; no external
// files are read at runtime.
staticFS, err := fs.Sub(web.StaticFS, "static")
if err != nil {
panic(fmt.Sprintf("server: sub fs: %v", err))
}
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, staticFS, "docs.html")
})
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
http.ServeFileFS(w, r, staticFS, "openapi.yaml")
})
// Authenticated endpoints.
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
requireAdmin := func(h http.Handler) http.Handler {