Class LoginRateLimiter

java.lang.Object
org.codelibs.fess.api.v2.handlers.LoginRateLimiter

public class LoginRateLimiter extends Object
In-memory, per-instance rate limiter for /api/v2/auth/login (and /auth/password).

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.

  • Constructor Details

    • LoginRateLimiter

      public LoginRateLimiter()
      Default constructor used by the DI container. Uses System.currentTimeMillis() as the clock and DEFAULT_MAX_ENTRIES as the initial cap; init() may override the cap from FessConfig once DI is fully wired.
  • Method Details

    • init

      @PostConstruct public void init()
      Called by Lasta Di after the component is bound. Reads the theme.api.login.rate.limit.max.entries property (if present) to override the default cap, then registers a periodic sweep task via TimeoutManager — the same pattern used by CoordinatorHelper and LogNotificationHelper. Sweep runs every SWEEP_INTERVAL_SECONDS seconds to evict entries whose windows have expired.
    • destroy

      @PreDestroy public void destroy()
      Stops the background sweep task on shutdown.
    • allow

      public boolean allow(LoginRateLimiter.Scope scope, String key, int maxPerWindow, int windowSeconds)
      Attempts to consume one slot in the (scope, key) bucket within a sliding window of windowSeconds. Expired timestamps at the head of the queue are evicted first; if the bucket is currently locked out or already at maxPerWindow the call returns false without recording a hit.

      MJ-6: empty or null key is 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 within windowSeconds; values <= 0 disable the gate and return true
      windowSeconds - sliding-window width in seconds
      Returns:
      true if the slot was consumed, false if the bucket is full or locked out
    • peek

      public boolean peek(LoginRateLimiter.Scope scope, String key, int maxPerWindow, int windowSeconds)
      Returns true when a fresh attempt against (scope, key) would currently be admitted by allow(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 call allow(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 within windowSeconds
      windowSeconds - 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

      public boolean lockOut(LoginRateLimiter.Scope scope, String key, int lockoutSeconds)
      Locks out (scope, key) for lockoutSeconds, 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 returning false for 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 the Retry-After: lockoutSeconds header the callers advertise (RFC 9110). Calls that arrive while now < lockUntilEpochMs are therefore no-ops; a fresh lockout is armed only once the previous one has elapsed.

      m-21: a shorter lockoutSeconds value 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 incoming lockoutSeconds is not consulted at all, so a longer value is ignored too. Since callers re-read the duration from configuration on every request, raising theme.api.login.lockout.seconds does not lengthen a lockout that is already running; the new value applies from the next lockout onwards. The guard still subsumes the former Math.max for the m-21 direction: when no lockout is active the stored deadline is already in the past, so now + lockoutSeconds is 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) and peek(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. When lockoutSeconds <= windowSeconds those 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 about windowSeconds + lockoutSeconds and the advertised Retry-After understates it. The loop is bounded — a refused request records no new hit — and at the shipped defaults (lockoutSeconds = 900, window 60) it cannot occur. Callers advertise the configured duration rather than the remaining time, so Retry-After over-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 entries monitor and stamped under the entry monitor, so a concurrent clear(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 detached Entry — the call then reports true although no lockout is in effect, and a second caller can report true for 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 to
      key - bucket key being locked out (e.g. IP address or username); ignored when null or empty
      lockoutSeconds - duration of the lockout in seconds; ignored when <= 0
      Returns:
      true if this call armed a new lockout, false if the call was ignored (disabled or missing key) or a lockout was already active
    • clear

      public void clear(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.

      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 scope
      key - the bucket key (username or IP address)
    • sweep

      public void sweep()
      Removes empty entries; safe to call from any thread.