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" ) // 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, 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 } 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 := clientIP(r) if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil { u.logger.Warn("write audit event", "type", eventType, "error", err) } }