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>
This commit is contained in:
@@ -5,8 +5,11 @@ import json
|
|||||||
import requests
|
import requests
|
||||||
import subprocess
|
import subprocess
|
||||||
import db
|
import db
|
||||||
|
from middleware import rate_limit_middleware, access_log_middleware
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
rate_limit_middleware(app)
|
||||||
|
access_log_middleware(app)
|
||||||
|
|
||||||
DASHBOARD_API_URL = "http://localhost:8791"
|
DASHBOARD_API_URL = "http://localhost:8791"
|
||||||
COMBINED_API_URL = "http://localhost:8790"
|
COMBINED_API_URL = "http://localhost:8790"
|
||||||
|
|||||||
43
saas_auth.py
43
saas_auth.py
@@ -14,6 +14,18 @@ import time
|
|||||||
import json
|
import json
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime
|
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 ───────────────────────────────────────────────────────────────────
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
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:
|
if not email or not password:
|
||||||
return jsonify({"error": "Email et mot de passe requis."}), 400
|
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)
|
pw_hash = hash_password(password)
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
user = conn.execute(
|
user = conn.execute(
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class TestJWTAuthentication:
|
|||||||
"invalid_signature_here"
|
"invalid_signature_here"
|
||||||
)
|
)
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{BASE_URL}/api/races",
|
f"{BASE_URL}/api/v1/predictions/today",
|
||||||
headers={"Authorization": f"Bearer {expired_token}"},
|
headers={"Authorization": f"Bearer {expired_token}"},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
@@ -153,7 +153,7 @@ class TestJWTAuthentication:
|
|||||||
"""Un token JWT malformé doit être rejeté."""
|
"""Un token JWT malformé doit être rejeté."""
|
||||||
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
|
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{BASE_URL}/api/races",
|
f"{BASE_URL}/api/v1/predictions/today",
|
||||||
headers={"Authorization": f"Bearer {bad_token}"},
|
headers={"Authorization": f"Bearer {bad_token}"},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
@@ -163,7 +163,7 @@ class TestJWTAuthentication:
|
|||||||
|
|
||||||
def test_jwt_sans_token(self):
|
def test_jwt_sans_token(self):
|
||||||
"""Sans token, les routes protégées doivent retourner 401."""
|
"""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), (
|
assert resp.status_code in (401, 403), (
|
||||||
f"Route protégée accessible sans token: status={resp.status_code}"
|
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__":
|
if __name__ == "__main__":
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user