- Trusted proxy config option for proxy-aware IP extraction used by rate limiting and audit logs; validates proxy IP before trusting X-Forwarded-For / X-Real-IP headers - TOTP replay protection via counter-based validation to reject reused codes within the same time step (±30s) - RateLimit middleware updated to extract client IP from proxy headers without IP spoofing risk - New tests for ClientIP proxy logic (spoofed headers, fallback) and extended rate-limit proxy coverage - HTMX error banner script integrated into web UI base - .gitignore updated for mciasdb build artifact Security: resolves CRIT-01 (TOTP replay attack) and DEF-03 (proxy-unaware rate limiting); gRPC TOTP enrollment aligned with REST via StorePendingTOTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
400 lines
14 KiB
Go
400 lines
14 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
|
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
|
)
|
|
|
|
// handleLoginPage renders the login form.
|
|
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
|
u.render(w, "login", LoginData{})
|
|
}
|
|
|
|
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
|
|
//
|
|
// 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, "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")
|
|
|
|
if username == "" || password == "" {
|
|
u.render(w, "login", LoginData{Error: "username and password are required"})
|
|
return
|
|
}
|
|
|
|
// Load account by username.
|
|
acct, err := u.db.GetAccountByUsername(username)
|
|
if err != nil {
|
|
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
|
_, _ = 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"})
|
|
return
|
|
}
|
|
|
|
// Security: check account status before credential verification.
|
|
if acct.Status != model.AccountStatusActive {
|
|
_, _ = 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
|
|
}
|
|
|
|
// Security: check per-account lockout before running Argon2 (F-08).
|
|
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
|
if lockErr != nil {
|
|
u.logger.Error("lockout check", "error", lockErr)
|
|
}
|
|
if locked {
|
|
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
|
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
|
|
u.render(w, "login", LoginData{Error: "account temporarily locked, please try again later"})
|
|
return
|
|
}
|
|
|
|
// Verify password.
|
|
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
|
if err != nil || !ok {
|
|
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
|
_ = u.db.RecordLoginFailure(acct.ID)
|
|
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
// 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 {
|
|
nonce, err := u.issueTOTPNonce(acct.ID)
|
|
if err != nil {
|
|
u.logger.Error("issue TOTP nonce", "error", err)
|
|
u.render(w, "login", LoginData{Error: "internal error"})
|
|
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, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
|
|
if err != nil || !valid {
|
|
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
|
_ = u.db.RecordLoginFailure(acct.ID)
|
|
// 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
|
|
}
|
|
// Security (CRIT-01): reject replay of a code already used within its
|
|
// ±30-second validity window.
|
|
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
|
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
|
|
_ = u.db.RecordLoginFailure(acct.ID)
|
|
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)
|
|
if err != nil {
|
|
u.render(w, "login", LoginData{Error: "internal error"})
|
|
return
|
|
}
|
|
for _, rol := range roles {
|
|
if rol == "admin" {
|
|
expiry = u.cfg.AdminExpiry()
|
|
break
|
|
}
|
|
}
|
|
|
|
// Login succeeded: clear any outstanding failure counter.
|
|
_ = u.db.ClearLoginFailures(acct.ID)
|
|
|
|
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
|
if err != nil {
|
|
u.logger.Error("issue token", "error", err)
|
|
u.render(w, "login", LoginData{Error: "internal error"})
|
|
return
|
|
}
|
|
|
|
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
u.logger.Error("track token", "error", err)
|
|
u.render(w, "login", LoginData{Error: "internal error"})
|
|
return
|
|
}
|
|
|
|
// Security: set session cookie as HttpOnly, Secure, SameSite=Strict.
|
|
// Path=/ so it is sent on all UI routes (not just /ui/*).
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: tokenStr,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: claims.ExpiresAt,
|
|
})
|
|
|
|
// Set CSRF tokens for subsequent requests.
|
|
if _, err := u.setCSRFCookies(w); err != nil {
|
|
u.logger.Error("set CSRF cookie", "error", err)
|
|
}
|
|
|
|
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
|
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
|
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI))
|
|
|
|
// Redirect to dashboard.
|
|
if isHTMX(r) {
|
|
w.Header().Set("HX-Redirect", "/dashboard")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
}
|
|
|
|
// handleLogout revokes the session token and clears the cookie.
|
|
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err == nil && cookie.Value != "" {
|
|
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
|
if err == nil {
|
|
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
|
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
|
}
|
|
u.writeAudit(r, model.EventTokenRevoked, nil, nil,
|
|
fmt.Sprintf(`{"jti":%q,"reason":"ui_logout"}`, claims.JTI))
|
|
}
|
|
}
|
|
u.clearSessionCookie(w)
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
}
|
|
|
|
// writeAudit is a fire-and-forget audit log helper for the UI package.
|
|
func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
|
ip := u.clientIP(r)
|
|
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
|
u.logger.Warn("write audit event", "type", eventType, "error", err)
|
|
}
|
|
}
|
|
|
|
// handleProfilePage renders the profile page for the currently logged-in user.
|
|
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
|
csrfToken, _ := u.setCSRFCookies(w)
|
|
u.render(w, "profile", ProfileData{
|
|
PageData: PageData{
|
|
CSRFToken: csrfToken,
|
|
ActorName: u.actorName(r),
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleSelfChangePassword allows an authenticated human user to change their
|
|
// own password. The current password must be supplied to prevent a stolen
|
|
// session token from being used to take over an account.
|
|
//
|
|
// Security: current password is verified with Argon2id (constant-time) before
|
|
// the new hash is written. Lockout is checked first so the endpoint cannot
|
|
// be used to brute-force the existing password. On success all other active
|
|
// sessions are revoked; the caller's own session is preserved so they remain
|
|
// logged in. The plaintext passwords are never logged or returned.
|
|
func (u *UIServer) handleSelfChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
|
if err := r.ParseForm(); err != nil {
|
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
|
return
|
|
}
|
|
|
|
claims := claimsFromContext(r.Context())
|
|
if claims == nil {
|
|
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
|
return
|
|
}
|
|
if acct.AccountType != model.AccountTypeHuman {
|
|
u.renderError(w, r, http.StatusBadRequest, "password change is only available for human accounts")
|
|
return
|
|
}
|
|
|
|
currentPassword := r.FormValue("current_password")
|
|
newPassword := r.FormValue("new_password")
|
|
confirmPassword := r.FormValue("confirm_password")
|
|
|
|
if currentPassword == "" || newPassword == "" {
|
|
u.renderError(w, r, http.StatusBadRequest, "current and new password are required")
|
|
return
|
|
}
|
|
// Server-side confirmation check mirrors the client-side guard; defends
|
|
// against direct POST requests that bypass the JavaScript validation.
|
|
if newPassword != confirmPassword {
|
|
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
|
|
return
|
|
}
|
|
|
|
// Security: check lockout before running Argon2 to prevent brute-force.
|
|
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
|
if lockErr != nil {
|
|
u.logger.Error("lockout check (UI self-service password change)", "error", lockErr)
|
|
}
|
|
if locked {
|
|
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
|
u.renderError(w, r, http.StatusTooManyRequests, "account temporarily locked, please try again later")
|
|
return
|
|
}
|
|
|
|
// Security: verify current password with constant-time Argon2id path used
|
|
// at login so this endpoint cannot serve as a timing oracle.
|
|
ok, verifyErr := auth.VerifyPassword(currentPassword, acct.PasswordHash)
|
|
if verifyErr != nil || !ok {
|
|
_ = u.db.RecordLoginFailure(acct.ID)
|
|
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
|
u.renderError(w, r, http.StatusUnauthorized, "current password is incorrect")
|
|
return
|
|
}
|
|
|
|
// Security (F-13): enforce minimum length before hashing.
|
|
if err := validate.Password(newPassword); err != nil {
|
|
u.renderError(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
|
|
Time: u.cfg.Argon2.Time,
|
|
Memory: u.cfg.Argon2.Memory,
|
|
Threads: u.cfg.Argon2.Threads,
|
|
})
|
|
if err != nil {
|
|
u.logger.Error("hash password (UI self-service)", "error", err)
|
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
|
|
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
|
u.logger.Error("update password hash", "error", err)
|
|
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
|
|
return
|
|
}
|
|
|
|
// Security: clear failure counter (user proved knowledge of current
|
|
// password), then revoke all sessions except the current one so stale
|
|
// tokens are invalidated while the caller stays logged in.
|
|
_ = u.db.ClearLoginFailures(acct.ID)
|
|
if err := u.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
|
u.logger.Error("revoke other tokens on UI password change", "account_id", acct.ID, "error", err)
|
|
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
|
|
return
|
|
}
|
|
|
|
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"ui_self_service"}`)
|
|
|
|
csrfToken, _ := u.setCSRFCookies(w)
|
|
u.render(w, "password_change_result", ProfileData{
|
|
PageData: PageData{
|
|
CSRFToken: csrfToken,
|
|
ActorName: u.actorName(r),
|
|
Flash: "Password updated successfully. Other active sessions have been revoked.",
|
|
},
|
|
})
|
|
}
|