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:
@@ -479,6 +479,87 @@ func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
// ---- Login lockout (F-08) ----
|
||||
|
||||
// LockoutWindow is the rolling window for failed-login counting.
|
||||
// LockoutThreshold is the number of failures within the window that triggers a lockout.
|
||||
// LockoutDuration is how long the lockout lasts after threshold is reached.
|
||||
// These are package-level vars (not consts) so tests can override them.
|
||||
//
|
||||
// Security: 10 failures in 15 minutes is conservative for a personal SSO; it
|
||||
// stops fast dictionary attacks while rarely affecting legitimate users.
|
||||
var (
|
||||
LockoutWindow = 15 * time.Minute
|
||||
LockoutThreshold = 10
|
||||
LockoutDuration = 15 * time.Minute
|
||||
)
|
||||
|
||||
// IsLockedOut returns true if the account has exceeded the failed-login
|
||||
// threshold within the current window and the lockout period has not expired.
|
||||
func (db *DB) IsLockedOut(accountID int64) (bool, error) {
|
||||
var windowStartStr string
|
||||
var count int
|
||||
err := db.sql.QueryRow(`
|
||||
SELECT window_start, attempt_count FROM failed_logins WHERE account_id = ?
|
||||
`, accountID).Scan(&windowStartStr, &count)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: is locked out %d: %w", accountID, err)
|
||||
}
|
||||
|
||||
windowStart, err := parseTime(windowStartStr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Window has expired — not locked out.
|
||||
if time.Since(windowStart) > LockoutWindow+LockoutDuration {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Under threshold — not locked out.
|
||||
if count < LockoutThreshold {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Threshold exceeded; locked out until window_start + LockoutDuration.
|
||||
lockedUntil := windowStart.Add(LockoutDuration)
|
||||
return time.Now().Before(lockedUntil), nil
|
||||
}
|
||||
|
||||
// RecordLoginFailure increments the failed-login counter for the account.
|
||||
// If the current window has expired a new window is started.
|
||||
func (db *DB) RecordLoginFailure(accountID int64) error {
|
||||
n := now()
|
||||
windowCutoff := time.Now().Add(-LockoutWindow).UTC().Format(time.RFC3339)
|
||||
|
||||
// Upsert: if a row exists and the window is still active, increment;
|
||||
// otherwise reset to a fresh window with count 1.
|
||||
_, err := db.sql.Exec(`
|
||||
INSERT INTO failed_logins (account_id, window_start, attempt_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
window_start = CASE WHEN window_start < ? THEN excluded.window_start ELSE window_start END,
|
||||
attempt_count = CASE WHEN window_start < ? THEN 1 ELSE attempt_count + 1 END
|
||||
`, accountID, n, windowCutoff, windowCutoff)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: record login failure %d: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearLoginFailures resets the failed-login counter for the account.
|
||||
// Called on successful login.
|
||||
func (db *DB) ClearLoginFailures(accountID int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM failed_logins WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: clear login failures %d: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteAuditEvent appends an audit log entry.
|
||||
// Details must never contain credential material.
|
||||
func (db *DB) WriteAuditEvent(eventType string, actorID, targetID *int64, ipAddress, details string) error {
|
||||
@@ -1010,3 +1091,78 @@ func (db *DB) GetSystemToken(accountID int64) (*model.SystemToken, error) {
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// Lockout parameters (package-level vars so tests can override them).
|
||||
//
|
||||
// Security (F-08): per-account failed-login tracking prevents brute-force
|
||||
// attacks. LockoutWindow defines the rolling window during which failures
|
||||
// are counted; LockoutThreshold is the number of failures that triggers a
|
||||
// lockout; LockoutDuration is how long the account remains locked after the
|
||||
// threshold is reached. All three are intentionally kept as vars (not
|
||||
// consts) so that tests can reduce them to millisecond-scale values without
|
||||
// recompiling.
|
||||
var (
|
||||
LockoutWindow = 15 * time.Minute
|
||||
LockoutThreshold = 10
|
||||
LockoutDuration = 15 * time.Minute
|
||||
)
|
||||
|
||||
// IsLockedOut returns true if accountID has exceeded LockoutThreshold
|
||||
// failures within the current LockoutWindow and the LockoutDuration has not
|
||||
// yet elapsed since the window opened.
|
||||
func (db *DB) IsLockedOut(accountID int64) (bool, error) {
|
||||
var windowStartStr string
|
||||
var count int
|
||||
err := db.sql.QueryRow(`
|
||||
SELECT window_start, attempt_count
|
||||
FROM failed_logins WHERE account_id = ?
|
||||
`, accountID).Scan(&windowStartStr, &count)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: is locked out %d: %w", accountID, err)
|
||||
}
|
||||
|
||||
windowStart, err := parseTime(windowStartStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: parse lockout window_start: %w", err)
|
||||
}
|
||||
|
||||
// The window has expired: the record is stale, the account is not locked.
|
||||
if time.Now().After(windowStart.Add(LockoutWindow)) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return count >= LockoutThreshold, nil
|
||||
}
|
||||
|
||||
// RecordLoginFailure increments the failure counter for accountID within the
|
||||
// current rolling window. If the window has expired the counter resets to 1
|
||||
// and the window_start is updated. Uses an UPSERT so the operation is safe
|
||||
// to call without a prior existence check.
|
||||
func (db *DB) RecordLoginFailure(accountID int64) error {
|
||||
n := now()
|
||||
windowCutoff := time.Now().Add(-LockoutWindow).UTC().Format(time.RFC3339)
|
||||
_, err := db.sql.Exec(`
|
||||
INSERT INTO failed_logins (account_id, window_start, attempt_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
window_start = CASE WHEN window_start < ? THEN excluded.window_start ELSE window_start END,
|
||||
attempt_count = CASE WHEN window_start < ? THEN 1 ELSE attempt_count + 1 END
|
||||
`, accountID, n, windowCutoff, windowCutoff)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: record login failure for account %d: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearLoginFailures removes the failure record for accountID. Called on a
|
||||
// successful login to reset the lockout state.
|
||||
func (db *DB) ClearLoginFailures(accountID int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM failed_logins WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: clear login failures for account %d: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
|
||||
-- The salt must be stable across restarts so the passphrase always yields the same key.
|
||||
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
|
||||
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sql: `
|
||||
-- Track per-account failed login attempts for lockout enforcement (F-08).
|
||||
-- One row per account; window_start resets when the window expires or on
|
||||
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
|
||||
CREATE TABLE IF NOT EXISTS failed_logins (
|
||||
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
window_start TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user