"""Enkel rate limiting i minnet. Lagt til etter sikkerhetsgjennomgang 2026-08-09 (se ARCHITECTURE_DECISIONS.md -tillegg): ingen av auth-endepunktene hadde noen form for forsøksbegrensning, noe som gjorde passord- og 2FA-kode-gjetting praktisk mulig for en angriper med nok samtidighet. Trygt å holde i minnet KUN fordi `teecup_api` kjører som ÉN uvicorn-prosess (ingen `--workers`-flagg, se Dockerfile) -- tilstanden deles derfor ikke mellom flere prosesser/containere, samme kjente begrensning som andre in-memory-cacher i appen (se "Cache over flere prosesser" i ARCHITECTURE_DECISIONS.md sin liste over åpne punkter). Ved fremtidig skalering til flere workers/containere MÅ dette flyttes til Redis eller tilsvarende delt lager -- ikke glem å oppdatere denne kommentaren da. """ import time from fastapi import Request from .errors import app_error def client_ip(request: Request) -> str: """Klient-IP via X-Forwarded-For -- trygt KUN fordi appen ikke er nåbar unntatt gjennom Caddy, som terminerer TLS og proxyer videre over vanlig http internt (samme tillitsmodell som should_use_secure_cookies() i app/auth.py). Første adresse i kjeden er den faktiske klienten.""" forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "unknown" class RateLimiter: """Fast-vindu-teller per nøkkel. Bruk som FastAPI-dependency (`Depends(en_limiter)`) for IP-basert begrensning, eller kall `.check(nøkkel)` direkte inne i en endepunkt-funksjon for en mer presis nøkkel (f.eks. en pending-bruker-ID -- riktig nøkkel når selve RESSURSEN som brute-forces er knyttet til en bestemt konto, ikke avsender-IP-en).""" def __init__( self, max_requests: int, window_seconds: float, message: str = "For mange forsøk. Vent litt og prøv igjen.", ): self.max_requests = max_requests self.window_seconds = window_seconds self.message = message self._buckets: dict[str, list[float]] = {} def check(self, key: str) -> None: now = time.monotonic() window_start = now - self.window_seconds hits = [t for t in self._buckets.get(key, []) if t >= window_start] if len(hits) >= self.max_requests: self._buckets[key] = hits raise app_error(429, "RATE_LIMITED", self.message) hits.append(now) self._buckets[key] = hits def __call__(self, request: Request) -> None: self.check(client_ip(request))