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:
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
)
|
||||
|
||||
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
|
||||
@@ -44,8 +45,9 @@ func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.FormValue("password")
|
||||
accountTypeStr := r.FormValue("account_type")
|
||||
|
||||
if username == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "username is required")
|
||||
// Security (F-12): validate username length and character set.
|
||||
if err := validate.Username(username); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,6 +58,11 @@ func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var passwordHash string
|
||||
if password != "" {
|
||||
// Security (F-13): enforce minimum length before hashing.
|
||||
if err := validate.Password(password); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
argonCfg := auth.ArgonParams{
|
||||
Time: u.cfg.Argon2.Time,
|
||||
Memory: u.cfg.Argon2.Memory,
|
||||
|
||||
@@ -71,10 +71,23 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
@@ -138,6 +151,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -171,6 +185,9 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user