"""Tiny in-memory token-bucket rate limiter. Used for `/admin/login` only. The student endpoints intentionally have no IP-based throttling because a campus deployment puts ~40 students behind one or a few NAT IPs; rate-limiting at the IP level would false-positive the entire class. For the admin login endpoint, IP-based limiting is appropriate: the instructor logs in from a single device, and brute-force attempts generally come from a few attacker IPs. Per-IP token bucket of 10 attempts / minute is generous for the legitimate user, hostile to a guesser. """ from __future__ import annotations import time from dataclasses import dataclass from typing import Optional from fastapi import Request @dataclass(slots=True) class _Bucket: tokens: float last_ts: float class TokenBucket: """Per-key (e.g., per-IP) token bucket. `capacity` tokens accrue at `rate_per_sec`. Each call to `take()` consumes one token; if the bucket is empty, returns False. State is process-local. An app restart resets all buckets, which is acceptable for the threat model (slows attackers; doesn't permanently lock anyone out). """ def __init__(self, capacity: int, refill_per_minute: float) -> None: self.capacity = float(capacity) self.rate_per_sec = refill_per_minute / 60.0 self.buckets: dict[str, _Bucket] = {} def take(self, key: str) -> bool: now = time.monotonic() b = self.buckets.get(key) if b is None: b = _Bucket(tokens=self.capacity, last_ts=now) self.buckets[key] = b elapsed = now - b.last_ts b.tokens = min(self.capacity, b.tokens + elapsed * self.rate_per_sec) b.last_ts = now if b.tokens < 1.0: return False b.tokens -= 1.0 return True def client_ip(request: Request) -> str: """Best-effort client IP extraction. Caddy puts the real client in `X-Forwarded-For`; uvicorn behind a 127.0.0.1-only proxy will see `request.client.host == "127.0.0.1"` for every request, so trusting X-F-F is necessary for any per-client behaviour at all. """ xff = request.headers.get("x-forwarded-for") if xff: return xff.split(",")[0].strip() return request.client.host if request.client else "unknown"