diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 9e9ca880..b6e83fed 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -287,7 +287,11 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } // BUG-110: Also check per-username lockout to prevent distributed brute force. - userLockKey := "login_user_lock:" + req.Username + // F1: canonicalize the username the same way GetUserByUsername does (COLLATE + // NOCASE) before keying the lockout, so case variants of one account + // (admin/Admin/ADMIN) share a single bucket instead of each getting its own. + unameKey := strings.ToLower(req.Username) + userLockKey := "login_user_lock:" + unameKey if limiter.IsLockedOut(userLockKey) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", @@ -316,7 +320,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } failKey := "login_fail:" + ip - userFailKey := "login_user_fail:" + req.Username + userFailKey := "login_user_fail:" + unameKey // Always run the password check — with an empty hash when the user does // not exist. auth.CheckPassword performs a dummy bcrypt comparison for an // empty hash, so bcrypt executes on every path and response time stays diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index bc8305ed..6f1eeb67 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -409,6 +409,40 @@ func TestLogin_UsernameLockoutBlocksCorrectPasswordFromFreshIP(t *testing.T) { } } +// TestLogin_UsernameLockoutIgnoresCasing locks F1: the per-username lockout key +// must be case-folded so it matches the DB's COLLATE NOCASE username lookup. +// Otherwise an attacker splits the 9-attempt lockout budget across case variants +// of one account (admin, Admin, ADMIN, …), all of which authenticate the same row. +func TestLogin_UsernameLockoutIgnoresCasing(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("casehunt", hash, 4) + + // Trip the per-username lockout using the lowercase spelling, from many IPs + // so the per-IP limiter is never the binding cap. + for i := 0; i < 10; i++ { + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "casehunt", + "password": "wrongpassword", + }, fmt.Sprintf("198.51.100.%d", i+1)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String()) + } + } + + // A different casing of the SAME account must land in the same lockout bucket. + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "CASEHUNT", + "password": "wrongpassword", + }, "198.51.100.250") + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("case-variant username bypassed the per-username lockout: status = %d, want 429; body = %s", rr.Code, rr.Body.String()) + } +} + func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) { database := newAuthTestDB(t) limiter := auth.NewRateLimiter()