Class LoginRateLimiter
Maintains a sliding window of attempt timestamps per (scope, key) pair plus
an optional lockout-until timestamp. Three scopes are recognized: IP, USER
and CHAT. LoginRateLimiter.Scope.CHAT is used by the v2 chat endpoints to throttle
per-user chat invocations independently of the login buckets.
Thresholds are passed in per call so the manager can read FessConfig once and
forward the values; this keeps the helper test-friendly (no static config).
Eviction: each allow(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int) call drops timestamps older than the window
from the head of the queue. Idle keys remain in the map until the next call;
sweep() can be invoked periodically to evict empty entries.
Memory cap: the entries map is bounded by effectiveCap. When the cap
is reached, eviction picks the oldest idle entry (no in-window hits AND
no active lockout) — M-4: locked-out entries are never evicted, otherwise an
attacker could pump bogus usernames to silently release a victim's lockout. If
every entry is either locked or active the map is allowed to grow above cap by
one and a WARN is logged (best-effort policy: prefer correctness over a strict
cap). The cap defaults to DEFAULT_MAX_ENTRIES (100,000) and can be
tuned via the theme.api.login.rate.limit.max.entries property.
Sweep schedule: on DI init a TimeoutManager periodic task calls
sweep() every SWEEP_INTERVAL_SECONDS seconds (default 300).
This is the same background-task pattern used by CoordinatorHelper and
LogNotificationHelper in this codebase.
MJ-30 i18n note: error.message is developer-facing English. Clients MUST use error.code (the V2ErrorCode token) for user-facing i18n.
Multi-node deployments require LB-level rate limiting; see Plan 3 risks.
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionstatic enumRate-limit scope: distinguishes the bucket namespace so the samekeyvalue (e.g. -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionbooleanallow(LoginRateLimiter.Scope scope, String key, int maxPerWindow, int windowSeconds) Attempts to consume one slot in the (scope, key) bucket within a sliding window ofwindowSeconds.voidclear(LoginRateLimiter.Scope scope, String key) Clears the rate-limit bucket for (scope, key) after a successful login so that the user is not penalized for earlier failed attempts in the same window.voiddestroy()Stops the background sweep task on shutdown.voidinit()Called by Lasta Di after the component is bound.booleanlockOut(LoginRateLimiter.Scope scope, String key, int lockoutSeconds) Locks out (scope, key) forlockoutSeconds, starting now.booleanpeek(LoginRateLimiter.Scope scope, String key, int maxPerWindow, int windowSeconds) Returns true when a fresh attempt against (scope, key) would currently be admitted byallow(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int), but without consuming a bucket slot.voidsweep()Removes empty entries; safe to call from any thread.
-
Constructor Details
-
LoginRateLimiter
public LoginRateLimiter()Default constructor used by the DI container. UsesSystem.currentTimeMillis()as the clock andDEFAULT_MAX_ENTRIESas the initial cap;init()may override the cap fromFessConfigonce DI is fully wired.
-
-
Method Details
-
init
@PostConstruct public void init()Called by Lasta Di after the component is bound. Reads thetheme.api.login.rate.limit.max.entriesproperty (if present) to override the default cap, then registers a periodic sweep task viaTimeoutManager— the same pattern used by CoordinatorHelper and LogNotificationHelper. Sweep runs everySWEEP_INTERVAL_SECONDSseconds to evict entries whose windows have expired. -
destroy
@PreDestroy public void destroy()Stops the background sweep task on shutdown. -
allow
Attempts to consume one slot in the (scope, key) bucket within a sliding window ofwindowSeconds. Expired timestamps at the head of the queue are evicted first; if the bucket is currently locked out or already atmaxPerWindowthe call returnsfalsewithout recording a hit.MJ-6: empty or null
keyis denied rather than silently allowed; an empty key usually means the caller failed to resolve the client identity, and granting access would bypass the gate entirely.- Parameters:
scope- rate-limit scope (e.g. IP, USER, CHAT)key- bucket key (e.g. IP address or username)maxPerWindow- upper bound of attempts permitted withinwindowSeconds; values<= 0disable the gate and returntruewindowSeconds- sliding-window width in seconds- Returns:
trueif the slot was consumed,falseif the bucket is full or locked out
-
peek
Returns true when a fresh attempt against (scope, key) would currently be admitted byallow(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int), but without consuming a bucket slot. Use this to short-circuit work (e.g. credential verification) when the bucket is already exhausted, then callallow(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int)only on the failure path so success and system-error paths do not count against the legitimate user's quota.MJ-6: returns
false(deny) when key is null or empty — defense in depth. Empty key means the caller could not resolve the identity; passing rather than denying would silently bypass the gate.- Parameters:
scope- rate-limit scope (e.g. IP, USER, CHAT)key- bucket key (e.g. IP address or username)maxPerWindow- upper bound of attempts permitted withinwindowSecondswindowSeconds- sliding-window width- Returns:
- true if a subsequent
allow(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int)would currently succeed
-
lockOut
Locks out (scope, key) forlockoutSeconds, starting now.An already-active lockout is never extended. Every caller re-invokes this method on each refused request, and
allow(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int)/peek(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int)keep returningfalsefor the whole lockout — so re-stamping the deadline from the current time would push the release point forward on every retry and a client that kept polling would never be released, contradicting theRetry-After: lockoutSecondsheader the callers advertise (RFC 9110). Calls that arrive whilenow < lockUntilEpochMsare therefore no-ops; a fresh lockout is armed only once the previous one has elapsed.m-21: a shorter
lockoutSecondsvalue supplied in a second call still never shrinks an existing longer lockout. Note that the guard is first deadline wins, not "stricter deadline wins": while a lockout is active the incominglockoutSecondsis not consulted at all, so a longer value is ignored too. Since callers re-read the duration from configuration on every request, raisingtheme.api.login.lockout.secondsdoes not lengthen a lockout that is already running; the new value applies from the next lockout onwards. The guard still subsumes the formerMath.maxfor the m-21 direction: when no lockout is active the stored deadline is already in the past, sonow + lockoutSecondsis unconditionally the larger value.Release is only as prompt as the sliding window allows.
allow(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int)andpeek(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String, int, int)return early while locked out, before the window-pruning step, so the recorded hits are frozen for the duration of the lockout. WhenlockoutSeconds <= windowSecondsthose hits are therefore still in-window at the moment the lockout expires, the next request is refused again, and the caller arms a fresh lockout: effective release is aboutwindowSeconds + lockoutSecondsand the advertisedRetry-Afterunderstates it. The loop is bounded — a refused request records no new hit — and at the shipped defaults (lockoutSeconds = 900, window60) it cannot occur. Callers advertise the configured duration rather than the remaining time, soRetry-Afterover-states the wait on every retry after the first; this class exposes no remaining-time accessor.The return value tells the caller whether this particular call armed the lockout. Callers invoke this method on every refused request, so without it they cannot tell the one request that triggered the lockout from the stream of retries that follow — and logging each refusal at WARN would let a locked-out client generate unbounded log volume. It is a best-effort signal for log volume, not an exactly-once guarantee: the entry is resolved under the
entriesmonitor and stamped under the entry monitor, so a concurrentclear(org.codelibs.fess.api.v2.handlers.LoginRateLimiter.Scope, java.lang.String)(successful login),sweep()or eviction that unmaps the entry in between can make the stamp land on a detachedEntry— the call then reportstruealthough no lockout is in effect, and a second caller can reporttruefor the same key. Suppression is also keyed per(scope, key), and both key components are caller-supplied (client IP and user name), so the "one WARN per lockout" bound holds per bucket, not per client.- Parameters:
scope- rate-limit scope (e.g. IP, USER, CHAT) that the lockout applies tokey- bucket key being locked out (e.g. IP address or username); ignored whennullor emptylockoutSeconds- duration of the lockout in seconds; ignored when<= 0- Returns:
trueif this call armed a new lockout,falseif the call was ignored (disabled or missing key) or a lockout was already active
-
clear
Clears the rate-limit bucket for (scope, key) after a successful login so that the user is not penalized for earlier failed attempts in the same window.MJ-5: called by LoginHandler on the successful-login path for both Scope.USER (by username) and Scope.IP (by client IP). This prevents a legitimate user who previously exceeded the window from being locked out on their next login after the correct password is supplied.
- Parameters:
scope- the rate-limit scopekey- the bucket key (username or IP address)
-
sweep
public void sweep()Removes empty entries; safe to call from any thread.
-