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.
This commit is contained in:
2026-03-11 20:59:26 -07:00
parent 6e690c4435
commit 0ad9ef1bb4
13 changed files with 1487 additions and 15 deletions

View File

@@ -26,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/internal/validate"
"git.wntrmute.dev/kyle/mcias/web"
)
@@ -67,18 +68,29 @@ func (s *Server) Handler() http.Handler {
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.
// Files are read from the embedded web/static filesystem at startup so that
// the handlers can write bytes directly without any redirect logic.
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")
docsHTML, err := fs.ReadFile(staticFS, "docs.html")
if err != nil {
panic(fmt.Sprintf("server: read docs.html: %v", err))
}
specYAML, err := fs.ReadFile(staticFS, "openapi.yaml")
if err != nil {
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
}
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(docsHTML)
})
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
http.ServeFileFS(w, r, staticFS, "openapi.yaml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(specYAML)
})
// Authenticated endpoints.
@@ -189,11 +201,26 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Security: check per-account lockout before running Argon2 (F-08).
// We still run a dummy Argon2 to equalise timing so an attacker cannot
// distinguish a locked account from a non-existent one.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check", "error", lockErr)
}
if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
return
}
// Verify password. This is always run, even for system accounts (which have
// no password hash), to maintain constant timing.
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
if err != nil || !ok {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
@@ -202,6 +229,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if acct.TOTPRequired {
if req.TOTPCode == "" {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required")
return
}
@@ -215,11 +243,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
valid, err := auth.ValidateTOTP(secret, req.TOTPCode)
if err != nil || !valid {
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
// Login succeeded: clear any outstanding failure counter.
_ = s.db.ClearLoginFailures(acct.ID)
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
roles, err := s.db.GetRoles(acct.ID)
@@ -489,8 +521,10 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
return
}
if req.Username == "" {
middleware.WriteError(w, http.StatusBadRequest, "username is required", "bad_request")
// Security (F-12): validate username length and character set before any DB
// operation to prevent log injection, stored-XSS, and storage abuse.
if err := validate.Username(req.Username); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
accountType := model.AccountType(req.Type)
@@ -505,6 +539,11 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
middleware.WriteError(w, http.StatusBadRequest, "password is required for human accounts", "bad_request")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(req.Password); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
var err error
passwordHash, err = auth.HashPassword(req.Password, auth.ArgonParams{
Time: s.cfg.Argon2.Time,