"""Rate limiting that is correct across multiple instances. Two windows: * per-minute burst control (fixed 60s bucket) * per-day quota When Redis is configured (``REDIS_URL``), counters live in Redis so every replica shares one view — horizontally correct. Without Redis, an in-process fallback is used (correct on a single instance). Same public API either way. """ from __future__ import annotations import threading import time from collections import deque from app.core.redis_client import get_redis _PER_MINUTE_WINDOW = 60.0 _PER_USER_MINUTE_LIMIT = 20 # authenticated, user-keyed _PER_IP_MINUTE_LIMIT = 30 # unauthenticated, IP-keyed # Auth endpoints (login + signup) get a much tighter per-IP cap to slow down # password brute-force and credential-stuffing attempts. 10/min ≈ 600/hour per # IP is enough for a normal user retrying a few times but stops a dictionary # attack cold. Applies regardless of the larger generic /chat etc. window. _PER_IP_AUTH_MINUTE_LIMIT = 10 # Support submissions (bug reports + feedback) get a tight dedicated cap so a # script cannot flood the table, while a genuine student can still file a few # in a row. Keyed per-user when signed in, per-IP when anonymous, in its own # bucket so it never shares state with (or locks out) normal study traffic. _PER_SUPPORT_MINUTE_LIMIT = 5 _DAY_SECONDS = 86_400 # ── In-memory fallback state ──────────────────────────────────────────────── _daily_store: dict[str, dict] = {} _daily_lock = threading.Lock() _DAILY_STORE_MAX = 10_000 _minute_store: dict[str, deque] = {} _minute_lock = threading.Lock() def _minute_limit_for(key: str) -> int: return _PER_USER_MINUTE_LIMIT if key.startswith("user:") else _PER_IP_MINUTE_LIMIT def over_per_minute(key: str, now: float | None = None, path: str | None = None) -> bool: """Return True if the per-minute limit is exceeded (and do not record). The optional ``path`` argument lets the caller apply a tighter cap on sensitive endpoints (e.g. /auth/login, /auth/signup). Auth requests are tracked in a SEPARATE bucket so they don't share state with general usage — that way a user's normal chat traffic can't accidentally lock them out of signing in, and an attacker can't exhaust their general budget to disable the brute-force guard. """ now = now or time.time() is_auth = path in { "/auth/login", "/auth/signup", "/auth/forgot-password", "/auth/reset-password", } is_support = path is not None and path.startswith("/support") if is_auth: limit = _PER_IP_AUTH_MINUTE_LIMIT bucket_ns = "auth" elif is_support: limit = _PER_SUPPORT_MINUTE_LIMIT bucket_ns = "support" else: limit = _minute_limit_for(key) bucket_ns = "gen" client = get_redis() if client is not None: try: bucket = int(now // _PER_MINUTE_WINDOW) rkey = f"rl:min:{bucket_ns}:{key}:{bucket}" count = client.incr(rkey) if count == 1: client.expire(rkey, int(_PER_MINUTE_WINDOW) + 5) return count > limit except Exception: pass # fall through to in-memory on any Redis error return _over_per_minute_memory(f"{bucket_ns}:{key}", now, limit) def _over_per_minute_memory(key: str, now: float, limit: int) -> bool: cutoff = now - _PER_MINUTE_WINDOW with _minute_lock: dq = _minute_store.setdefault(key, deque()) while dq and dq[0] <= cutoff: dq.popleft() if len(dq) >= limit: return True dq.append(now) return False def over_daily(key: str, daily_limit: int, now: float | None = None) -> bool: """Return True if the daily quota is exhausted. Increments on allow.""" now = now or time.time() client = get_redis() if client is not None: try: day = int(now // _DAY_SECONDS) rkey = f"rl:day:{key}:{day}" count = client.incr(rkey) if count == 1: client.expire(rkey, _DAY_SECONDS + 60) return count > daily_limit except Exception: pass return _over_daily_memory(key, daily_limit, now) def _over_daily_memory(key: str, daily_limit: int, now: float) -> bool: with _daily_lock: _evict_expired(now) entry = _daily_store.get(key) if entry is None or now > entry["reset_at"]: entry = {"count": 0, "reset_at": now + _DAY_SECONDS} _daily_store[key] = entry if entry["count"] >= daily_limit: return True entry["count"] += 1 return False def _evict_expired(now: float) -> None: if len(_daily_store) < _DAILY_STORE_MAX: return for k in [k for k, v in _daily_store.items() if now > v.get("reset_at", 0)]: del _daily_store[k] if len(_daily_store) >= _DAILY_STORE_MAX: for k in list(_daily_store.keys())[: _DAILY_STORE_MAX // 2]: del _daily_store[k]