django-cfg 1.2.22__py3-none-any.whl → 1.2.25__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/knowbase/tasks/archive_tasks.py +6 -6
- django_cfg/apps/knowbase/tasks/document_processing.py +3 -3
- django_cfg/apps/knowbase/tasks/external_data_tasks.py +2 -2
- django_cfg/apps/knowbase/tasks/maintenance.py +3 -3
- django_cfg/apps/payments/admin/__init__.py +23 -0
- django_cfg/apps/payments/admin/api_keys_admin.py +347 -0
- django_cfg/apps/payments/admin/balance_admin.py +434 -0
- django_cfg/apps/payments/admin/currencies_admin.py +186 -0
- django_cfg/apps/payments/admin/filters.py +259 -0
- django_cfg/apps/payments/admin/payments_admin.py +142 -0
- django_cfg/apps/payments/admin/subscriptions_admin.py +227 -0
- django_cfg/apps/payments/admin/tariffs_admin.py +199 -0
- django_cfg/apps/payments/config/__init__.py +65 -0
- django_cfg/apps/payments/config/module.py +70 -0
- django_cfg/apps/payments/config/providers.py +115 -0
- django_cfg/apps/payments/config/settings.py +96 -0
- django_cfg/apps/payments/config/utils.py +52 -0
- django_cfg/apps/payments/decorators.py +291 -0
- django_cfg/apps/payments/management/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/README.md +178 -0
- django_cfg/apps/payments/management/commands/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/currency_stats.py +323 -0
- django_cfg/apps/payments/management/commands/populate_currencies.py +246 -0
- django_cfg/apps/payments/management/commands/update_currencies.py +336 -0
- django_cfg/apps/payments/managers/currency_manager.py +65 -14
- django_cfg/apps/payments/middleware/api_access.py +294 -0
- django_cfg/apps/payments/middleware/rate_limiting.py +216 -0
- django_cfg/apps/payments/middleware/usage_tracking.py +296 -0
- django_cfg/apps/payments/migrations/0001_initial.py +125 -11
- django_cfg/apps/payments/models/__init__.py +18 -0
- django_cfg/apps/payments/models/api_keys.py +2 -2
- django_cfg/apps/payments/models/balance.py +2 -2
- django_cfg/apps/payments/models/base.py +16 -0
- django_cfg/apps/payments/models/events.py +2 -2
- django_cfg/apps/payments/models/payments.py +112 -2
- django_cfg/apps/payments/models/subscriptions.py +2 -2
- django_cfg/apps/payments/services/__init__.py +64 -7
- django_cfg/apps/payments/services/billing/__init__.py +8 -0
- django_cfg/apps/payments/services/cache/__init__.py +15 -0
- django_cfg/apps/payments/services/cache/base.py +30 -0
- django_cfg/apps/payments/services/cache/simple_cache.py +135 -0
- django_cfg/apps/payments/services/core/__init__.py +17 -0
- django_cfg/apps/payments/services/core/balance_service.py +447 -0
- django_cfg/apps/payments/services/core/fallback_service.py +432 -0
- django_cfg/apps/payments/services/core/payment_service.py +576 -0
- django_cfg/apps/payments/services/core/subscription_service.py +614 -0
- django_cfg/apps/payments/services/internal_types.py +297 -0
- django_cfg/apps/payments/services/middleware/__init__.py +8 -0
- django_cfg/apps/payments/services/monitoring/__init__.py +22 -0
- django_cfg/apps/payments/services/monitoring/api_schemas.py +222 -0
- django_cfg/apps/payments/services/monitoring/provider_health.py +372 -0
- django_cfg/apps/payments/services/providers/__init__.py +22 -0
- django_cfg/apps/payments/services/providers/base.py +137 -0
- django_cfg/apps/payments/services/providers/cryptapi.py +273 -0
- django_cfg/apps/payments/services/providers/cryptomus.py +310 -0
- django_cfg/apps/payments/services/providers/nowpayments.py +293 -0
- django_cfg/apps/payments/services/providers/registry.py +103 -0
- django_cfg/apps/payments/services/security/__init__.py +34 -0
- django_cfg/apps/payments/services/security/error_handler.py +637 -0
- django_cfg/apps/payments/services/security/payment_notifications.py +342 -0
- django_cfg/apps/payments/services/security/webhook_validator.py +475 -0
- django_cfg/apps/payments/services/validators/__init__.py +8 -0
- django_cfg/apps/payments/signals/__init__.py +13 -0
- django_cfg/apps/payments/signals/api_key_signals.py +160 -0
- django_cfg/apps/payments/signals/payment_signals.py +128 -0
- django_cfg/apps/payments/signals/subscription_signals.py +196 -0
- django_cfg/apps/payments/tasks/__init__.py +12 -0
- django_cfg/apps/payments/tasks/webhook_processing.py +177 -0
- django_cfg/apps/payments/urls.py +5 -5
- django_cfg/apps/payments/utils/__init__.py +45 -0
- django_cfg/apps/payments/utils/billing_utils.py +342 -0
- django_cfg/apps/payments/utils/config_utils.py +245 -0
- django_cfg/apps/payments/utils/middleware_utils.py +228 -0
- django_cfg/apps/payments/utils/validation_utils.py +94 -0
- django_cfg/apps/payments/views/payment_views.py +40 -2
- django_cfg/apps/payments/views/webhook_views.py +266 -0
- django_cfg/apps/payments/viewsets.py +65 -0
- django_cfg/apps/support/signals.py +16 -4
- django_cfg/apps/support/templates/support/chat/ticket_chat.html +1 -1
- django_cfg/cli/README.md +2 -2
- django_cfg/cli/commands/create_project.py +1 -1
- django_cfg/cli/commands/info.py +1 -1
- django_cfg/cli/main.py +1 -1
- django_cfg/cli/utils.py +5 -5
- django_cfg/core/config.py +18 -4
- django_cfg/models/payments.py +546 -0
- django_cfg/models/revolution.py +1 -1
- django_cfg/models/tasks.py +51 -2
- django_cfg/modules/base.py +12 -6
- django_cfg/modules/django_currency/README.md +104 -269
- django_cfg/modules/django_currency/__init__.py +99 -41
- django_cfg/modules/django_currency/clients/__init__.py +11 -0
- django_cfg/modules/django_currency/clients/coingecko_client.py +257 -0
- django_cfg/modules/django_currency/clients/yfinance_client.py +246 -0
- django_cfg/modules/django_currency/core/__init__.py +42 -0
- django_cfg/modules/django_currency/core/converter.py +169 -0
- django_cfg/modules/django_currency/core/exceptions.py +28 -0
- django_cfg/modules/django_currency/core/models.py +54 -0
- django_cfg/modules/django_currency/database/__init__.py +25 -0
- django_cfg/modules/django_currency/database/database_loader.py +507 -0
- django_cfg/modules/django_currency/utils/__init__.py +9 -0
- django_cfg/modules/django_currency/utils/cache.py +92 -0
- django_cfg/modules/django_email.py +42 -4
- django_cfg/modules/django_unfold/dashboard.py +20 -0
- django_cfg/registry/core.py +10 -0
- django_cfg/template_archive/__init__.py +0 -0
- django_cfg/template_archive/django_sample.zip +0 -0
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/METADATA +11 -6
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/RECORD +113 -50
- django_cfg/apps/agents/examples/__init__.py +0 -3
- django_cfg/apps/agents/examples/simple_example.py +0 -161
- django_cfg/apps/knowbase/examples/__init__.py +0 -3
- django_cfg/apps/knowbase/examples/external_data_usage.py +0 -191
- django_cfg/apps/knowbase/mixins/examples/vehicle_model_example.py +0 -199
- django_cfg/apps/payments/services/base.py +0 -68
- django_cfg/apps/payments/services/nowpayments.py +0 -78
- django_cfg/apps/payments/services/providers.py +0 -77
- django_cfg/apps/payments/services/redis_service.py +0 -215
- django_cfg/modules/django_currency/cache.py +0 -430
- django_cfg/modules/django_currency/converter.py +0 -324
- django_cfg/modules/django_currency/service.py +0 -277
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,294 @@
|
|
1
|
+
"""
|
2
|
+
API Access Control Middleware.
|
3
|
+
Handles API key authentication and subscription validation.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import logging
|
7
|
+
from typing import Optional, Tuple
|
8
|
+
from django.http import JsonResponse, HttpRequest, HttpResponse
|
9
|
+
from django.utils.deprecation import MiddlewareMixin
|
10
|
+
from django.conf import settings
|
11
|
+
from django.utils import timezone
|
12
|
+
from ..models import APIKey, Subscription, EndpointGroup
|
13
|
+
from ..services import ApiKeyCache, RateLimitCache
|
14
|
+
from ..services.security import error_handler, SecurityError
|
15
|
+
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
class APIAccessMiddleware(MiddlewareMixin):
|
20
|
+
"""
|
21
|
+
Middleware for API access control using API keys and subscriptions.
|
22
|
+
|
23
|
+
Features:
|
24
|
+
- API key validation
|
25
|
+
- Subscription status checking
|
26
|
+
- Endpoint access control
|
27
|
+
- Usage tracking
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(self, get_response=None):
|
31
|
+
super().__init__(get_response)
|
32
|
+
self.api_key_cache = ApiKeyCache()
|
33
|
+
self.rate_limit_cache = RateLimitCache()
|
34
|
+
|
35
|
+
# Paths that don't require API key authentication
|
36
|
+
self.exempt_paths = getattr(settings, 'PAYMENTS_EXEMPT_PATHS', [
|
37
|
+
'/api/v1/api-key/validate/',
|
38
|
+
'/api/v1/api-key/create/',
|
39
|
+
'/admin/',
|
40
|
+
'/cfg/',
|
41
|
+
])
|
42
|
+
|
43
|
+
# API prefixes that require authentication
|
44
|
+
self.api_prefixes = getattr(settings, 'PAYMENTS_API_PREFIXES', [
|
45
|
+
'/api/v1/',
|
46
|
+
])
|
47
|
+
|
48
|
+
def process_request(self, request: HttpRequest) -> Optional[JsonResponse]:
|
49
|
+
"""Process incoming request for API access control."""
|
50
|
+
|
51
|
+
# Skip non-API requests
|
52
|
+
if not self._is_api_request(request):
|
53
|
+
return None
|
54
|
+
|
55
|
+
# Skip exempt paths
|
56
|
+
if self._is_exempt_path(request):
|
57
|
+
return None
|
58
|
+
|
59
|
+
# Extract API key
|
60
|
+
api_key = self._extract_api_key(request)
|
61
|
+
if not api_key:
|
62
|
+
security_error = SecurityError(
|
63
|
+
"API key required for protected endpoint",
|
64
|
+
details={'path': request.path, 'method': request.method}
|
65
|
+
)
|
66
|
+
error_handler.handle_error(security_error, {
|
67
|
+
'middleware': 'api_access',
|
68
|
+
'operation': 'api_key_extraction'
|
69
|
+
}, request)
|
70
|
+
|
71
|
+
return self._error_response(
|
72
|
+
'API key required',
|
73
|
+
status=401,
|
74
|
+
error_code='MISSING_API_KEY'
|
75
|
+
)
|
76
|
+
|
77
|
+
# Validate API key
|
78
|
+
api_key_obj = self._validate_api_key(api_key)
|
79
|
+
if not api_key_obj:
|
80
|
+
security_error = SecurityError(
|
81
|
+
f"Invalid or expired API key attempted",
|
82
|
+
details={
|
83
|
+
'api_key_prefix': api_key[:8] + '...' if len(api_key) > 8 else api_key,
|
84
|
+
'path': request.path,
|
85
|
+
'method': request.method,
|
86
|
+
'ip_address': self._get_client_ip(request)
|
87
|
+
}
|
88
|
+
)
|
89
|
+
error_handler.handle_error(security_error, {
|
90
|
+
'middleware': 'api_access',
|
91
|
+
'operation': 'api_key_validation'
|
92
|
+
}, request)
|
93
|
+
|
94
|
+
return self._error_response(
|
95
|
+
'Invalid or expired API key',
|
96
|
+
status=401,
|
97
|
+
error_code='INVALID_API_KEY'
|
98
|
+
)
|
99
|
+
|
100
|
+
# Check subscription access
|
101
|
+
endpoint_group = self._get_endpoint_group(request)
|
102
|
+
if endpoint_group:
|
103
|
+
subscription = self._check_subscription_access(api_key_obj.user, endpoint_group)
|
104
|
+
if not subscription:
|
105
|
+
return self._error_response(
|
106
|
+
f'No active subscription for {endpoint_group.display_name}',
|
107
|
+
status=403,
|
108
|
+
error_code='NO_SUBSCRIPTION'
|
109
|
+
)
|
110
|
+
|
111
|
+
# Check usage limits
|
112
|
+
if self._is_usage_exceeded(subscription):
|
113
|
+
return self._error_response(
|
114
|
+
'Usage limit exceeded for this subscription',
|
115
|
+
status=429,
|
116
|
+
error_code='USAGE_EXCEEDED'
|
117
|
+
)
|
118
|
+
|
119
|
+
# Store subscription in request for usage tracking
|
120
|
+
request.payment_subscription = subscription
|
121
|
+
|
122
|
+
# Store API key in request
|
123
|
+
request.payment_api_key = api_key_obj
|
124
|
+
request.payment_user = api_key_obj.user
|
125
|
+
|
126
|
+
return None
|
127
|
+
|
128
|
+
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
|
129
|
+
"""Process response to track API usage."""
|
130
|
+
|
131
|
+
# Track usage if API key was used
|
132
|
+
if hasattr(request, 'payment_api_key') and hasattr(request, 'payment_subscription'):
|
133
|
+
self._track_usage(request.payment_api_key, request.payment_subscription, request)
|
134
|
+
|
135
|
+
return response
|
136
|
+
|
137
|
+
def _is_api_request(self, request: HttpRequest) -> bool:
|
138
|
+
"""Check if request is an API request."""
|
139
|
+
path = request.path
|
140
|
+
return any(path.startswith(prefix) for prefix in self.api_prefixes)
|
141
|
+
|
142
|
+
def _is_exempt_path(self, request: HttpRequest) -> bool:
|
143
|
+
"""Check if path is exempt from API key requirement."""
|
144
|
+
path = request.path
|
145
|
+
return any(path.startswith(exempt) for exempt in self.exempt_paths)
|
146
|
+
|
147
|
+
def _extract_api_key(self, request: HttpRequest) -> Optional[str]:
|
148
|
+
"""Extract API key from request headers or query params."""
|
149
|
+
|
150
|
+
# Try Authorization header first (Bearer token)
|
151
|
+
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
152
|
+
if auth_header.startswith('Bearer '):
|
153
|
+
return auth_header[7:] # Remove 'Bearer ' prefix
|
154
|
+
|
155
|
+
# Try X-API-Key header
|
156
|
+
api_key = request.META.get('HTTP_X_API_KEY')
|
157
|
+
if api_key:
|
158
|
+
return api_key
|
159
|
+
|
160
|
+
# Try query parameter (less secure, for testing)
|
161
|
+
api_key = request.GET.get('api_key')
|
162
|
+
if api_key:
|
163
|
+
return api_key
|
164
|
+
|
165
|
+
return None
|
166
|
+
|
167
|
+
def _validate_api_key(self, api_key: str) -> Optional[APIKey]:
|
168
|
+
"""Validate API key using Redis cache with DB fallback."""
|
169
|
+
|
170
|
+
try:
|
171
|
+
# Try Redis first
|
172
|
+
cached_key = self.redis_service.get_api_key(api_key)
|
173
|
+
if cached_key:
|
174
|
+
return cached_key
|
175
|
+
|
176
|
+
# Fallback to database
|
177
|
+
api_key_obj = APIKey.objects.select_related('user').filter(
|
178
|
+
key_value=api_key,
|
179
|
+
is_active=True
|
180
|
+
).first()
|
181
|
+
|
182
|
+
if api_key_obj and api_key_obj.is_valid():
|
183
|
+
# Cache valid key
|
184
|
+
self.redis_service.cache_api_key(api_key_obj)
|
185
|
+
|
186
|
+
# Update last used
|
187
|
+
api_key_obj.record_usage()
|
188
|
+
|
189
|
+
return api_key_obj
|
190
|
+
|
191
|
+
return None
|
192
|
+
|
193
|
+
except Exception as e:
|
194
|
+
logger.error(f"Error validating API key: {e}")
|
195
|
+
return None
|
196
|
+
|
197
|
+
def _get_endpoint_group(self, request: HttpRequest) -> Optional[EndpointGroup]:
|
198
|
+
"""Determine endpoint group based on request path."""
|
199
|
+
|
200
|
+
# This would be customized per project
|
201
|
+
# For now, return None (no endpoint group restrictions)
|
202
|
+
# In real implementation, this would map URL patterns to endpoint groups
|
203
|
+
|
204
|
+
path = request.path
|
205
|
+
|
206
|
+
# Example mapping (would be configurable)
|
207
|
+
endpoint_mappings = {
|
208
|
+
'/api/v1/payments/': 'payments',
|
209
|
+
'/api/v1/subscriptions/': 'billing',
|
210
|
+
'/api/v1/users/': 'user_management',
|
211
|
+
}
|
212
|
+
|
213
|
+
for path_prefix, group_name in endpoint_mappings.items():
|
214
|
+
if path.startswith(path_prefix):
|
215
|
+
try:
|
216
|
+
return EndpointGroup.objects.get(name=group_name, is_active=True)
|
217
|
+
except EndpointGroup.DoesNotExist:
|
218
|
+
continue
|
219
|
+
|
220
|
+
return None
|
221
|
+
|
222
|
+
def _check_subscription_access(self, user, endpoint_group: EndpointGroup) -> Optional[Subscription]:
|
223
|
+
"""Check if user has active subscription for endpoint group."""
|
224
|
+
|
225
|
+
try:
|
226
|
+
# Get active subscription for this endpoint group
|
227
|
+
subscription = Subscription.objects.filter(
|
228
|
+
user=user,
|
229
|
+
endpoint_group=endpoint_group,
|
230
|
+
status='active',
|
231
|
+
expires_at__gt=timezone.now()
|
232
|
+
).first()
|
233
|
+
|
234
|
+
return subscription
|
235
|
+
|
236
|
+
except Exception as e:
|
237
|
+
logger.error(f"Error checking subscription access: {e}")
|
238
|
+
return None
|
239
|
+
|
240
|
+
def _is_usage_exceeded(self, subscription: Subscription) -> bool:
|
241
|
+
"""Check if subscription usage limit is exceeded."""
|
242
|
+
|
243
|
+
try:
|
244
|
+
# Check current usage against limit
|
245
|
+
if subscription.usage_limit == 0: # Unlimited
|
246
|
+
return False
|
247
|
+
|
248
|
+
return subscription.usage_current >= subscription.usage_limit
|
249
|
+
|
250
|
+
except Exception as e:
|
251
|
+
logger.error(f"Error checking usage limits: {e}")
|
252
|
+
return False
|
253
|
+
|
254
|
+
def _track_usage(self, api_key: APIKey, subscription: Subscription, request: HttpRequest):
|
255
|
+
"""Track API usage for billing and analytics."""
|
256
|
+
|
257
|
+
try:
|
258
|
+
# Increment subscription usage
|
259
|
+
subscription.usage_current += 1
|
260
|
+
subscription.save(update_fields=['usage_current'])
|
261
|
+
|
262
|
+
# Update Redis cache
|
263
|
+
self.redis_service.increment_usage(api_key.key_value, subscription.id)
|
264
|
+
|
265
|
+
# Log usage for analytics
|
266
|
+
logger.info(
|
267
|
+
f"API usage tracked - User: {api_key.user.id}, "
|
268
|
+
f"Subscription: {subscription.id}, "
|
269
|
+
f"Path: {request.path}, "
|
270
|
+
f"Usage: {subscription.usage_current}/{subscription.usage_limit}"
|
271
|
+
)
|
272
|
+
|
273
|
+
except Exception as e:
|
274
|
+
logger.error(f"Error tracking usage: {e}")
|
275
|
+
|
276
|
+
def _get_client_ip(self, request: HttpRequest) -> str:
|
277
|
+
"""Extract client IP address from request."""
|
278
|
+
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
279
|
+
if x_forwarded_for:
|
280
|
+
ip = x_forwarded_for.split(',')[0].strip()
|
281
|
+
else:
|
282
|
+
ip = request.META.get('REMOTE_ADDR', '')
|
283
|
+
return ip
|
284
|
+
|
285
|
+
def _error_response(self, message: str, status: int = 400, error_code: str = 'ERROR') -> JsonResponse:
|
286
|
+
"""Return standardized error response."""
|
287
|
+
|
288
|
+
return JsonResponse({
|
289
|
+
'error': {
|
290
|
+
'code': error_code,
|
291
|
+
'message': message,
|
292
|
+
'timestamp': timezone.now().isoformat(),
|
293
|
+
}
|
294
|
+
}, status=status)
|
@@ -0,0 +1,216 @@
|
|
1
|
+
"""
|
2
|
+
Rate Limiting Middleware.
|
3
|
+
Implements sliding window rate limiting using Redis.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import logging
|
7
|
+
import time
|
8
|
+
from typing import Optional
|
9
|
+
from django.http import JsonResponse, HttpRequest
|
10
|
+
from django.utils.deprecation import MiddlewareMixin
|
11
|
+
from django.conf import settings
|
12
|
+
from django.utils import timezone
|
13
|
+
from ..services import RateLimitCache
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class RateLimitingMiddleware(MiddlewareMixin):
|
19
|
+
"""
|
20
|
+
Rate limiting middleware using sliding window algorithm.
|
21
|
+
|
22
|
+
Features:
|
23
|
+
- Per-API-key rate limiting
|
24
|
+
- Per-IP rate limiting (fallback)
|
25
|
+
- Sliding window algorithm
|
26
|
+
- Redis-based with circuit breaker
|
27
|
+
- Configurable limits
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(self, get_response=None):
|
31
|
+
super().__init__(get_response)
|
32
|
+
self.rate_limit_cache = RateLimitCache()
|
33
|
+
|
34
|
+
# Default rate limits (can be overridden in settings)
|
35
|
+
self.default_limits = getattr(settings, 'PAYMENTS_RATE_LIMITS', {
|
36
|
+
'per_minute': 60, # 60 requests per minute
|
37
|
+
'per_hour': 1000, # 1000 requests per hour
|
38
|
+
'per_day': 10000, # 10000 requests per day
|
39
|
+
})
|
40
|
+
|
41
|
+
# Paths exempt from rate limiting
|
42
|
+
self.exempt_paths = getattr(settings, 'PAYMENTS_RATE_LIMIT_EXEMPT_PATHS', [
|
43
|
+
'/admin/',
|
44
|
+
'/cfg/',
|
45
|
+
])
|
46
|
+
|
47
|
+
# Enable/disable rate limiting
|
48
|
+
self.enabled = getattr(settings, 'PAYMENTS_RATE_LIMITING_ENABLED', True)
|
49
|
+
|
50
|
+
def process_request(self, request: HttpRequest) -> Optional[JsonResponse]:
|
51
|
+
"""Process request for rate limiting."""
|
52
|
+
|
53
|
+
if not self.enabled:
|
54
|
+
return None
|
55
|
+
|
56
|
+
# Skip exempt paths
|
57
|
+
if self._is_exempt_path(request):
|
58
|
+
return None
|
59
|
+
|
60
|
+
# Get rate limiting key (API key or IP)
|
61
|
+
rate_key = self._get_rate_key(request)
|
62
|
+
if not rate_key:
|
63
|
+
return None
|
64
|
+
|
65
|
+
# Get rate limits for this key
|
66
|
+
limits = self._get_rate_limits(request)
|
67
|
+
|
68
|
+
# Check each time window
|
69
|
+
for window, limit in limits.items():
|
70
|
+
if self._is_rate_limited(rate_key, window, limit):
|
71
|
+
return self._rate_limit_response(window, limit)
|
72
|
+
|
73
|
+
# Record this request
|
74
|
+
self._record_request(rate_key)
|
75
|
+
|
76
|
+
return None
|
77
|
+
|
78
|
+
def _is_exempt_path(self, request: HttpRequest) -> bool:
|
79
|
+
"""Check if path is exempt from rate limiting."""
|
80
|
+
path = request.path
|
81
|
+
return any(path.startswith(exempt) for exempt in self.exempt_paths)
|
82
|
+
|
83
|
+
def _get_rate_key(self, request: HttpRequest) -> Optional[str]:
|
84
|
+
"""Get rate limiting key (API key preferred, IP as fallback)."""
|
85
|
+
|
86
|
+
# Use API key if available (from previous middleware)
|
87
|
+
if hasattr(request, 'payment_api_key'):
|
88
|
+
return f"api_key:{request.payment_api_key.key_value}"
|
89
|
+
|
90
|
+
# Fallback to IP address
|
91
|
+
ip = self._get_client_ip(request)
|
92
|
+
if ip:
|
93
|
+
return f"ip:{ip}"
|
94
|
+
|
95
|
+
return None
|
96
|
+
|
97
|
+
def _get_client_ip(self, request: HttpRequest) -> Optional[str]:
|
98
|
+
"""Get client IP address."""
|
99
|
+
|
100
|
+
# Check for forwarded headers first
|
101
|
+
forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
102
|
+
if forwarded_for:
|
103
|
+
return forwarded_for.split(',')[0].strip()
|
104
|
+
|
105
|
+
# Check for real IP header
|
106
|
+
real_ip = request.META.get('HTTP_X_REAL_IP')
|
107
|
+
if real_ip:
|
108
|
+
return real_ip
|
109
|
+
|
110
|
+
# Fallback to remote address
|
111
|
+
return request.META.get('REMOTE_ADDR')
|
112
|
+
|
113
|
+
def _get_rate_limits(self, request: HttpRequest) -> dict:
|
114
|
+
"""Get rate limits for this request."""
|
115
|
+
|
116
|
+
# Check if API key has custom limits
|
117
|
+
if hasattr(request, 'payment_api_key'):
|
118
|
+
api_key = request.payment_api_key
|
119
|
+
|
120
|
+
# Check if user has subscription with custom limits
|
121
|
+
if hasattr(request, 'payment_subscription'):
|
122
|
+
subscription = request.payment_subscription
|
123
|
+
# Custom limits based on subscription tier could be implemented here
|
124
|
+
# For now, use default limits
|
125
|
+
pass
|
126
|
+
|
127
|
+
return self.default_limits
|
128
|
+
|
129
|
+
def _is_rate_limited(self, rate_key: str, window: str, limit: int) -> bool:
|
130
|
+
"""Check if rate limit is exceeded for given window."""
|
131
|
+
|
132
|
+
try:
|
133
|
+
# Get window duration in seconds
|
134
|
+
window_seconds = self._get_window_seconds(window)
|
135
|
+
if not window_seconds:
|
136
|
+
return False
|
137
|
+
|
138
|
+
# Use Redis sliding window
|
139
|
+
current_time = int(time.time())
|
140
|
+
window_start = current_time - window_seconds
|
141
|
+
|
142
|
+
# Get request count in window
|
143
|
+
redis_key = f"rate_limit:{rate_key}:{window}"
|
144
|
+
|
145
|
+
# Use Redis sorted set for sliding window
|
146
|
+
# Remove old entries and count current entries
|
147
|
+
count = self.redis_service.sliding_window_count(
|
148
|
+
redis_key,
|
149
|
+
window_start,
|
150
|
+
current_time,
|
151
|
+
window_seconds
|
152
|
+
)
|
153
|
+
|
154
|
+
return count >= limit
|
155
|
+
|
156
|
+
except Exception as e:
|
157
|
+
logger.error(f"Error checking rate limit: {e}")
|
158
|
+
# On error, allow request (fail open)
|
159
|
+
return False
|
160
|
+
|
161
|
+
def _get_window_seconds(self, window: str) -> Optional[int]:
|
162
|
+
"""Convert window name to seconds."""
|
163
|
+
|
164
|
+
window_map = {
|
165
|
+
'per_minute': 60,
|
166
|
+
'per_hour': 3600,
|
167
|
+
'per_day': 86400,
|
168
|
+
}
|
169
|
+
|
170
|
+
return window_map.get(window)
|
171
|
+
|
172
|
+
def _record_request(self, rate_key: str):
|
173
|
+
"""Record request for rate limiting."""
|
174
|
+
|
175
|
+
try:
|
176
|
+
current_time = int(time.time())
|
177
|
+
|
178
|
+
# Record for each window
|
179
|
+
for window in self.default_limits.keys():
|
180
|
+
window_seconds = self._get_window_seconds(window)
|
181
|
+
if window_seconds:
|
182
|
+
redis_key = f"rate_limit:{rate_key}:{window}"
|
183
|
+
|
184
|
+
# Add current timestamp to sorted set
|
185
|
+
self.redis_service.record_request(
|
186
|
+
redis_key,
|
187
|
+
current_time,
|
188
|
+
window_seconds
|
189
|
+
)
|
190
|
+
|
191
|
+
except Exception as e:
|
192
|
+
logger.error(f"Error recording request: {e}")
|
193
|
+
|
194
|
+
def _rate_limit_response(self, window: str, limit: int) -> JsonResponse:
|
195
|
+
"""Return rate limit exceeded response."""
|
196
|
+
|
197
|
+
window_seconds = self._get_window_seconds(window)
|
198
|
+
retry_after = window_seconds if window_seconds else 60
|
199
|
+
|
200
|
+
response = JsonResponse({
|
201
|
+
'error': {
|
202
|
+
'code': 'RATE_LIMIT_EXCEEDED',
|
203
|
+
'message': f'Rate limit exceeded: {limit} requests {window}',
|
204
|
+
'limit': limit,
|
205
|
+
'window': window,
|
206
|
+
'retry_after': retry_after,
|
207
|
+
'timestamp': timezone.now().isoformat(),
|
208
|
+
}
|
209
|
+
}, status=429)
|
210
|
+
|
211
|
+
# Add rate limit headers
|
212
|
+
response['X-RateLimit-Limit'] = str(limit)
|
213
|
+
response['X-RateLimit-Window'] = window
|
214
|
+
response['Retry-After'] = str(retry_after)
|
215
|
+
|
216
|
+
return response
|