Compare commits

..

1 Commits

Author SHA1 Message Date
DevOps Engineer
7f5573f076 feat(security): add IP-based rate limiting on /api/v1/auth/login — fix brute force HRT-62
- saas_auth.py: in-memory sliding-window rate limiter (5 attempts/5min, 15min block)
  using collections.defaultdict + threading.Lock, stdlib only, no new deps
- portal_server.py: register rate_limit_middleware + access_log_middleware
  (was missing, leaving global 100req/min limit unApplied on portal routes)
- tests/security/test_security.py: add TestLoginRateLimit class with
  test_login_brute_force_blocked_after_5_attempts and test_login_429_has_retry_after_header

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-27 14:50:08 +02:00
3 changed files with 98 additions and 3 deletions

View File

@@ -5,8 +5,11 @@ import json
import requests
import subprocess
import db
from middleware import rate_limit_middleware, access_log_middleware
app = Flask(__name__)
rate_limit_middleware(app)
access_log_middleware(app)
DASHBOARD_API_URL = "http://localhost:8791"
COMBINED_API_URL = "http://localhost:8790"

View File

@@ -14,6 +14,18 @@ import time
import json
from functools import wraps
from datetime import datetime
from collections import defaultdict
from threading import Lock
# ─── Rate limiting login ───────────────────────────────────────────────────────
_login_attempts: dict = defaultdict(
lambda: {"count": 0, "window_start": 0.0, "blocked_until": 0.0}
)
_login_lock = Lock()
LOGIN_RATE_MAX = 5 # max tentatives par fenêtre
LOGIN_RATE_WINDOW = 300 # 5 minutes (en secondes)
LOGIN_BLOCK_DURATION = 900 # 15 min de blocage après dépassement
# ─── Config ───────────────────────────────────────────────────────────────────
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
@@ -184,6 +196,37 @@ def login():
if not email or not password:
return jsonify({"error": "Email et mot de passe requis."}), 400
# ── Rate limit par IP ────────────────────────────────────────
ip = request.remote_addr or "unknown"
now = time.time()
with _login_lock:
bucket = _login_attempts[ip]
# Lever le blocage si la durée est écoulée
if now >= bucket["blocked_until"]:
if now - bucket["window_start"] >= LOGIN_RATE_WINDOW:
bucket["count"] = 0
bucket["window_start"] = now
bucket["count"] += 1
count = bucket["count"]
if count > LOGIN_RATE_MAX:
bucket["blocked_until"] = now + LOGIN_BLOCK_DURATION
retry_after = LOGIN_BLOCK_DURATION
blocked = True
else:
retry_after = int(LOGIN_RATE_WINDOW - (now - bucket["window_start"]))
blocked = False
else:
blocked = True
retry_after = int(bucket["blocked_until"] - now)
if blocked:
resp = jsonify({"error": "Trop de tentatives. Réessayez plus tard."})
resp.status_code = 429
resp.headers["Retry-After"] = str(retry_after)
return resp
# ─────────────────────────────────────────────────────────────
pw_hash = hash_password(password)
conn = get_db()
user = conn.execute(

View File

@@ -141,7 +141,7 @@ class TestJWTAuthentication:
"invalid_signature_here"
)
resp = requests.get(
f"{BASE_URL}/api/races",
f"{BASE_URL}/api/v1/predictions/today",
headers={"Authorization": f"Bearer {expired_token}"},
timeout=5,
)
@@ -153,7 +153,7 @@ class TestJWTAuthentication:
"""Un token JWT malformé doit être rejeté."""
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
resp = requests.get(
f"{BASE_URL}/api/races",
f"{BASE_URL}/api/v1/predictions/today",
headers={"Authorization": f"Bearer {bad_token}"},
timeout=5,
)
@@ -163,7 +163,7 @@ class TestJWTAuthentication:
def test_jwt_sans_token(self):
"""Sans token, les routes protégées doivent retourner 401."""
resp = requests.get(f"{BASE_URL}/api/export/csv", timeout=5)
resp = requests.get(f"{BASE_URL}/api/v1/export/csv", timeout=5)
assert resp.status_code in (401, 403), (
f"Route protégée accessible sans token: status={resp.status_code}"
)
@@ -303,6 +303,55 @@ class TestPlanAuthorisation:
)
# === Tests rate limiting login ===
class TestLoginRateLimit:
"""Tests rate limiting sur /api/v1/auth/login."""
TARGET_URL = (
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/login"
)
def test_login_brute_force_blocked_after_5_attempts(self):
"""Après 5 tentatives, le 6ème appel doit retourner 429."""
# Utiliser un email unique pour isoler le test
email = f"ratelimit_test_{int(time.time())}@h3r7.tech"
for i in range(5):
resp = requests.post(
self.TARGET_URL,
json={"email": email, "password": "wrong_password"},
timeout=5,
)
assert resp.status_code in (400, 401), (
f"Tentative {i + 1}: status inattendu {resp.status_code}"
)
# La 6ème tentative doit être bloquée
resp = requests.post(
self.TARGET_URL,
json={"email": email, "password": "wrong_password"},
timeout=5,
)
assert resp.status_code == 429, (
f"Rate limit non appliqué après 5 tentatives: got {resp.status_code}"
)
assert "Retry-After" in resp.headers, "Header Retry-After manquant sur 429"
def test_login_429_has_retry_after_header(self):
"""La réponse 429 doit inclure Retry-After."""
email = f"ratelimit_test2_{int(time.time())}@h3r7.tech"
for _ in range(6):
requests.post(
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
)
resp = requests.post(
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
)
if resp.status_code == 429:
assert "Retry-After" in resp.headers
assert int(resp.headers["Retry-After"]) > 0
if __name__ == "__main__":
import subprocess