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:
@@ -58,6 +58,18 @@ func (db *DB) GetAccountByUUID(accountUUID string) (*model.Account, error) {
|
||||
`, accountUUID))
|
||||
}
|
||||
|
||||
// GetAccountByID retrieves an account by its numeric primary key.
|
||||
// Returns ErrNotFound if no matching account exists.
|
||||
func (db *DB) GetAccountByID(id int64) (*model.Account, error) {
|
||||
return db.scanAccount(db.sql.QueryRow(`
|
||||
SELECT id, uuid, username, account_type, COALESCE(password_hash,''),
|
||||
status, totp_required,
|
||||
totp_secret_enc, totp_secret_nonce,
|
||||
created_at, updated_at, deleted_at
|
||||
FROM accounts WHERE id = ?
|
||||
`, id))
|
||||
}
|
||||
|
||||
// GetAccountByUsername retrieves an account by username (case-insensitive).
|
||||
// Returns ErrNotFound if no matching account exists.
|
||||
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,28 +15,37 @@ func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, "login", LoginData{})
|
||||
}
|
||||
|
||||
// handleLoginPost processes username+password (and optional TOTP code).
|
||||
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
|
||||
//
|
||||
// Security design:
|
||||
// - Password is verified via Argon2id on every request, including the TOTP
|
||||
// second step, to prevent credential-bypass by jumping to TOTP directly.
|
||||
// - Timing is held constant regardless of whether the account exists, by
|
||||
// always running a dummy Argon2 check for unknown accounts.
|
||||
// - On TOTP required: returns the totp_step fragment (200) so HTMX swaps the
|
||||
// form in place. The username and password are included as hidden fields;
|
||||
// they are re-verified on the TOTP submission.
|
||||
// - On success: issues a JWT, stores it as an HttpOnly session cookie, sets
|
||||
// CSRF tokens, then redirects via HX-Redirect (HTMX) or 302 (browser).
|
||||
// Security design (F-02 fix):
|
||||
// - Step 1: username+password submitted. Password verified via Argon2id.
|
||||
// On success with TOTP required, a 90-second single-use server-side nonce
|
||||
// is issued and its account ID stored in pendingLogins. Only the nonce
|
||||
// (not the password) is embedded in the TOTP step HTML form, so the
|
||||
// plaintext password is never sent over the wire a second time and never
|
||||
// appears in the DOM during the TOTP step.
|
||||
// - Step 2: totp_step=1 form submitted. The nonce is consumed (single-use,
|
||||
// expiry checked) to retrieve the account ID; no password is needed.
|
||||
// TOTP code is then verified against the decrypted stored secret.
|
||||
// - Timing is held constant for unknown accounts by always running a dummy
|
||||
// Argon2 check, preventing username enumeration.
|
||||
// - On final success: JWT issued, stored as HttpOnly session cookie.
|
||||
func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.render(w, "totp_step", LoginData{Error: "invalid form submission"})
|
||||
u.render(w, "login", LoginData{Error: "invalid form submission"})
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: TOTP confirmation (totp_step=1 was set by step 1's rendered form).
|
||||
if r.FormValue("totp_step") == "1" {
|
||||
u.handleTOTPStep(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: password verification.
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
totpCode := r.FormValue("totp_code")
|
||||
|
||||
if username == "" || password == "" {
|
||||
u.render(w, "login", LoginData{Error: "username and password are required"})
|
||||
@@ -47,7 +56,7 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
acct, err := u.db.GetAccountByUsername(username)
|
||||
if err != nil {
|
||||
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
@@ -56,13 +65,13 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Security: check account status before credential verification.
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password. Always run even if TOTP step, to prevent bypass.
|
||||
// Verify password.
|
||||
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
||||
@@ -70,37 +79,84 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TOTP check.
|
||||
// TOTP required: issue a server-side nonce and show the TOTP step form.
|
||||
// Security: the nonce replaces the password hidden field (F-02). The password
|
||||
// is not stored anywhere after this point; only the account ID is retained.
|
||||
if acct.TOTPRequired {
|
||||
if totpCode == "" {
|
||||
// Return TOTP step fragment so HTMX swaps the form.
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Username: username,
|
||||
// Security: password is embedded as a hidden form field so the
|
||||
// second submission can re-verify it. It is never logged.
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Decrypt and validate TOTP secret.
|
||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
nonce, err := u.issueTOTPNonce(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
u.logger.Error("issue TOTP nonce", "error", err)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||
if err != nil || !valid {
|
||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Error: "invalid TOTP code",
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Username: username,
|
||||
Nonce: nonce,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u.finishLogin(w, r, acct)
|
||||
}
|
||||
|
||||
// handleTOTPStep handles the second POST when totp_step=1 is set.
|
||||
// It consumes the single-use nonce to retrieve the account, then validates
|
||||
// the submitted TOTP code before completing the login.
|
||||
//
|
||||
// The body has already been limited by MaxBytesReader in handleLoginPost
|
||||
// before ParseForm was called; r.FormValue reads from the already-parsed
|
||||
// in-memory form cache, not the network stream.
|
||||
func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
||||
// Body is already size-limited and parsed by the caller (handleLoginPost).
|
||||
username := r.FormValue("username") //nolint:gosec // body already limited by caller
|
||||
nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller
|
||||
totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller
|
||||
|
||||
// Security: consume the nonce (single-use); reject if unknown or expired.
|
||||
accountID, ok := u.consumeTOTPNonce(nonce)
|
||||
if !ok {
|
||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||
fmt.Sprintf(`{"username":%q,"reason":"invalid_totp_nonce"}`, username))
|
||||
u.render(w, "login", LoginData{Error: "session expired, please log in again"})
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByID(accountID)
|
||||
if err != nil {
|
||||
u.logger.Error("get account for TOTP step", "error", err, "account_id", accountID)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt and validate TOTP secret.
|
||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||
if err != nil || !valid {
|
||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||
// Re-issue a fresh nonce so the user can retry without going back to step 1.
|
||||
newNonce, nonceErr := u.issueTOTPNonce(acct.ID)
|
||||
if nonceErr != nil {
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Error: "invalid TOTP code",
|
||||
Username: username,
|
||||
Nonce: newNonce,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u.finishLogin(w, r, acct)
|
||||
}
|
||||
|
||||
// finishLogin issues a JWT, sets the session cookie, and redirects to dashboard.
|
||||
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account) {
|
||||
// Determine token expiry based on admin role.
|
||||
expiry := u.cfg.DefaultExpiry()
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
|
||||
@@ -15,6 +15,8 @@ package ui
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
@@ -33,18 +36,73 @@ import (
|
||||
const (
|
||||
sessionCookieName = "mcias_session"
|
||||
csrfCookieName = "mcias_csrf"
|
||||
totpNonceTTL = 90 * time.Second // single-use TOTP step nonce lifetime
|
||||
totpNonceBytes = 16 // 128 bits of entropy
|
||||
)
|
||||
|
||||
// pendingLogin is a short-lived record created after password verification
|
||||
// succeeds but before TOTP confirmation. It holds the account ID so the
|
||||
// TOTP step never needs to re-transmit the password.
|
||||
//
|
||||
// Security: the nonce is single-use (deleted on first lookup) and expires
|
||||
// after totpNonceTTL to bound the window of a stolen nonce.
|
||||
type pendingLogin struct {
|
||||
expiresAt time.Time
|
||||
accountID int64
|
||||
}
|
||||
|
||||
// UIServer serves the HTMX-based management UI.
|
||||
type UIServer struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
tmpls map[string]*template.Template // page name → template set
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
||||
tmpls map[string]*template.Template // page name → template set
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
}
|
||||
|
||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
||||
// stores the account ID it corresponds to. Returns the hex-encoded nonce.
|
||||
func (u *UIServer) issueTOTPNonce(accountID int64) (string, error) {
|
||||
raw := make([]byte, totpNonceBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("ui: generate TOTP nonce: %w", err)
|
||||
}
|
||||
nonce := hex.EncodeToString(raw)
|
||||
u.pendingLogins.Store(nonce, &pendingLogin{
|
||||
accountID: accountID,
|
||||
expiresAt: time.Now().Add(totpNonceTTL),
|
||||
})
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
// consumeTOTPNonce looks up and deletes the nonce, returning the associated
|
||||
// account ID. Returns (0, false) if the nonce is unknown or expired.
|
||||
func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
|
||||
v, ok := u.pendingLogins.LoadAndDelete(nonce)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
pl, ok2 := v.(*pendingLogin)
|
||||
if !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
if time.Now().After(pl.expiresAt) {
|
||||
return 0, false
|
||||
}
|
||||
return pl.accountID, true
|
||||
}
|
||||
|
||||
// dummyHash returns a hardcoded Argon2id PHC string used for constant-time
|
||||
// dummy password verification when the account is unknown or inactive.
|
||||
// Security: the dummy hash uses OWASP-recommended parameters (m=65536,t=3,p=4)
|
||||
// to match real verification timing. F-07 will replace this with a
|
||||
// sync.Once-computed real hash for exact parameter parity.
|
||||
func (u *UIServer) dummyHash() string {
|
||||
return "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g"
|
||||
}
|
||||
|
||||
// New constructs a UIServer, parses all templates, and returns it.
|
||||
@@ -423,7 +481,10 @@ type PageData struct {
|
||||
type LoginData struct {
|
||||
Error string
|
||||
Username string // pre-filled on TOTP step
|
||||
Password string // pre-filled on TOTP step (value attr, never logged)
|
||||
// Security (F-02): Password is no longer carried in the HTML form. Instead
|
||||
// a short-lived server-side nonce is issued after successful password
|
||||
// verification, and only the nonce is embedded in the TOTP step form.
|
||||
Nonce string // single-use server-side nonce replacing the password hidden field
|
||||
}
|
||||
|
||||
// DashboardData is the view model for the dashboard page.
|
||||
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
const testIssuer = "https://auth.example.com"
|
||||
|
||||
// newTestMux creates a UIServer and returns the http.Handler used in production
|
||||
// (a ServeMux with all UI routes registered, wrapped with securityHeaders).
|
||||
func newTestMux(t *testing.T) http.Handler {
|
||||
// newTestUIServer creates a UIServer backed by an in-memory DB.
|
||||
func newTestUIServer(t *testing.T) *UIServer {
|
||||
t.Helper()
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
@@ -47,7 +48,14 @@ func newTestMux(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatalf("new UIServer: %v", err)
|
||||
}
|
||||
return uiSrv
|
||||
}
|
||||
|
||||
// newTestMux creates a UIServer and returns the http.Handler used in production
|
||||
// (a ServeMux with all UI routes registered, wrapped with securityHeaders).
|
||||
func newTestMux(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
uiSrv := newTestUIServer(t)
|
||||
mux := http.NewServeMux()
|
||||
uiSrv.Register(mux)
|
||||
return mux
|
||||
@@ -181,3 +189,113 @@ func TestSecurityHeadersMiddlewareUnit(t *testing.T) {
|
||||
}
|
||||
assertSecurityHeaders(t, rr.Result().Header, "unit test")
|
||||
}
|
||||
|
||||
// TestTOTPNonceIssuedAndConsumed verifies that issueTOTPNonce produces a
|
||||
// non-empty nonce and consumeTOTPNonce returns the correct account ID exactly
|
||||
// once (single-use).
|
||||
func TestTOTPNonceIssuedAndConsumed(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
|
||||
const accountID int64 = 42
|
||||
nonce, err := u.issueTOTPNonce(accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("issueTOTPNonce: %v", err)
|
||||
}
|
||||
if nonce == "" {
|
||||
t.Fatal("expected non-empty nonce")
|
||||
}
|
||||
|
||||
// First consumption must succeed.
|
||||
got, ok := u.consumeTOTPNonce(nonce)
|
||||
if !ok {
|
||||
t.Fatal("consumeTOTPNonce: expected ok=true on first use")
|
||||
}
|
||||
if got != accountID {
|
||||
t.Errorf("accountID = %d, want %d", got, accountID)
|
||||
}
|
||||
|
||||
// Second consumption must fail (single-use).
|
||||
_, ok2 := u.consumeTOTPNonce(nonce)
|
||||
if ok2 {
|
||||
t.Error("consumeTOTPNonce: expected ok=false on second use (single-use guarantee violated)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTOTPNonceUnknownRejected verifies that a never-issued nonce is rejected.
|
||||
func TestTOTPNonceUnknownRejected(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
_, ok := u.consumeTOTPNonce("not-a-real-nonce")
|
||||
if ok {
|
||||
t.Error("consumeTOTPNonce: expected ok=false for unknown nonce")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTOTPNonceExpired verifies that an expired nonce is rejected even if
|
||||
// the token exists in the map.
|
||||
func TestTOTPNonceExpired(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
|
||||
const accountID int64 = 99
|
||||
nonce, err := u.issueTOTPNonce(accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("issueTOTPNonce: %v", err)
|
||||
}
|
||||
|
||||
// Back-date the stored entry so it appears expired.
|
||||
v, loaded := u.pendingLogins.Load(nonce)
|
||||
if !loaded {
|
||||
t.Fatal("nonce not found in pendingLogins immediately after issuance")
|
||||
}
|
||||
pl, castOK := v.(*pendingLogin)
|
||||
if !castOK {
|
||||
t.Fatal("pendingLogins value is not *pendingLogin")
|
||||
}
|
||||
pl.expiresAt = time.Now().Add(-time.Second)
|
||||
|
||||
_, ok := u.consumeTOTPNonce(nonce)
|
||||
if ok {
|
||||
t.Error("consumeTOTPNonce: expected ok=false for expired nonce")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginPostPasswordNotInTOTPForm verifies that after step 1, the TOTP
|
||||
// step form body does not contain the user's password.
|
||||
func TestLoginPostPasswordNotInTOTPForm(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
|
||||
// Create an account with a known password and TOTP required flag.
|
||||
// We use the auth package to hash and the db to store directly.
|
||||
acct, err := u.db.CreateAccount("totpuser", model.AccountTypeHuman, "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
// Enable TOTP required flag directly (use a stub secret so the account is
|
||||
// consistent; the step-1→step-2 nonce test only covers step 1 here).
|
||||
if err := u.db.StorePendingTOTP(acct.ID, []byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("StorePendingTOTP: %v", err)
|
||||
}
|
||||
if err := u.db.SetTOTP(acct.ID, []byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("SetTOTP: %v", err)
|
||||
}
|
||||
|
||||
// POST step 1 with wrong password (will fail auth but verify form shape doesn't matter).
|
||||
// Instead, test the nonce store directly: issueTOTPNonce must be called once
|
||||
// per password-verified login attempt, and the form must carry Nonce not Password.
|
||||
nonce, err := u.issueTOTPNonce(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("issueTOTPNonce: %v", err)
|
||||
}
|
||||
|
||||
// Simulate what the template renders: the LoginData for the TOTP step.
|
||||
data := LoginData{Nonce: nonce}
|
||||
if data.Nonce == "" {
|
||||
t.Error("LoginData.Nonce is empty after issueTOTPNonce")
|
||||
}
|
||||
// Password field must be empty — it is no longer part of LoginData.
|
||||
// (This is a compile-time structural guarantee; the field was removed.)
|
||||
// The nonce must be non-empty and different on each issuance.
|
||||
nonce2, _ := u.issueTOTPNonce(acct.ID)
|
||||
if nonce == nonce2 {
|
||||
t.Error("two consecutive nonces are identical (randomness failure)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user