Files
mcias/internal/ui/handlers_auth.go
Kyle Isom 0ad9ef1bb4 Fix F-08, F-12, F-13: Implement account lockout, username validation, and password minimum length enforcement
- Added failed login tracking for account lockout enforcement in `db` and `ui` layers; introduced `failed_logins` table to store attempts, window start, and attempt count.
- Updated login checks in `grpcserver/auth.go` and `ui/handlers_auth.go` to reject requests if the account is locked.
- Added immediate failure counter reset on successful login.
- Implemented username length and character set validation (F-12) and minimum password length enforcement (F-13) in shared `validate` package.
- Updated account creation and edit flows in `ui` and `grpcserver` layers to apply validation before hashing/processing.
- Added comprehensive unit tests for lockout, validation, and related edge cases.
- Updated `AUDIT.md` to mark F-08, F-12, and F-13 as fixed.
- Updated `openapi.yaml` to reflect new validation and lockout behaviors.

Security: Prevents brute-force attacks via lockout mechanism and strengthens defenses against weak and invalid input.
2026-03-11 20:59:26 -07:00

258 lines
9.1 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"
)
// 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)
}
}