Source code for litestar_security.accounts._rate_limits

"""Operation-scoped rate limiting for abuse-prone local-account entry points.

Local login, registration, recovery, verification, and refresh routes are
unauthenticated and deliberately expensive: password verification runs Argon2,
which costs the server real CPU on every attempt. That combination makes them
both a password-guessing surface and an amplification lever, so each one
consumes a budget before it does any credential work.

Two buckets guard every limited operation. The client bucket uses a key the
application supplies, because only the application knows which proxy headers it
trusts. The subject bucket uses a peppered digest of the normalized identifier,
which is what stops one account being targeted from many addresses. Neither
bucket ever receives a raw identifier, password, or token.

:class:`RateLimiter` is a port. :class:`StoreRateLimiter` is the bundled
implementation over a native Litestar :class:`~litestar.stores.base.Store`,
resolved by name from the application store registry. A shared backend shares
bucket values, but the native store contract has no compare-and-increment, so
the bundled read-modify-write implementation is exact only within one process.
"""

from _thread import LockType
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from hashlib import sha256
from hmac import digest as hmac_digest
from logging import getLogger
from math import ceil
from threading import Lock
from types import MappingProxyType
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from anyio import sleep
from litestar.exceptions import ImproperlyConfiguredException
from litestar.stores.base import Store

from litestar_security.accounts._internal import (
    MINIMUM_PEPPER_BYTES,
    aware_utc_time,
    new_event_id,
    strict_text,
    utc_now,
)
from litestar_security.accounts._operations import (
    LOGIN,
    LOGIN_MFA,
    MFA_RECOVERY_CONSUME,
    MFA_RECOVERY_REPLACE,
    MFA_TOTP_ENROLL,
    MFA_TOTP_REMOVE,
    MFA_TOTP_VERIFY,
    OUTCOME_RATE_LIMITED,
    PASSKEY_ASSERT,
    PASSKEY_AUTH_OPTIONS,
    PASSKEY_REGISTER_OPTIONS,
    PASSKEY_REGISTER_VERIFY,
    PASSKEY_REMOVE,
    PASSWORD_RESET,
    PASSWORD_VERIFY,
    RATE_LIMITED_OPERATIONS,
    RECOVERY,
    REFRESH_ROTATE,
    REGISTRATION,
    VERIFICATION_CONSUME,
    VERIFICATION_RESEND,
)
from litestar_security.accounts._records import (
    NoOpSecurityEventSink,
    SecurityEvent,
    SecurityEventSink,
    emit_security_event,
)
from litestar_security.authentication import VerificationUnavailable

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator, Callable, Mapping


__all__ = (
    "DEFAULT_RATE_LIMIT_POLICIES",
    "RATE_LIMIT_STORE_NAME",
    "RateLimitAttempt",
    "RateLimitDecision",
    "RateLimitGuard",
    "RateLimitPolicy",
    "RateLimited",
    "RateLimiter",
    "StoreRateLimiter",
    "UnlimitedRateLimiter",
    "validate_rate_limits",
)

_LOGGER = getLogger(__name__)

RATE_LIMIT_STORE_NAME = "litestar_security.rate_limits"

_SUBJECT_DIGEST_LABEL = b"litestar-security/rate-limit/subject"
_MAXIMUM_WINDOW = timedelta(days=1)
_MAXIMUM_LIMIT = 1_000_000
_MAXIMUM_COST = 1_000
_MAXIMUM_KEY_TEXT = 512
_PROCESS_LOCK_POLL_INTERVAL = 0.001
_PROCESS_RATE_LIMIT_LOCK: LockType = Lock()


@asynccontextmanager
async def _hold_process_rate_limit_lock(lock: LockType) -> "AsyncGenerator[None, None]":
    while not lock.acquire(blocking=False):  # noqa: ASYNC110 - a threading lock has no async notification
        await sleep(_PROCESS_LOCK_POLL_INTERVAL)
    try:
        yield
    finally:
        lock.release()


