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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user