This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 238bab5e5 fix(auth): keep login lockouts effective at capacity (#3046)
238bab5e5 is described below

commit 238bab5e58e4c3fd43981200838b397cddbc879e
Author: xdz997 <[email protected]>
AuthorDate: Fri Sep 4 15:42:09 2026 +0800

    fix(auth): keep login lockouts effective at capacity (#3046)
---
 .../rocketmq/studio/auth/LoginRateLimiter.java     | 242 +++++++++++++++++----
 .../rocketmq/studio/auth/LoginRateLimiterTest.java |  69 ++++++
 2 files changed, 265 insertions(+), 46 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
index 0dbe30ae1..e14a1f432 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
@@ -16,17 +16,19 @@ import org.springframework.stereotype.Component;
 
 import java.time.Clock;
 import java.time.Duration;
+import java.util.Arrays;
+import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ThreadLocalRandom;
 
 /**
  * In-memory brute-force protection for the Studio login endpoint.
  *
- * <p>Failed logins are counted per username. After {@link 
#MAX_FAILED_ATTEMPTS} failures within
- * {@link #FAILURE_WINDOW}, further attempts for that username are rejected for
- * {@link #LOCK_DURATION}. A successful login resets the counter. The state is 
deliberately kept
- * in memory only: it throttles online guessing attacks and resets on 
restart.</p>
+ * <p>Failed logins are counted per normalized username. The exact tracker has 
a strict size
+ * bound. When that bound is occupied, previously unseen usernames use a 
second fixed-size set
+ * of hash buckets instead of failing open or growing memory. Collisions can 
share a lock only
+ * while the exact tracker is saturated; they cannot disable rate limiting.</p>
  */
 @Slf4j
 @Component
@@ -36,10 +38,16 @@ public class LoginRateLimiter {
     static final Duration FAILURE_WINDOW = Duration.ofMinutes(5);
     static final Duration LOCK_DURATION = Duration.ofMinutes(5);
     static final int MAX_TRACKED_USERNAMES = 10_000;
+    static final int OVERFLOW_BUCKET_COUNT = 1_024;
 
-    private final Map<String, AttemptState> attempts = new 
ConcurrentHashMap<>();
+    private final Map<String, AttemptState> attempts = new HashMap<>();
+    private final AttemptState[] overflowAttempts;
     private final Clock clock;
     private final int maxTrackedUsernames;
+    private final int overflowBucketMask;
+    private final int overflowHashSeed;
+
+    private long nextExactExpiryMillis = Long.MAX_VALUE;
 
     public LoginRateLimiter() {
         this(Clock.systemUTC());
@@ -50,78 +58,220 @@ public class LoginRateLimiter {
     }
 
     LoginRateLimiter(Clock clock, int maxTrackedUsernames) {
+        this(clock, maxTrackedUsernames, OVERFLOW_BUCKET_COUNT,
+                ThreadLocalRandom.current().nextInt());
+    }
+
+    LoginRateLimiter(Clock clock, int maxTrackedUsernames,
+                     int overflowBucketCount, int overflowHashSeed) {
+        if (maxTrackedUsernames <= 0) {
+            throw new IllegalArgumentException("maxTrackedUsernames must be 
positive");
+        }
+        if (overflowBucketCount <= 0
+                || (overflowBucketCount & (overflowBucketCount - 1)) != 0) {
+            throw new IllegalArgumentException("overflowBucketCount must be a 
power of two");
+        }
         this.clock = clock;
         this.maxTrackedUsernames = maxTrackedUsernames;
+        this.overflowAttempts = new AttemptState[overflowBucketCount];
+        this.overflowBucketMask = overflowBucketCount - 1;
+        this.overflowHashSeed = overflowHashSeed;
     }
 
     /**
-     * Rejects the attempt with HTTP 429 while the username is locked out.
+     * Rejects the attempt with HTTP 429 while its exact or overflow state is 
locked.
      */
-    public void checkAllowed(String username) {
+    public synchronized void checkAllowed(String username) {
         String key = key(username);
-        AttemptState state = attempts.get(key);
-        if (state == null || state.lockedUntilMillis() == 0) {
+        long now = clock.millis();
+        AttemptState exact = activeExactState(key, now);
+        if (exact != null) {
+            rejectIfLocked(exact, now);
             return;
         }
-        long now = clock.millis();
-        if (state.lockedUntilMillis() > now) {
-            long remainingSeconds = (state.lockedUntilMillis() - now + 999) / 
1000;
-            throw new BusinessException(429, "Too many failed login attempts; 
try again in "
-                    + remainingSeconds + " seconds");
+
+        reclaimExpiredExactAttemptsIfDue(now);
+        if (attempts.size() < maxTrackedUsernames) {
+            return;
+        }
+
+        int bucket = overflowBucket(key);
+        AttemptState overflow = activeOverflowState(bucket, now);
+        if (overflow != null) {
+            rejectIfLocked(overflow, now);
         }
-        attempts.remove(key, state);
     }
 
     public synchronized void recordFailure(String username) {
         String key = key(username);
         long now = clock.millis();
-        if (!attempts.containsKey(key) && attempts.size() >= 
maxTrackedUsernames) {
-            removeExpiredAttempts(now);
-            if (attempts.size() >= maxTrackedUsernames) {
-                log.debug("Login rate limiter is at capacity; ignoring a new 
username");
-                return;
-            }
+        AttemptState exact = activeExactState(key, now);
+        if (exact != null) {
+            FailureUpdate update = incrementFailure(exact, now);
+            attempts.put(key, update.state());
+            noteExactExpiry(update.state());
+            logLock(username, update, false);
+            return;
         }
-        AttemptState state = attempts.compute(key, (ignored, current) -> {
-            AttemptState base = current;
-            if (base == null || base.lockedUntilMillis() != 0
-                    || now - base.windowStartMillis() >= 
FAILURE_WINDOW.toMillis()) {
-                base = new AttemptState(now, 0, 0);
-            }
-            int failures = base.failureCount() + 1;
-            if (failures >= MAX_FAILED_ATTEMPTS) {
-                return new AttemptState(now, 0, now + 
LOCK_DURATION.toMillis());
+
+        reclaimExpiredExactAttemptsIfDue(now);
+        if (attempts.size() < maxTrackedUsernames) {
+            FailureUpdate update = incrementFailure(null, now);
+            attempts.put(key, update.state());
+            noteExactExpiry(update.state());
+            logLock(username, update, false);
+            return;
+        }
+
+        int bucket = overflowBucket(key);
+        AttemptState overflow = activeOverflowState(bucket, now);
+        FailureUpdate update = incrementFailure(overflow, now);
+        overflowAttempts[bucket] = update.state();
+        logLock(username, update, true);
+    }
+
+    public synchronized void recordSuccess(String username) {
+        String key = key(username);
+        if (attempts.remove(key) != null) {
+            // Once an exact slot is available, overflow state from the 
saturated period is no
+            // longer needed and must not affect a later saturation episode.
+            clearOverflowAttempts();
+            return;
+        }
+        overflowAttempts[overflowBucket(key)] = null;
+    }
+
+    synchronized int trackedUsernameCount() {
+        return attempts.size();
+    }
+
+    synchronized int activeOverflowBucketCount() {
+        int active = 0;
+        long now = clock.millis();
+        for (int index = 0; index < overflowAttempts.length; index++) {
+            if (activeOverflowState(index, now) != null) {
+                active++;
             }
-            return new AttemptState(base.windowStartMillis(), failures, 0);
-        });
-        if (state != null && state.lockedUntilMillis() != 0) {
+        }
+        return active;
+    }
+
+    private AttemptState activeExactState(String key, long now) {
+        AttemptState state = attempts.get(key);
+        if (state == null || !state.expiredAt(now)) {
+            return state;
+        }
+        attempts.remove(key);
+        clearOverflowAttempts();
+        return null;
+    }
+
+    private AttemptState activeOverflowState(int bucket, long now) {
+        AttemptState state = overflowAttempts[bucket];
+        if (state == null || !state.expiredAt(now)) {
+            return state;
+        }
+        overflowAttempts[bucket] = null;
+        return null;
+    }
+
+    private void reclaimExpiredExactAttemptsIfDue(long now) {
+        if (now < nextExactExpiryMillis) {
+            return;
+        }
+        boolean removed = attempts.entrySet().removeIf(entry -> 
entry.getValue().expiredAt(now));
+        nextExactExpiryMillis = attempts.values().stream()
+                .mapToLong(AttemptState::expiresAtMillis)
+                .min()
+                .orElse(Long.MAX_VALUE);
+        if (removed) {
+            clearOverflowAttempts();
+        }
+    }
+
+    private FailureUpdate incrementFailure(AttemptState current, long now) {
+        if (current != null && current.lockedAt(now)) {
+            // A request that passed checkAllowed before another request 
established this lock
+            // may finish later. Its failure must not shorten or clear the 
active lock.
+            return new FailureUpdate(current, false);
+        }
+        AttemptState base = current == null || current.expiredAt(now)
+                ? new AttemptState(now, 0, 0)
+                : current;
+        int failures = base.failureCount() + 1;
+        if (failures >= MAX_FAILED_ATTEMPTS) {
+            return new FailureUpdate(
+                    new AttemptState(now, 0, now + LOCK_DURATION.toMillis()), 
true);
+        }
+        return new FailureUpdate(
+                new AttemptState(base.windowStartMillis(), failures, 0), 
false);
+    }
+
+    private void rejectIfLocked(AttemptState state, long now) {
+        if (!state.lockedAt(now)) {
+            return;
+        }
+        long remainingSeconds = (state.lockedUntilMillis() - now + 999) / 1000;
+        throw new BusinessException(429, "Too many failed login attempts; try 
again in "
+                + remainingSeconds + " seconds");
+    }
+
+    private void logLock(String username, FailureUpdate update, boolean 
overflow) {
+        if (!update.newlyLocked()) {
+            return;
+        }
+        if (overflow) {
+            log.warn("Locked a saturated login-rate bucket after {} failed 
attempts within {} minutes",
+                    MAX_FAILED_ATTEMPTS, FAILURE_WINDOW.toMinutes());
+        } else {
             log.warn("Locked login for user {} after {} failed attempts within 
{} minutes",
                     username, MAX_FAILED_ATTEMPTS, FAILURE_WINDOW.toMinutes());
         }
     }
 
-    public void recordSuccess(String username) {
-        attempts.remove(key(username));
+    private void noteExactExpiry(AttemptState state) {
+        nextExactExpiryMillis = Math.min(nextExactExpiryMillis, 
state.expiresAtMillis());
     }
 
-    int trackedUsernameCount() {
-        return attempts.size();
+    private void clearOverflowAttempts() {
+        Arrays.fill(overflowAttempts, null);
     }
 
-    private void removeExpiredAttempts(long now) {
-        attempts.entrySet().removeIf(entry -> {
-            AttemptState state = entry.getValue();
-            if (state.lockedUntilMillis() != 0) {
-                return state.lockedUntilMillis() <= now;
-            }
-            return now - state.windowStartMillis() >= 
FAILURE_WINDOW.toMillis();
-        });
+    private int overflowBucket(String key) {
+        long hash = 0xcbf29ce484222325L ^ 
Integer.toUnsignedLong(overflowHashSeed);
+        for (int index = 0; index < key.length(); index++) {
+            hash ^= key.charAt(index);
+            hash *= 0x100000001b3L;
+        }
+        hash ^= hash >>> 33;
+        hash *= 0xff51afd7ed558ccdL;
+        hash ^= hash >>> 33;
+        return ((int) hash) & overflowBucketMask;
     }
 
     private String key(String username) {
         return username.trim().toLowerCase(Locale.ROOT);
     }
 
+    private record FailureUpdate(AttemptState state, boolean newlyLocked) {
+    }
+
     private record AttemptState(long windowStartMillis, int failureCount, long 
lockedUntilMillis) {
+
+        boolean lockedAt(long now) {
+            return lockedUntilMillis > now;
+        }
+
+        boolean expiredAt(long now) {
+            return lockedUntilMillis != 0
+                    ? lockedUntilMillis <= now
+                    : now - windowStartMillis >= FAILURE_WINDOW.toMillis();
+        }
+
+        long expiresAtMillis() {
+            return lockedUntilMillis != 0
+                    ? lockedUntilMillis
+                    : windowStartMillis + FAILURE_WINDOW.toMillis();
+        }
     }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
index c18e08f21..617510167 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
@@ -150,6 +150,75 @@ class LoginRateLimiterTest {
                 .isInstanceOf(BusinessException.class);
     }
 
+    @Test
+    void capacityShouldNotDisableRateLimitingForAnUntrackedUsernameTest() {
+        limiter = new LoginRateLimiter(clock, 2);
+        limiter.recordFailure("decoy-one");
+        limiter.recordFailure("decoy-two");
+
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            assertThatCode(() -> 
limiter.checkAllowed("operator")).doesNotThrowAnyException();
+            limiter.recordFailure("operator");
+        }
+
+        assertThat(limiter.trackedUsernameCount()).isEqualTo(2);
+        assertThatThrownBy(() -> limiter.checkAllowed("operator"))
+                .isInstanceOf(BusinessException.class)
+                .satisfies(exception ->
+                        assertThat(((BusinessException) 
exception).getCode()).isEqualTo(429));
+    }
+
+    @Test
+    void trackerCapacityShouldRemainBoundedWhenAllSlotsAreLockedTest() {
+        limiter = new LoginRateLimiter(clock, 2);
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("operator");
+            limiter.recordFailure("second-user");
+        }
+
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("attacker");
+        }
+        assertThat(limiter.trackedUsernameCount()).isEqualTo(2);
+        assertThatThrownBy(() -> limiter.checkAllowed("operator"))
+                .isInstanceOf(BusinessException.class);
+        assertThatThrownBy(() -> limiter.checkAllowed("second-user"))
+                .isInstanceOf(BusinessException.class);
+        assertThatThrownBy(() -> limiter.checkAllowed("attacker"))
+                .isInstanceOf(BusinessException.class);
+        assertThat(limiter.activeOverflowBucketCount()).isEqualTo(1);
+    }
+
+    @Test
+    void failureAlreadyInFlightShouldNotClearAnActiveLockTest() {
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("operator");
+        }
+
+        // A login request can pass checkAllowed before another request 
creates the lock,
+        // then finish password verification and record its failure after the 
lock exists.
+        limiter.recordFailure("operator");
+
+        assertThatThrownBy(() -> limiter.checkAllowed("operator"))
+                .isInstanceOf(BusinessException.class)
+                .satisfies(exception ->
+                        assertThat(((BusinessException) 
exception).getCode()).isEqualTo(429));
+    }
+
+    @Test
+    void activeLocksShouldNotEvictEachOtherWhenNewFailuresArriveTest() {
+        limiter = new LoginRateLimiter(clock, 2);
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("operator");
+            limiter.recordFailure("second-user");
+        }
+        limiter.recordFailure("operator");
+
+        assertThat(limiter.trackedUsernameCount()).isEqualTo(2);
+        assertThatThrownBy(() -> limiter.checkAllowed("second-user"))
+                .isInstanceOf(BusinessException.class);
+    }
+
     private static final class MutableClock extends Clock {
 
         private Instant now;

Reply via email to