[docs] @dataclass(frozen=True, slots=True) class RateLimitPolicy: """One operation's budget, applied to each bucket independently. Args: limit: Attempts allowed per window in a single bucket. window: Length of the fixed window the limit applies to. """ limit: int window: timedelta def __post_init__(self) -> None: """Require a bounded positive limit and a whole-second window.""" limit_value: object = self.limit window_value: object = self.window if limit_value.__class__ is not int or not 1 <= self.limit <= _MAXIMUM_LIMIT: msg = "Rate limit must be a positive bounded integer" raise ImproperlyConfiguredException(detail=msg) if ( not isinstance(window_value, timedelta) # pyright: ignore[reportUnnecessaryIsInstance] - runtime port or self.window <= timedelta(0) or self.window > _MAXIMUM_WINDOW or self.window.microseconds ): msg = "Rate limit window must be positive whole seconds of at most one day" raise ImproperlyConfiguredException(detail=msg)
DEFAULT_RATE_LIMIT_POLICIES: "Mapping[str, RateLimitPolicy]" = MappingProxyType({ LOGIN: RateLimitPolicy(limit=10, window=timedelta(minutes=5)), LOGIN_MFA: RateLimitPolicy(limit=10, window=timedelta(minutes=5)), MFA_RECOVERY_CONSUME: RateLimitPolicy(limit=10, window=timedelta(minutes=5)), MFA_RECOVERY_REPLACE: RateLimitPolicy(limit=5, window=timedelta(hours=1)), MFA_TOTP_ENROLL: RateLimitPolicy(limit=5, window=timedelta(hours=1)), MFA_TOTP_REMOVE: RateLimitPolicy(limit=5, window=timedelta(hours=1)), MFA_TOTP_VERIFY: RateLimitPolicy(limit=10, window=timedelta(minutes=5)), PASSKEY_ASSERT: RateLimitPolicy(limit=20, window=timedelta(minutes=5)), PASSKEY_AUTH_OPTIONS: RateLimitPolicy(limit=20, window=timedelta(minutes=5)), PASSKEY_REGISTER_OPTIONS: RateLimitPolicy(limit=5, window=timedelta(hours=1)), PASSKEY_REGISTER_VERIFY: RateLimitPolicy(limit=10, window=timedelta(minutes=5)), PASSKEY_REMOVE: RateLimitPolicy(limit=5, window=timedelta(hours=1)), REGISTRATION: RateLimitPolicy(limit=5, window=timedelta(hours=1)), RECOVERY: RateLimitPolicy(limit=5, window=timedelta(hours=1)), PASSWORD_RESET: RateLimitPolicy(limit=10, window=timedelta(hours=1)), # Second-factor re-verifications of an already-authenticated principal share # MFA_TOTP_VERIFY's cadence; VERIFICATION_CONSUME mirrors it for the same reason. PASSWORD_VERIFY: RateLimitPolicy(limit=10, window=timedelta(minutes=5)), VERIFICATION_RESEND: RateLimitPolicy(limit=5, window=timedelta(hours=1)), VERIFICATION_CONSUME: RateLimitPolicy(limit=10, window=timedelta(minutes=5)), REFRESH_ROTATE: RateLimitPolicy(limit=60, window=timedelta(minutes=5)), }) assert DEFAULT_RATE_LIMIT_POLICIES.keys() == RATE_LIMITED_OPERATIONS, ( # noqa: S101 - import-time coverage guard "DEFAULT_RATE_LIMIT_POLICIES must map exactly RATE_LIMITED_OPERATIONS" )
[docs] @dataclass(frozen=True, slots=True) class RateLimitAttempt: """One bucketed attempt presented to a limiter. Args: operation: Canonical ``local.*`` name of the entry point being consumed. client_key: Application-supplied trusted client identity, or ``None`` to skip the client bucket. subject_digest: Peppered digest of the normalized identifier, or ``None`` when the operation carries no identifier. Never a raw identifier. cost: Units this attempt consumes from each bucket. """ operation: str client_key: str | None = None subject_digest: str | None = None cost: int = 1 def __post_init__(self) -> None: """Require a named operation, bounded bucket keys, and a positive cost.""" cost_value: object = self.cost if not strict_text(self.operation): msg = "Rate limit request operation must be non-empty text" raise ValueError(msg) for value in (self.client_key, self.subject_digest): if value is not None and (not strict_text(value) or len(value) > _MAXIMUM_KEY_TEXT): msg = "Rate limit bucket keys must be bounded non-empty text" raise ValueError(msg) if cost_value.__class__ is not int or not 1 <= self.cost <= _MAXIMUM_COST: msg = "Rate limit cost must be a positive bounded integer" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class RateLimitDecision: """One limiter verdict for a single attempt. Args: allowed: Whether the attempt may proceed. retry_after: Whole seconds until the caller may retry, reported only when the attempt was denied. """ allowed: bool retry_after: int | None = None def __post_init__(self) -> None: """Require a positive retry hint, and only on denial.""" allowed_value: object = self.allowed retry_value: object = self.retry_after if allowed_value.__class__ is not bool: msg = "Rate limit decision must be boolean" raise ValueError(msg) if retry_value is not None and (retry_value.__class__ is not int or retry_value < 1): msg = "Rate limit retry-after must be a positive whole number of seconds" raise ValueError(msg) if self.allowed and retry_value is not None: msg = "Allowed rate limit decisions cannot carry a retry-after" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class RateLimited: """Sanitized outcome returned when an operation exhausted its budget. Args: retry_after: Whole seconds until the caller may retry, when the limiter reported one. code: Stable machine-readable reason. """ retry_after: int | None = None code: str = "rate_limited"
[docs] @runtime_checkable class RateLimiter(Protocol): """Application-owned budget for one abuse-prone operation. Implementations MUST consume atomically: ``N`` concurrent :meth:`acquire` calls against the same bucket under a policy limit of ``k`` must admit exactly ``k``, never more or fewer. A limiter that raises is treated as unavailable and fails closed, so raising is the correct response to a backend outage. """
[docs] async def acquire(self, request: RateLimitAttempt) -> RateLimitDecision: """Consume one attempt's cost and report whether it may proceed. Args: request: The operation, buckets, and cost to charge. Returns: The decision, carrying ``retry_after`` seconds when it denies. Raises: Exception: Any failure signals an outage. The caller fails closed and answers ``503`` rather than letting the limit lapse. """ ... # pragma: no cover
[docs] @dataclass(frozen=True, slots=True) class UnlimitedRateLimiter: """Allow every attempt, for deployments that limit at the edge instead."""
[docs] async def acquire(self, request: RateLimitAttempt) -> RateLimitDecision: """Allow one attempt without consuming any budget. Args: request: Ignored; nothing is counted. Returns: An allowing decision. """ del request return RateLimitDecision(allowed=True)
[docs] @dataclass(slots=True) class StoreRateLimiter: """Fixed-window limiter over a native Litestar store. The store is resolved by name from the application registry during startup, so an unconfigured name yields Litestar's in-memory default. A process-wide lock serializes every bundled limiter instance's read-modify-write operation, making counting exact within one process. Native stores expose no compare-and-increment, however, so a shared backend is not atomic across worker processes or machines. Multi-process deployments must supply a :class:`RateLimiter` backed by an atomic primitive and verify it with :func:`litestar_security.testing.assert_rate_limiter_conformance`. Args: policies: Budget per operation. Operations absent from the mapping are not limited by this limiter. store_name: Registry name resolved during application startup. store: Pre-resolved store, bypassing registry resolution. clock: Source of the current time. """ policies: "Mapping[str, RateLimitPolicy]" = field(default_factory=lambda: DEFAULT_RATE_LIMIT_POLICIES) store_name: str = RATE_LIMIT_STORE_NAME store: Store | None = field(default=None, repr=False) clock: "Callable[[], datetime]" = field(default=utc_now, repr=False, compare=False) _lock: LockType = field(default_factory=lambda: _PROCESS_RATE_LIMIT_LOCK, init=False, repr=False, compare=False) def __post_init__(self) -> None: """Validate the store name, policies, and clock, then freeze the mapping.""" store_value: object = self.store if not strict_text(self.store_name): msg = "Rate limit store name must be non-empty text" raise ImproperlyConfiguredException(detail=msg) if store_value is not None and not isinstance(store_value, Store): # pyright: ignore[reportUnnecessaryIsInstance] - runtime port msg = "Rate limit store must be a Litestar Store" raise ImproperlyConfiguredException(detail=msg) clock_value: object = self.clock if not callable(clock_value): msg = "Rate limit clock must be callable" raise ImproperlyConfiguredException(detail=msg) policies = dict(self.policies) if any(not strict_text(name) or policy.__class__ is not RateLimitPolicy for name, policy in policies.items()): msg = "Rate limit policies must map operation names to RateLimitPolicy values" raise ImproperlyConfiguredException(detail=msg) self.policies = MappingProxyType(policies)
[docs] def bind(self, store: Store) -> None: """Attach the store resolved from the application registry at startup. Args: store: The store to count in. A shared backend shares bucket values, but cannot make this read-modify-write implementation atomic across worker processes. Raises: ImproperlyConfiguredException: If the value is not a Litestar store. """ store_value: object = store if not isinstance(store_value, Store): # pyright: ignore[reportUnnecessaryIsInstance] - runtime port msg = "Rate limit store must be a Litestar Store" raise ImproperlyConfiguredException(detail=msg) self.store = store
[docs] async def acquire(self, request: RateLimitAttempt) -> RateLimitDecision: """Consume one attempt from every configured bucket for the operation. A process-wide lock makes the complete multi-bucket accounting operation exact across bundled limiter instances in this process. The underlying store has no compare-and-increment, so deployments spanning multiple processes or machines must provide an atomic :class:`RateLimiter` and verify it with :func:`litestar_security.testing.assert_rate_limiter_conformance`. Args: request: The operation, buckets, and cost to charge. Returns: The decision. An operation absent from the policy mapping is allowed. When several buckets are exhausted, the longest wait is reported. Raises: RuntimeError: If the store has not been resolved, or a stored counter cannot be read as an integer. """ policy = self.policies.get(request.operation) if policy is None: return RateLimitDecision(allowed=True) store = self.store if store is None: msg = "Rate limit store has not been resolved" raise RuntimeError(msg) async with _hold_process_rate_limit_lock(self._lock): now = self.clock() retry_after = 0 for kind, value in (("c", request.client_key), ("s", request.subject_digest)): if value is None: continue exhausted = await self._consume(store, request, policy, kind=kind, value=value, now=now) if exhausted is not None: retry_after = max(retry_after, exhausted) if retry_after: return RateLimitDecision(allowed=False, retry_after=retry_after) return RateLimitDecision(allowed=True)
async def _consume( # noqa: PLR0913 - one bucket read/write; every input is named self, store: Store, request: RateLimitAttempt, policy: RateLimitPolicy, *, kind: str, value: str, now: datetime ) -> int | None: window = int(policy.window.total_seconds()) elapsed = now.timestamp() slot = int(elapsed // window) bucket = sha256(f"{request.operation}\x00{kind}\x00{value}".encode()).hexdigest() key = f"{self.store_name}:{slot}:{bucket}" raw = await store.get(key) try: used = int(raw) if raw is not None else 0 except ValueError: msg = "Rate limit counter is unreadable" raise RuntimeError(msg) from None used += request.cost await store.set(key, str(used).encode("ascii"), expires_in=window) if used <= policy.limit: return None return max(1, ceil((slot + 1) * window - elapsed))
[docs] @dataclass(frozen=True, slots=True) class RateLimitGuard: """Bucket one operation's attempts without exposing the identifier. Every service that limits an entry point shares one guard, so denial audit events are constructed the same way everywhere instead of once per service. Args: limiter: The configured budget implementation. pepper: Secret used to derive subject digests; at least 32 bytes. events: Sink notified when an attempt is denied. clock: Source of the current time for denial events. event_ids: Factory for unique denial event identifiers. """ limiter: RateLimiter = field(repr=False) pepper: bytes = field(repr=False) events: SecurityEventSink = field(default_factory=NoOpSecurityEventSink, repr=False, compare=False) clock: "Callable[[], datetime]" = field(default=utc_now, repr=False, compare=False) event_ids: "Callable[[], str]" = field(default=new_event_id, repr=False, compare=False) def __post_init__(self) -> None: """Require a limiter, a long enough digest pepper, and a usable sink.""" limiter_value: object = object.__getattribute__(self, "limiter") pepper_value: object = object.__getattribute__(self, "pepper") events_value: object = object.__getattribute__(self, "events") if not isinstance(limiter_value, RateLimiter): msg = "Rate limit guard limiter must implement RateLimiter" raise ImproperlyConfiguredException(detail=msg) if not isinstance(pepper_value, bytes) or len(pepper_value) < MINIMUM_PEPPER_BYTES: msg = "Rate limit guard pepper must be at least 32 bytes" raise ImproperlyConfiguredException(detail=msg) if not isinstance(events_value, SecurityEventSink): msg = "Rate limit guard events must implement SecurityEventSink" raise ImproperlyConfiguredException(detail=msg) clock_value: object = object.__getattribute__(self, "clock") event_ids_value: object = object.__getattribute__(self, "event_ids") if not callable(clock_value) or not callable(event_ids_value): msg = "Rate limit guard clock and event id factory must be callable" raise ImproperlyConfiguredException(detail=msg)
[docs] async def check( self, operation: str, *, client_key: str | None = None, identifier: str | None = None ) -> "RateLimited | VerificationUnavailable | None": """Consume one attempt, returning ``None`` when the caller may proceed. Args: operation: The rate-limited operation name. client_key: The caller identity for the client bucket, or ``None`` to skip it. identifier: The submitted account identifier for the subject bucket, or ``None`` to skip it. It is digested before it reaches the limiter. Returns: ``None`` when the attempt may proceed, ``RateLimited`` when the budget is spent, or ``VerificationUnavailable`` when the limiter failed. A limiter outage fails closed, so it can never silently remove the limit. """ try: request = RateLimitAttempt( operation=operation, client_key=client_key, subject_digest=self.subject_digest(identifier) if identifier is not None else None, ) decision: object = await self.limiter.acquire(request) except Exception: # noqa: BLE001 - an unavailable limiter must fail closed, not open _LOGGER.error("Rate limiter unavailable for %s", operation) # noqa: TRY400 - omit untrusted details return VerificationUnavailable() if not isinstance(decision, RateLimitDecision): # pyright: ignore[reportUnnecessaryIsInstance] - runtime port _LOGGER.error("Rate limiter returned an unusable decision for %s", operation) return VerificationUnavailable() if decision.allowed: return None await self._emit_denial(operation) return RateLimited(retry_after=decision.retry_after)
[docs] def subject_digest(self, identifier: str) -> str: """Derive the stable peppered bucket digest for one normalized identifier. Args: identifier: The normalized account identifier. Returns: The hex digest used as a bucket key, so a limiter backend never stores identifiers. """ return hmac_digest(self.pepper, _SUBJECT_DIGEST_LABEL + identifier.encode("utf-8"), sha256).hex()
async def _emit_denial(self, operation: str) -> None: # The account is deliberately absent: a denial is keyed on a digest, and # resolving it back to an account would defeat the point of digesting it. try: event = SecurityEvent( event_id=self.event_ids(), occurred_at=aware_utc_time(self.clock()), operation=operation, outcome=OUTCOME_RATE_LIMITED, ) except Exception: # noqa: BLE001 - a failed clock or id factory cannot change a settled denial _LOGGER.error("Rate limit event could not be built for %s", operation) # noqa: TRY400 - omit details return await emit_security_event(self.events, event)
def validate_rate_limits(value: object, *, name: str) -> None: """Require an optional rate-limit guard to be exactly a :class:`RateLimitGuard`. Args: value: The configured guard, or ``None`` when the service is unlimited. name: Service name used in the configuration error. Raises: ImproperlyConfiguredException: If a guard is supplied but is the wrong type. """ if value is not None and value.__class__ is not RateLimitGuard: msg = f"{name} rate limits must be a RateLimitGuard" raise ImproperlyConfiguredException(detail=msg)