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:
@@ -473,3 +473,127 @@ func TestRevokeAllUserTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockoutNotLockedInitially verifies a fresh account is not locked out.
|
||||
func TestLockoutNotLockedInitially(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
acct, err := d.CreateAccount("locktest", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
locked, err := d.IsLockedOut(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLockedOut: %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Fatal("fresh account should not be locked out")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockoutThreshold verifies that IsLockedOut returns true after
|
||||
// LockoutThreshold failures within LockoutWindow.
|
||||
func TestLockoutThreshold(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
acct, err := d.CreateAccount("locktest2", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
// Use a short window so test runs fast.
|
||||
origWindow := LockoutWindow
|
||||
origThreshold := LockoutThreshold
|
||||
LockoutWindow = 5 * time.Second
|
||||
LockoutThreshold = 3
|
||||
t.Cleanup(func() {
|
||||
LockoutWindow = origWindow
|
||||
LockoutThreshold = origThreshold
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := d.RecordLoginFailure(acct.ID); err != nil {
|
||||
t.Fatalf("RecordLoginFailure %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
locked, err := d.IsLockedOut(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLockedOut: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("account should be locked after reaching threshold")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockoutClearedOnSuccess verifies ClearLoginFailures removes the record
|
||||
// and IsLockedOut returns false afterwards.
|
||||
func TestLockoutClearedOnSuccess(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
acct, err := d.CreateAccount("locktest3", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
origThreshold := LockoutThreshold
|
||||
LockoutThreshold = 2
|
||||
t.Cleanup(func() { LockoutThreshold = origThreshold })
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := d.RecordLoginFailure(acct.ID); err != nil {
|
||||
t.Fatalf("RecordLoginFailure %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
locked, err := d.IsLockedOut(acct.ID)
|
||||
if err != nil || !locked {
|
||||
t.Fatalf("expected locked=true, got locked=%v err=%v", locked, err)
|
||||
}
|
||||
|
||||
if err := d.ClearLoginFailures(acct.ID); err != nil {
|
||||
t.Fatalf("ClearLoginFailures: %v", err)
|
||||
}
|
||||
|
||||
locked, err = d.IsLockedOut(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLockedOut after clear: %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Fatal("account should not be locked after ClearLoginFailures")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockoutWindowExpiry verifies that a stale failure record (outside the
|
||||
// window) does not cause a lockout.
|
||||
func TestLockoutWindowExpiry(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
acct, err := d.CreateAccount("locktest4", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
origWindow := LockoutWindow
|
||||
origThreshold := LockoutThreshold
|
||||
LockoutWindow = 50 * time.Millisecond
|
||||
LockoutThreshold = 2
|
||||
t.Cleanup(func() {
|
||||
LockoutWindow = origWindow
|
||||
LockoutThreshold = origThreshold
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := d.RecordLoginFailure(acct.ID); err != nil {
|
||||
t.Fatalf("RecordLoginFailure %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the window to expire.
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
locked, err := d.IsLockedOut(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLockedOut after window expiry: %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Fatal("account should not be locked after window has expired")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user