Skip to content

Commit 9354131

Browse files
sarg3ntclaude
andauthored
fix(security): P2-4 — sliding-window login rate limit + generic lockout error (#53)
* fix(security): sliding-window login rate limit + generic lockout error Two related changes for the per-account brute-force defense (2026-05 audit P2-4). Previously the failed-attempt counter incremented monotonically until a successful login, with a hard lockout at 5 attempts → 15 minutes. That's solid against single-IP brute force (the global IP rate limiter caps a single source at 50 req/sec, so an attacker reaches 5 failures within a fraction of a second and gets the 15-minute lockout). But: 1. UX is bad — a legitimate user who mistypes their password twice today and twice next week is locked out without the lockout window ever resetting. 2. The hard error message "account is temporarily locked due to too many failed attempts" was an enumeration oracle: a network observer running email→password sprays could distinguish "this email is locked" (which implies it exists) from "this email doesn't exist." Fix: - New `last_failed_attempt DATETIME` column on users via golang-migrate migration 000001. Tracks the timestamp of the most recent failure. - RecordLoginAttempt now reads previous state under the existing DB mu, computes a NEW count based on whether the previous failure was inside the 5-minute sliding window (reset to 1 if outside), and applies a tiered cooldown: count 3 within window → 1 minute cooldown count 4 within window → 5 minute cooldown count 5+ → 15 minute hard lockout (existing) - Login() now returns the SAME generic "invalid credentials" error for the locked-account path as for wrong-password and missing-user. The lockout is still ENFORCED — the attacker just doesn't learn it from the response. RecordLoginAttempt is still called on locked-account hits so probes against a locked account count against its window and can't be used as a rate-free oracle. The new column is intentionally NOT surfaced to the User model or any API response — lockout state stays server-side. Tests cover: - window-reset (>5min ago → count restarts at 1, not increments) - tiered cooldown (count=3 sets locked_until ~60s, count=4 ~5m, count=5+ ~15m) - locked-account login returns generic "invalid credentials", never mentions "lock" / "locked" / "attempt" P2-4 from the 2026-05 security audit. * fix(security): close P2-4 follow-ups from Copilot review Three real issues plus test hygiene: 1. RecordLoginAttempt no longer clears a still-active lockout when the 5-min sliding window resets the count. Previously, waiting 6 min mid-15-min-lockout and failing once more would set newCount=1 and write locked_until=NULL, releasing the lock 9 min early. We now SELECT the existing locked_until and keep max(existing, newly computed). Regression test added. 2. Down migration is now a hard failure instead of a comments-only no-op. Running it as a no-op would silently decrement the golang-migrate schema version while last_failed_attempt remains — confusing/unsafe rollback state. 3. Constant-time Login: always run CheckPassword exactly once, even on missing-user / locked-account paths, against a precomputed dummy hash when no real user exists. Without this, "user missing" and "account locked" returned in microseconds while "exists + wrong password" did the full ~100ms bcrypt — a timing oracle that defeated the generic error message. 4. Tests now assert errors from HashPassword / GetUserByEmail / RecordLoginAttempt setup so failures point to the root cause. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a114726 commit 9354131

5 files changed

Lines changed: 367 additions & 14 deletions

File tree

gearbox/internal/framework/auth/auth.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ const (
2828
LockoutDuration = 15 * time.Minute
2929
)
3030

31+
// dummyPasswordHash is a bcrypt hash used to keep the Login() codepath
32+
// taking constant bcrypt time even when the user does not exist. Without
33+
// it, "user missing" returns in microseconds while "user exists + wrong
34+
// password" runs a ~100ms bcrypt compare — an obvious timing oracle for
35+
// account enumeration. Generated once at package init; the underlying
36+
// plaintext is never used. See 2026-05 audit P2-4 follow-up.
37+
var dummyPasswordHash string
38+
39+
func init() {
40+
h, err := HashPassword("timing-equivalence-dummy-not-a-real-password")
41+
if err != nil {
42+
panic(fmt.Sprintf("auth init: failed to generate dummy password hash: %v", err))
43+
}
44+
dummyPasswordHash = h
45+
}
46+
3147
// Manager handles authentication and session management.
3248
type Manager struct {
3349
db *database.DB
@@ -87,14 +103,37 @@ func (m *Manager) Login(w http.ResponseWriter, r *http.Request, email, password
87103
return nil, fmt.Errorf("database error: %w", err)
88104
}
89105

106+
// Run CheckPassword exactly once on every Login call regardless of
107+
// user state. Without this, "user missing" and "account locked" return
108+
// in microseconds while "exists + wrong password" runs a ~100ms bcrypt
109+
// compare — a timing oracle that defeats the generic error message.
110+
// See 2026-05 audit P2-4 follow-up.
111+
passwordHash := dummyPasswordHash
112+
if user != nil {
113+
passwordHash = user.PasswordHash
114+
}
115+
passwordOK := CheckPassword(password, passwordHash)
116+
90117
if user == nil {
91118
// Don't reveal that the user doesn't exist
92119
return nil, fmt.Errorf("invalid credentials")
93120
}
94121

95-
// Check if account is locked
122+
// Check if account is locked. Return the SAME generic error as the
123+
// "user not found" and "wrong password" paths so an attacker can't
124+
// distinguish "this email is locked" (which implies it exists) from
125+
// "this email doesn't exist" via the error message. The lockout itself
126+
// is still enforced — the attacker just doesn't learn it from the
127+
// response. See 2026-05 audit P2-4.
96128
if user.IsLocked() {
97-
return nil, fmt.Errorf("account is temporarily locked due to too many failed attempts")
129+
// Record the attempt (counts against the window) so an attacker
130+
// can't bypass the rate limit by repeatedly probing a locked
131+
// account with no cost.
132+
if err := m.db.RecordLoginAttempt(user.ID, false); err != nil {
133+
m.logger.Error("failed to record locked-attempt", "error", err, "user_id", user.ID)
134+
}
135+
m.logAudit(r, &user.ID, models.AuditActionLoginFailed, "")
136+
return nil, fmt.Errorf("invalid credentials")
98137
}
99138

100139
// Check if account is active
@@ -105,8 +144,8 @@ func (m *Manager) Login(w http.ResponseWriter, r *http.Request, email, password
105144
return nil, fmt.Errorf("account is disabled")
106145
}
107146

108-
// Validate password
109-
if !CheckPassword(password, user.PasswordHash) {
147+
// Validate password (result computed above for constant-time path)
148+
if !passwordOK {
110149
// Record failed attempt
111150
if err := m.db.RecordLoginAttempt(user.ID, false); err != nil {
112151
m.logger.Error("failed to record login attempt", "error", err, "user_id", user.ID)

gearbox/internal/framework/auth/auth_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"log/slog"
55
"net/http/httptest"
66
"os"
7+
"strings"
78
"testing"
89
"time"
910

@@ -465,3 +466,219 @@ func TestGenerateToken(t *testing.T) {
465466
t.Errorf("Token seems too short: %d characters", len(token1))
466467
}
467468
}
469+
470+
// 2026-05 audit P2-4: failed-attempt count resets when the previous
471+
// failure was outside the sliding window.
472+
func TestRecordLoginAttempt_WindowReset(t *testing.T) {
473+
manager, db, cleanup := setupTestManager(t)
474+
defer cleanup()
475+
_ = manager // we only need the DB; manager.db is the same instance
476+
477+
passwordHash, err := HashPassword("correct_password")
478+
if err != nil {
479+
t.Fatalf("HashPassword: %v", err)
480+
}
481+
if _, _, err := db.EnsureAdminExists(passwordHash, false); err != nil {
482+
t.Fatalf("EnsureAdminExists: %v", err)
483+
}
484+
user, err := db.GetUserByEmail("admin")
485+
if err != nil || user == nil {
486+
t.Fatalf("GetUserByEmail: %v", err)
487+
}
488+
489+
// 2 fresh failures: count = 2, no cooldown yet.
490+
for i := 0; i < 2; i++ {
491+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
492+
t.Fatalf("RecordLoginAttempt[%d]: %v", i, err)
493+
}
494+
}
495+
u, _ := db.GetUserByID(user.ID)
496+
if u.FailedLoginAttempts != 2 {
497+
t.Errorf("after 2 failures: FailedLoginAttempts = %d, want 2", u.FailedLoginAttempts)
498+
}
499+
if u.IsLocked() {
500+
t.Errorf("after 2 failures: IsLocked = true; want false (below threshold)")
501+
}
502+
503+
// Rewind last_failed_attempt to >5min ago via direct DB update — the
504+
// next failure should reset count to 1, not increment to 3.
505+
if _, err := db.GetDB().Exec(
506+
`UPDATE users SET last_failed_attempt = datetime('now', '-10 minutes') WHERE id = ?`, user.ID,
507+
); err != nil {
508+
t.Fatalf("rewind last_failed_attempt: %v", err)
509+
}
510+
511+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
512+
t.Fatalf("RecordLoginAttempt after rewind: %v", err)
513+
}
514+
u, _ = db.GetUserByID(user.ID)
515+
if u.FailedLoginAttempts != 1 {
516+
t.Errorf("after window-reset: FailedLoginAttempts = %d, want 1 (count restarted)", u.FailedLoginAttempts)
517+
}
518+
}
519+
520+
// Cooldown tiers: count=3 → 1m cooldown, count=4 → 5m, count=5+ → 15m.
521+
func TestRecordLoginAttempt_TieredCooldown(t *testing.T) {
522+
manager, db, cleanup := setupTestManager(t)
523+
defer cleanup()
524+
_ = manager
525+
526+
passwordHash, err := HashPassword("correct_password")
527+
if err != nil {
528+
t.Fatalf("HashPassword: %v", err)
529+
}
530+
if _, _, err := db.EnsureAdminExists(passwordHash, false); err != nil {
531+
t.Fatalf("EnsureAdminExists: %v", err)
532+
}
533+
user, err := db.GetUserByEmail("admin")
534+
if err != nil || user == nil {
535+
t.Fatalf("GetUserByEmail: user=%v err=%v", user, err)
536+
}
537+
538+
// 2 failures: still no cooldown.
539+
for i := 0; i < 2; i++ {
540+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
541+
t.Fatalf("RecordLoginAttempt[%d]: %v", i, err)
542+
}
543+
}
544+
u, _ := db.GetUserByID(user.ID)
545+
if u.LockedUntil != nil {
546+
t.Errorf("after 2 failures: LockedUntil set; want nil")
547+
}
548+
549+
// 3rd: ~1 minute cooldown.
550+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
551+
t.Fatalf("RecordLoginAttempt (3rd): %v", err)
552+
}
553+
u, _ = db.GetUserByID(user.ID)
554+
if u.LockedUntil == nil {
555+
t.Fatalf("after 3 failures: LockedUntil nil; want ~1min")
556+
}
557+
if d := time.Until(*u.LockedUntil); d < 50*time.Second || d > 70*time.Second {
558+
t.Errorf("after 3 failures: LockedUntil in %v, want ~60s", d)
559+
}
560+
561+
// 4th: ~5 minute cooldown.
562+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
563+
t.Fatalf("RecordLoginAttempt (4th): %v", err)
564+
}
565+
u, _ = db.GetUserByID(user.ID)
566+
if d := time.Until(*u.LockedUntil); d < 4*time.Minute+50*time.Second || d > 5*time.Minute+10*time.Second {
567+
t.Errorf("after 4 failures: LockedUntil in %v, want ~5min", d)
568+
}
569+
570+
// 5th: hard 15-minute lockout (existing behavior).
571+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
572+
t.Fatalf("RecordLoginAttempt (5th): %v", err)
573+
}
574+
u, _ = db.GetUserByID(user.ID)
575+
if d := time.Until(*u.LockedUntil); d < 14*time.Minute+50*time.Second || d > 15*time.Minute+10*time.Second {
576+
t.Errorf("after 5 failures: LockedUntil in %v, want ~15min", d)
577+
}
578+
}
579+
580+
// A still-active lockout MUST survive a sliding-window reset. The 15-min
581+
// hard lock outlives the 5-min failure window; an attacker could otherwise
582+
// wait 6 min mid-lockout, attempt one more failure, watch the count reset
583+
// to 1, and have locked_until cleared back to NULL — releasing the lock
584+
// 9 min early. Regression test for 2026-05 audit P2-4 follow-up.
585+
func TestRecordLoginAttempt_LockoutSurvivesWindowReset(t *testing.T) {
586+
manager, db, cleanup := setupTestManager(t)
587+
defer cleanup()
588+
_ = manager
589+
590+
passwordHash, err := HashPassword("correct_password")
591+
if err != nil {
592+
t.Fatalf("HashPassword: %v", err)
593+
}
594+
if _, _, err := db.EnsureAdminExists(passwordHash, false); err != nil {
595+
t.Fatalf("EnsureAdminExists: %v", err)
596+
}
597+
user, err := db.GetUserByEmail("admin")
598+
if err != nil || user == nil {
599+
t.Fatalf("GetUserByEmail: user=%v err=%v", user, err)
600+
}
601+
602+
// Trigger the 15-min hard lockout.
603+
for i := 0; i < 5; i++ {
604+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
605+
t.Fatalf("RecordLoginAttempt[%d]: %v", i, err)
606+
}
607+
}
608+
u, _ := db.GetUserByID(user.ID)
609+
if u.LockedUntil == nil {
610+
t.Fatal("setup: expected lockout after 5 failures")
611+
}
612+
originalLock := *u.LockedUntil
613+
614+
// Rewind last_failed_attempt to >5 min ago — the failure window has
615+
// expired, but the 15-min lockout has not.
616+
if _, err := db.GetDB().Exec(
617+
`UPDATE users SET last_failed_attempt = datetime('now', '-10 minutes') WHERE id = ?`,
618+
user.ID,
619+
); err != nil {
620+
t.Fatalf("rewind last_failed_attempt: %v", err)
621+
}
622+
623+
// One more failure: the count resets to 1 (window expired), but the
624+
// existing future locked_until MUST be preserved.
625+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
626+
t.Fatalf("RecordLoginAttempt after rewind: %v", err)
627+
}
628+
u, _ = db.GetUserByID(user.ID)
629+
if u.LockedUntil == nil {
630+
t.Fatal("locked_until cleared by sliding-window reset; should still be active")
631+
}
632+
// The preserved lock should match (or be later than) the original.
633+
if u.LockedUntil.Before(originalLock.Add(-time.Second)) {
634+
t.Errorf("locked_until shortened from %v to %v", originalLock, *u.LockedUntil)
635+
}
636+
}
637+
638+
// The Login() flow returns the SAME generic error for a locked account as
639+
// for a wrong-password or missing-user — preventing email enumeration via
640+
// error-message variance.
641+
func TestManager_Login_LockedAccountReturnsGenericError(t *testing.T) {
642+
manager, db, cleanup := setupTestManager(t)
643+
defer cleanup()
644+
645+
passwordHash, err := HashPassword("correct_password")
646+
if err != nil {
647+
t.Fatalf("HashPassword: %v", err)
648+
}
649+
if _, _, err := db.EnsureAdminExists(passwordHash, false); err != nil {
650+
t.Fatalf("EnsureAdminExists: %v", err)
651+
}
652+
user, err := db.GetUserByEmail("admin")
653+
if err != nil || user == nil {
654+
t.Fatalf("GetUserByEmail: user=%v err=%v", user, err)
655+
}
656+
657+
// Lock the account by hitting the cooldown threshold.
658+
for i := 0; i < 5; i++ {
659+
if err := db.RecordLoginAttempt(user.ID, false); err != nil {
660+
t.Fatalf("RecordLoginAttempt[%d]: %v", i, err)
661+
}
662+
}
663+
u, _ := db.GetUserByID(user.ID)
664+
if !u.IsLocked() {
665+
t.Fatalf("expected account to be locked after 5 failures")
666+
}
667+
668+
// Now try to log in — even with the CORRECT password, we should get
669+
// the generic "invalid credentials" error, not "account locked".
670+
req := httptest.NewRequest("GET", "/login", nil)
671+
w := httptest.NewRecorder()
672+
_, err = manager.Login(w, req, "admin", "correct_password")
673+
if err == nil {
674+
t.Fatal("expected error for locked account; got nil")
675+
}
676+
if !strings.Contains(err.Error(), "invalid credentials") {
677+
t.Errorf("locked-account error = %q, want to contain 'invalid credentials' (generic). "+
678+
"Leaking lockout state lets attackers enumerate valid emails.", err.Error())
679+
}
680+
if strings.Contains(strings.ToLower(err.Error()), "lock") {
681+
t.Errorf("locked-account error mentions 'lock': %q. This is an enumeration aid; "+
682+
"must be the same generic error as wrong-password.", err.Error())
683+
}
684+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- INTENTIONAL FAILURE: this migration has no safe automatic rollback.
2+
--
3+
-- SQLite < 3.35 cannot DROP COLUMN at all. Newer SQLite versions can, but
4+
-- we don't gate on runtime version, and the standard fallback (rebuild
5+
-- the table) is risky for the users table mid-deploy. Letting this run
6+
-- as a no-op would decrement the golang-migrate schema version while
7+
-- last_failed_attempt remained — a confusing/unsafe rollback state that
8+
-- silently desyncs application code from the database.
9+
--
10+
-- If you genuinely need to roll this back, drop the column manually using
11+
-- the table-rebuild recipe (CREATE new, copy, drop, rename) and then
12+
-- update the schema_migrations table by hand.
13+
INSERT INTO _down_migration_000001_not_supported_run_manually VALUES (1);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- 2026-05 security audit P2-4: track per-account timestamp of the most
2+
-- recent failed login attempt so RecordLoginAttempt can implement a
3+
-- sliding window over failures (rather than the previous "increment
4+
-- forever until success" semantic). Used purely server-side; never
5+
-- exposed in API responses or UI — surfacing lockout state to the
6+
-- caller is itself an enumeration aid for attackers.
7+
ALTER TABLE users ADD COLUMN last_failed_attempt DATETIME;

0 commit comments

Comments
 (0)