P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps
- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed - Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles, self-lockout + reference guards) - Unify role model in app/core/roles.py; reject unknown roles at creation and at login/JWT validation; startup normalizes unambiguous legacy aliases - Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable) - WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset; GET handshake uses constant-time verify token (403 on mismatch) - GET /api/whatsapp/mock-log now requires auth - Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML, HSTS behind TLS - Pagination: limit alias for page_size, hard cap enforced, both -> 422
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Dependency-light login rate limiter.
|
||||
|
||||
Brute-force protection for ``POST /api/auth/login``: a sliding window of
|
||||
failed attempts keyed by ``ip|email``. Defaults to ~5 failures / 15 minutes
|
||||
(env-tunable via ``LOGIN_RATE_LIMIT_MAX_ATTEMPTS`` /
|
||||
``LOGIN_RATE_LIMIT_WINDOW_SECONDS``).
|
||||
|
||||
In-process storage is intentional: the app currently runs a single uvicorn
|
||||
worker, and keeping the limiter dependency-light avoids pulling slowapi in
|
||||
for one endpoint. ``_now`` is a module-level hook so tests can fast-forward
|
||||
the clock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
"""Wall-clock epoch seconds; overridable in tests via monkeypatch."""
|
||||
return time.time()
|
||||
|
||||
|
||||
class LoginRateLimiter:
|
||||
"""Sliding-window failure limiter keyed by ``ip|email``."""
|
||||
|
||||
def __init__(self, max_attempts: int = 5, window_seconds: int = 15 * 60) -> None:
|
||||
self.max_attempts = max(max_attempts, 1)
|
||||
self.window_seconds = max(window_seconds, 1)
|
||||
self._failures: defaultdict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def key(self, ip: str, email: str) -> str:
|
||||
return f"{ip}|{email.strip().lower()}"
|
||||
|
||||
def _prune(self, key: str, now: float | None = None) -> None:
|
||||
now = now if now is not None else _now()
|
||||
window_start = now - self.window_seconds
|
||||
bucket = self._failures.get(key)
|
||||
if bucket is None:
|
||||
return
|
||||
while bucket and bucket[0] <= window_start:
|
||||
bucket.popleft()
|
||||
if not bucket:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
def failure_count(self, key: str) -> int:
|
||||
self._prune(key)
|
||||
return len(self._failures.get(key, ()))
|
||||
|
||||
def is_blocked(self, key: str) -> bool:
|
||||
return self.failure_count(key) >= self.max_attempts
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
self._failures[key].append(_now())
|
||||
self._prune(key)
|
||||
|
||||
def clear(self, key: str) -> None:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._failures.clear()
|
||||
|
||||
def check_or_raise(self, key: str) -> None:
|
||||
"""Raise HTTP 429 when the key has exhausted its attempts."""
|
||||
if self.is_blocked(key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
)
|
||||
|
||||
|
||||
# Shared instance — module import is safe because the app fails closed at
|
||||
# boot (app/core/config.py) before any request can reach the login route.
|
||||
login_rate_limiter = LoginRateLimiter(
|
||||
max_attempts=settings.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
window_seconds=settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||
)
|
||||
Reference in New Issue
Block a user