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

@@ -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
}