Initial commit
Dieser Commit ist enthalten in:
1
lizenzserver/middleware/__init__.py
Normale Datei
1
lizenzserver/middleware/__init__.py
Normale Datei
@ -0,0 +1 @@
|
||||
# Middleware Module
|
||||
158
lizenzserver/middleware/rate_limiter.py
Normale Datei
158
lizenzserver/middleware/rate_limiter.py
Normale Datei
@ -0,0 +1,158 @@
|
||||
import time
|
||||
from functools import wraps
|
||||
from flask import request, jsonify
|
||||
import redis
|
||||
from typing import Optional, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RateLimiter:
|
||||
"""Rate limiting middleware using Redis"""
|
||||
|
||||
def __init__(self, redis_url: str):
|
||||
self.redis_client = None
|
||||
try:
|
||||
self.redis_client = redis.from_url(redis_url, decode_responses=True)
|
||||
self.redis_client.ping()
|
||||
logger.info("Connected to Redis for rate limiting")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis not available for rate limiting: {e}")
|
||||
|
||||
def limit(self, requests_per_minute: int = 60, requests_per_hour: int = 1000):
|
||||
"""Decorator for rate limiting endpoints"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not self.redis_client:
|
||||
# Redis not available, skip rate limiting
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Get client identifier (API key or IP)
|
||||
client_id = self._get_client_id()
|
||||
|
||||
# Check rate limits
|
||||
is_allowed, retry_after = self._check_rate_limit(
|
||||
client_id,
|
||||
requests_per_minute,
|
||||
requests_per_hour
|
||||
)
|
||||
|
||||
if not is_allowed:
|
||||
response = jsonify({
|
||||
"error": "Rate limit exceeded",
|
||||
"retry_after": retry_after
|
||||
})
|
||||
response.status_code = 429
|
||||
response.headers['Retry-After'] = str(retry_after)
|
||||
response.headers['X-RateLimit-Limit'] = str(requests_per_minute)
|
||||
return response
|
||||
|
||||
# Add rate limit headers
|
||||
response = f(*args, **kwargs)
|
||||
if hasattr(response, 'headers'):
|
||||
response.headers['X-RateLimit-Limit'] = str(requests_per_minute)
|
||||
response.headers['X-RateLimit-Remaining'] = str(
|
||||
self._get_remaining_requests(client_id, requests_per_minute)
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def _get_client_id(self) -> str:
|
||||
"""Get client identifier from request"""
|
||||
# First try API key
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key:
|
||||
return f"api_key:{api_key}"
|
||||
|
||||
# Then try auth token
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
return f"token:{auth_header[7:32]}" # Use first 32 chars of token
|
||||
|
||||
# Fallback to IP
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
ip = request.headers.get('X-Forwarded-For').split(',')[0]
|
||||
else:
|
||||
ip = request.remote_addr
|
||||
|
||||
return f"ip:{ip}"
|
||||
|
||||
def _check_rate_limit(self, client_id: str,
|
||||
requests_per_minute: int,
|
||||
requests_per_hour: int) -> Tuple[bool, Optional[int]]:
|
||||
"""Check if request is within rate limits"""
|
||||
now = int(time.time())
|
||||
|
||||
# Check minute limit
|
||||
minute_key = f"rate_limit:minute:{client_id}:{now // 60}"
|
||||
minute_count = self.redis_client.incr(minute_key)
|
||||
self.redis_client.expire(minute_key, 60)
|
||||
|
||||
if minute_count > requests_per_minute:
|
||||
retry_after = 60 - (now % 60)
|
||||
return False, retry_after
|
||||
|
||||
# Check hour limit
|
||||
hour_key = f"rate_limit:hour:{client_id}:{now // 3600}"
|
||||
hour_count = self.redis_client.incr(hour_key)
|
||||
self.redis_client.expire(hour_key, 3600)
|
||||
|
||||
if hour_count > requests_per_hour:
|
||||
retry_after = 3600 - (now % 3600)
|
||||
return False, retry_after
|
||||
|
||||
return True, None
|
||||
|
||||
def _get_remaining_requests(self, client_id: str, limit: int) -> int:
|
||||
"""Get remaining requests in current minute"""
|
||||
now = int(time.time())
|
||||
minute_key = f"rate_limit:minute:{client_id}:{now // 60}"
|
||||
|
||||
try:
|
||||
current_count = int(self.redis_client.get(minute_key) or 0)
|
||||
return max(0, limit - current_count)
|
||||
except:
|
||||
return limit
|
||||
|
||||
class APIKeyRateLimiter(RateLimiter):
|
||||
"""Extended rate limiter with API key specific limits"""
|
||||
|
||||
def __init__(self, redis_url: str, db_repo):
|
||||
super().__init__(redis_url)
|
||||
self.db_repo = db_repo
|
||||
|
||||
def limit_by_api_key(self):
|
||||
"""Rate limit based on API key configuration"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
|
||||
if not api_key:
|
||||
# Use default limits for non-API key requests
|
||||
return self.limit()(f)(*args, **kwargs)
|
||||
|
||||
# Get API key configuration from database
|
||||
query = """
|
||||
SELECT rate_limit_per_minute, rate_limit_per_hour
|
||||
FROM api_clients
|
||||
WHERE api_key = %s AND is_active = true
|
||||
"""
|
||||
|
||||
client = self.db_repo.execute_one(query, (api_key,))
|
||||
|
||||
if not client:
|
||||
return jsonify({"error": "Invalid API key"}), 401
|
||||
|
||||
# Use custom limits or defaults
|
||||
rpm = client.get('rate_limit_per_minute', 60)
|
||||
rph = client.get('rate_limit_per_hour', 1000)
|
||||
|
||||
return self.limit(rpm, rph)(f)(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren