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,475 @@
|
|
1
|
+
"""
|
2
|
+
Enhanced Webhook Signature Validation Service.
|
3
|
+
Critical Foundation Security Component.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import json
|
7
|
+
import hmac
|
8
|
+
import hashlib
|
9
|
+
import logging
|
10
|
+
import time
|
11
|
+
from typing import Dict, Any, Optional, Tuple
|
12
|
+
from datetime import datetime, timedelta
|
13
|
+
from django.core.cache import cache
|
14
|
+
from django.utils import timezone
|
15
|
+
from django.conf import settings
|
16
|
+
|
17
|
+
from django_cfg.apps.payments.config import get_payments_config
|
18
|
+
from django_cfg.apps.payments.models.events import PaymentEvent
|
19
|
+
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
|
23
|
+
class WebhookValidator:
|
24
|
+
"""
|
25
|
+
Secure webhook signature validation with replay attack protection.
|
26
|
+
|
27
|
+
Foundation Security Component - CRITICAL for system security.
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(self):
|
31
|
+
self.config = get_payments_config()
|
32
|
+
self.nonce_cache_timeout = 3600 # 1 hour nonce validity
|
33
|
+
self.max_timestamp_drift = 300 # 5 minutes max timestamp drift
|
34
|
+
|
35
|
+
def validate_webhook(
|
36
|
+
self,
|
37
|
+
provider: str,
|
38
|
+
webhook_data: Dict[str, Any],
|
39
|
+
request_headers: Dict[str, str],
|
40
|
+
raw_body: bytes = None
|
41
|
+
) -> Tuple[bool, Optional[str]]:
|
42
|
+
"""
|
43
|
+
Comprehensive webhook validation with security checks.
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
Tuple[bool, Optional[str]]: (is_valid, error_message)
|
47
|
+
"""
|
48
|
+
|
49
|
+
try:
|
50
|
+
# Step 1: Provider-specific signature validation
|
51
|
+
signature_valid, signature_error = self._validate_provider_signature(
|
52
|
+
provider, webhook_data, request_headers, raw_body
|
53
|
+
)
|
54
|
+
|
55
|
+
if not signature_valid:
|
56
|
+
self._log_security_event('signature_validation_failed', provider, signature_error)
|
57
|
+
return False, signature_error
|
58
|
+
|
59
|
+
# Step 2: Replay attack protection
|
60
|
+
replay_valid, replay_error = self._validate_against_replay(
|
61
|
+
provider, webhook_data, request_headers
|
62
|
+
)
|
63
|
+
|
64
|
+
if not replay_valid:
|
65
|
+
self._log_security_event('replay_attack_detected', provider, replay_error)
|
66
|
+
return False, replay_error
|
67
|
+
|
68
|
+
# Step 3: Timestamp validation
|
69
|
+
timestamp_valid, timestamp_error = self._validate_timestamp(
|
70
|
+
webhook_data, request_headers
|
71
|
+
)
|
72
|
+
|
73
|
+
if not timestamp_valid:
|
74
|
+
self._log_security_event('timestamp_validation_failed', provider, timestamp_error)
|
75
|
+
return False, timestamp_error
|
76
|
+
|
77
|
+
# Step 4: Rate limiting check
|
78
|
+
rate_limit_valid, rate_limit_error = self._check_rate_limits(
|
79
|
+
provider, request_headers
|
80
|
+
)
|
81
|
+
|
82
|
+
if not rate_limit_valid:
|
83
|
+
self._log_security_event('rate_limit_exceeded', provider, rate_limit_error)
|
84
|
+
return False, rate_limit_error
|
85
|
+
|
86
|
+
# All validations passed
|
87
|
+
self._log_security_event('webhook_validated', provider, 'Validation successful')
|
88
|
+
return True, None
|
89
|
+
|
90
|
+
except Exception as e:
|
91
|
+
error_msg = f"Webhook validation error: {str(e)}"
|
92
|
+
logger.error(f"Critical validation error for {provider}: {e}", exc_info=True)
|
93
|
+
self._log_security_event('validation_exception', provider, error_msg)
|
94
|
+
return False, error_msg
|
95
|
+
|
96
|
+
def _validate_provider_signature(
|
97
|
+
self,
|
98
|
+
provider: str,
|
99
|
+
webhook_data: Dict[str, Any],
|
100
|
+
request_headers: Dict[str, str],
|
101
|
+
raw_body: bytes = None
|
102
|
+
) -> Tuple[bool, Optional[str]]:
|
103
|
+
"""Validate signature based on provider-specific method."""
|
104
|
+
|
105
|
+
if provider == 'cryptapi':
|
106
|
+
return self._validate_cryptapi_signature(webhook_data, request_headers)
|
107
|
+
elif provider == 'cryptomus':
|
108
|
+
return self._validate_cryptomus_signature(webhook_data, request_headers, raw_body)
|
109
|
+
elif provider == 'nowpayments':
|
110
|
+
return self._validate_nowpayments_signature(webhook_data, request_headers, raw_body)
|
111
|
+
elif provider == 'test':
|
112
|
+
return True, None # Allow test webhooks in development
|
113
|
+
else:
|
114
|
+
return False, f"Unknown provider: {provider}"
|
115
|
+
|
116
|
+
def _validate_cryptapi_signature(
|
117
|
+
self,
|
118
|
+
webhook_data: Dict[str, Any],
|
119
|
+
request_headers: Dict[str, str]
|
120
|
+
) -> Tuple[bool, Optional[str]]:
|
121
|
+
"""
|
122
|
+
CryptAPI signature validation with nonce verification.
|
123
|
+
|
124
|
+
CRITICAL FIX: Proper nonce validation to prevent replay attacks.
|
125
|
+
"""
|
126
|
+
|
127
|
+
# Get security nonce from webhook data
|
128
|
+
security_nonce = webhook_data.get('nonce')
|
129
|
+
if not security_nonce:
|
130
|
+
return False, "Missing security nonce in CryptAPI webhook"
|
131
|
+
|
132
|
+
# Validate nonce format and uniqueness
|
133
|
+
nonce_valid, nonce_error = self._validate_nonce(security_nonce, 'cryptapi')
|
134
|
+
if not nonce_valid:
|
135
|
+
return False, f"Invalid security nonce: {nonce_error}"
|
136
|
+
|
137
|
+
# Check required fields
|
138
|
+
required_fields = ['order_id', 'value_coin', 'confirmations']
|
139
|
+
missing_fields = [field for field in required_fields if field not in webhook_data]
|
140
|
+
if missing_fields:
|
141
|
+
return False, f"Missing required fields: {', '.join(missing_fields)}"
|
142
|
+
|
143
|
+
# Validate order_id format
|
144
|
+
order_id = webhook_data.get('order_id')
|
145
|
+
if not self._validate_order_id_format(order_id):
|
146
|
+
return False, f"Invalid order_id format: {order_id}"
|
147
|
+
|
148
|
+
# CryptAPI specific validation: check if address exists in our system
|
149
|
+
address_in = webhook_data.get('address')
|
150
|
+
if address_in and not self._validate_payment_address(address_in, 'cryptapi'):
|
151
|
+
return False, f"Unknown payment address: {address_in}"
|
152
|
+
|
153
|
+
return True, None
|
154
|
+
|
155
|
+
def _validate_cryptomus_signature(
|
156
|
+
self,
|
157
|
+
webhook_data: Dict[str, Any],
|
158
|
+
request_headers: Dict[str, str],
|
159
|
+
raw_body: bytes = None
|
160
|
+
) -> Tuple[bool, Optional[str]]:
|
161
|
+
"""
|
162
|
+
Cryptomus webhook signature validation.
|
163
|
+
|
164
|
+
Uses HMAC-SHA256 signature verification.
|
165
|
+
"""
|
166
|
+
|
167
|
+
# Get webhook secret from configuration
|
168
|
+
if not self.config or not hasattr(self.config, 'providers'):
|
169
|
+
return False, "Cryptomus configuration not found"
|
170
|
+
|
171
|
+
cryptomus_config = self.config.providers.get('cryptomus')
|
172
|
+
if not cryptomus_config:
|
173
|
+
return False, "Cryptomus provider not configured"
|
174
|
+
|
175
|
+
webhook_secret = getattr(cryptomus_config, 'webhook_secret', None)
|
176
|
+
if not webhook_secret:
|
177
|
+
logger.warning("Cryptomus webhook secret not configured, skipping validation")
|
178
|
+
return True, None # Allow if not configured (development mode)
|
179
|
+
|
180
|
+
# Get signature from headers
|
181
|
+
signature = request_headers.get('HTTP_X_CRYPTOMUS_SIGNATURE')
|
182
|
+
if not signature:
|
183
|
+
return False, "Missing Cryptomus signature header"
|
184
|
+
|
185
|
+
# Calculate expected signature
|
186
|
+
if raw_body:
|
187
|
+
payload = raw_body
|
188
|
+
else:
|
189
|
+
payload = json.dumps(webhook_data, separators=(',', ':'), sort_keys=True).encode()
|
190
|
+
|
191
|
+
expected_signature = hmac.new(
|
192
|
+
webhook_secret.encode(),
|
193
|
+
payload,
|
194
|
+
hashlib.sha256
|
195
|
+
).hexdigest()
|
196
|
+
|
197
|
+
# Secure comparison
|
198
|
+
if not hmac.compare_digest(signature, expected_signature):
|
199
|
+
return False, "Invalid Cryptomus signature"
|
200
|
+
|
201
|
+
# Validate required fields
|
202
|
+
required_fields = ['order_id', 'status']
|
203
|
+
missing_fields = [field for field in required_fields if field not in webhook_data]
|
204
|
+
if missing_fields:
|
205
|
+
return False, f"Missing required fields: {', '.join(missing_fields)}"
|
206
|
+
|
207
|
+
return True, None
|
208
|
+
|
209
|
+
def _validate_nowpayments_signature(
|
210
|
+
self,
|
211
|
+
webhook_data: Dict[str, Any],
|
212
|
+
request_headers: Dict[str, str],
|
213
|
+
raw_body: bytes = None
|
214
|
+
) -> Tuple[bool, Optional[str]]:
|
215
|
+
"""
|
216
|
+
NowPayments IPN signature validation.
|
217
|
+
|
218
|
+
Uses HMAC-SHA512 signature verification.
|
219
|
+
"""
|
220
|
+
|
221
|
+
# Get IPN secret from configuration
|
222
|
+
if not self.config or not hasattr(self.config, 'providers'):
|
223
|
+
return False, "NowPayments configuration not found"
|
224
|
+
|
225
|
+
nowpayments_config = self.config.providers.get('nowpayments')
|
226
|
+
if not nowpayments_config:
|
227
|
+
return False, "NowPayments provider not configured"
|
228
|
+
|
229
|
+
ipn_secret = getattr(nowpayments_config, 'ipn_secret', None)
|
230
|
+
if not ipn_secret:
|
231
|
+
logger.warning("NowPayments IPN secret not configured, skipping validation")
|
232
|
+
return True, None # Allow if not configured (development mode)
|
233
|
+
|
234
|
+
# Get signature from headers
|
235
|
+
signature = request_headers.get('HTTP_X_NOWPAYMENTS_SIG')
|
236
|
+
if not signature:
|
237
|
+
return False, "Missing NowPayments signature header"
|
238
|
+
|
239
|
+
# Calculate expected signature
|
240
|
+
if raw_body:
|
241
|
+
payload = raw_body.decode('utf-8')
|
242
|
+
else:
|
243
|
+
payload = json.dumps(webhook_data, separators=(',', ':'), sort_keys=True)
|
244
|
+
|
245
|
+
expected_signature = hmac.new(
|
246
|
+
ipn_secret.encode(),
|
247
|
+
payload.encode(),
|
248
|
+
hashlib.sha512
|
249
|
+
).hexdigest()
|
250
|
+
|
251
|
+
# Secure comparison
|
252
|
+
if not hmac.compare_digest(signature, expected_signature):
|
253
|
+
return False, "Invalid NowPayments signature"
|
254
|
+
|
255
|
+
return True, None
|
256
|
+
|
257
|
+
def _validate_against_replay(
|
258
|
+
self,
|
259
|
+
provider: str,
|
260
|
+
webhook_data: Dict[str, Any],
|
261
|
+
request_headers: Dict[str, str]
|
262
|
+
) -> Tuple[bool, Optional[str]]:
|
263
|
+
"""
|
264
|
+
Protect against replay attacks using idempotency keys.
|
265
|
+
"""
|
266
|
+
|
267
|
+
# Generate idempotency key
|
268
|
+
idempotency_key = self._generate_idempotency_key(provider, webhook_data, request_headers)
|
269
|
+
|
270
|
+
# Check if we've seen this webhook before
|
271
|
+
cache_key = f"webhook_idempotency:{idempotency_key}"
|
272
|
+
if cache.get(cache_key):
|
273
|
+
return False, f"Replay attack detected: duplicate webhook {idempotency_key}"
|
274
|
+
|
275
|
+
# Store idempotency key to prevent replays
|
276
|
+
cache.set(cache_key, True, timeout=self.nonce_cache_timeout)
|
277
|
+
|
278
|
+
return True, None
|
279
|
+
|
280
|
+
def _validate_timestamp(
|
281
|
+
self,
|
282
|
+
webhook_data: Dict[str, Any],
|
283
|
+
request_headers: Dict[str, str]
|
284
|
+
) -> Tuple[bool, Optional[str]]:
|
285
|
+
"""
|
286
|
+
Validate webhook timestamp to prevent old webhook replay.
|
287
|
+
"""
|
288
|
+
|
289
|
+
# Try different timestamp fields
|
290
|
+
timestamp_fields = ['timestamp', 'created_at', 'updated_at', 'time']
|
291
|
+
webhook_timestamp = None
|
292
|
+
|
293
|
+
for field in timestamp_fields:
|
294
|
+
if field in webhook_data:
|
295
|
+
webhook_timestamp = webhook_data[field]
|
296
|
+
break
|
297
|
+
|
298
|
+
# Also check headers
|
299
|
+
if not webhook_timestamp:
|
300
|
+
webhook_timestamp = request_headers.get('HTTP_X_TIMESTAMP')
|
301
|
+
|
302
|
+
if not webhook_timestamp:
|
303
|
+
# If no timestamp provided, skip validation (some providers don't include it)
|
304
|
+
return True, None
|
305
|
+
|
306
|
+
try:
|
307
|
+
# Parse timestamp (support multiple formats)
|
308
|
+
if isinstance(webhook_timestamp, (int, float)):
|
309
|
+
webhook_time = datetime.fromtimestamp(webhook_timestamp, tz=timezone.utc)
|
310
|
+
else:
|
311
|
+
# Try to parse ISO format
|
312
|
+
webhook_time = datetime.fromisoformat(webhook_timestamp.replace('Z', '+00:00'))
|
313
|
+
|
314
|
+
current_time = timezone.now()
|
315
|
+
time_diff = abs((current_time - webhook_time).total_seconds())
|
316
|
+
|
317
|
+
if time_diff > self.max_timestamp_drift:
|
318
|
+
return False, f"Webhook timestamp too old or too new: {time_diff}s drift"
|
319
|
+
|
320
|
+
return True, None
|
321
|
+
|
322
|
+
except Exception as e:
|
323
|
+
logger.warning(f"Could not validate timestamp: {e}")
|
324
|
+
return True, None # Skip validation if timestamp format is unknown
|
325
|
+
|
326
|
+
def _check_rate_limits(
|
327
|
+
self,
|
328
|
+
provider: str,
|
329
|
+
request_headers: Dict[str, str]
|
330
|
+
) -> Tuple[bool, Optional[str]]:
|
331
|
+
"""
|
332
|
+
Check webhook rate limits to prevent abuse.
|
333
|
+
"""
|
334
|
+
|
335
|
+
# Extract IP address
|
336
|
+
ip_address = (
|
337
|
+
request_headers.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or
|
338
|
+
request_headers.get('HTTP_X_REAL_IP', '') or
|
339
|
+
request_headers.get('REMOTE_ADDR', 'unknown')
|
340
|
+
)
|
341
|
+
|
342
|
+
# Rate limit key
|
343
|
+
rate_limit_key = f"webhook_rate_limit:{provider}:{ip_address}"
|
344
|
+
|
345
|
+
# Check current rate
|
346
|
+
current_count = cache.get(rate_limit_key, 0)
|
347
|
+
max_webhooks_per_minute = 60 # Configurable limit
|
348
|
+
|
349
|
+
if current_count >= max_webhooks_per_minute:
|
350
|
+
return False, f"Rate limit exceeded: {current_count} webhooks/minute from {ip_address}"
|
351
|
+
|
352
|
+
# Increment counter
|
353
|
+
cache.set(rate_limit_key, current_count + 1, timeout=60)
|
354
|
+
|
355
|
+
return True, None
|
356
|
+
|
357
|
+
def _validate_nonce(self, nonce: str, provider: str) -> Tuple[bool, Optional[str]]:
|
358
|
+
"""
|
359
|
+
Validate nonce format and uniqueness.
|
360
|
+
|
361
|
+
CRITICAL: Prevents replay attacks for CryptAPI.
|
362
|
+
"""
|
363
|
+
|
364
|
+
# Validate nonce format
|
365
|
+
if not nonce or len(nonce) < 8:
|
366
|
+
return False, "Nonce too short"
|
367
|
+
|
368
|
+
if len(nonce) > 64:
|
369
|
+
return False, "Nonce too long"
|
370
|
+
|
371
|
+
# Check nonce uniqueness
|
372
|
+
nonce_key = f"webhook_nonce:{provider}:{nonce}"
|
373
|
+
if cache.get(nonce_key):
|
374
|
+
return False, "Nonce already used (replay attack)"
|
375
|
+
|
376
|
+
# Store nonce to prevent reuse
|
377
|
+
cache.set(nonce_key, True, timeout=self.nonce_cache_timeout)
|
378
|
+
|
379
|
+
return True, None
|
380
|
+
|
381
|
+
def _validate_order_id_format(self, order_id: str) -> bool:
|
382
|
+
"""Validate order ID format."""
|
383
|
+
if not order_id:
|
384
|
+
return False
|
385
|
+
|
386
|
+
# Basic validation (customize per your order ID format)
|
387
|
+
if len(order_id) < 3 or len(order_id) > 50:
|
388
|
+
return False
|
389
|
+
|
390
|
+
# Allow alphanumeric, dash, underscore
|
391
|
+
import re
|
392
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', order_id):
|
393
|
+
return False
|
394
|
+
|
395
|
+
return True
|
396
|
+
|
397
|
+
def _validate_payment_address(self, address: str, provider: str) -> bool:
|
398
|
+
"""
|
399
|
+
Validate that payment address exists in our system.
|
400
|
+
|
401
|
+
CRITICAL: Prevents webhooks for unknown addresses.
|
402
|
+
"""
|
403
|
+
from ..models.payments import UniversalPayment
|
404
|
+
|
405
|
+
try:
|
406
|
+
# Check if address exists in our payments
|
407
|
+
return UniversalPayment.objects.filter(
|
408
|
+
provider=provider,
|
409
|
+
pay_address=address
|
410
|
+
).exists()
|
411
|
+
except Exception as e:
|
412
|
+
logger.error(f"Error validating payment address: {e}")
|
413
|
+
return True # Allow if validation fails (avoid false negatives)
|
414
|
+
|
415
|
+
def _generate_idempotency_key(
|
416
|
+
self,
|
417
|
+
provider: str,
|
418
|
+
webhook_data: Dict[str, Any],
|
419
|
+
request_headers: Dict[str, str]
|
420
|
+
) -> str:
|
421
|
+
"""Generate secure idempotency key for webhook deduplication."""
|
422
|
+
|
423
|
+
# Use multiple fields for uniqueness
|
424
|
+
payment_id = (
|
425
|
+
webhook_data.get('payment_id') or
|
426
|
+
webhook_data.get('order_id') or
|
427
|
+
webhook_data.get('id') or
|
428
|
+
webhook_data.get('uuid') or
|
429
|
+
'unknown'
|
430
|
+
)
|
431
|
+
|
432
|
+
# Include status to allow multiple status updates for same payment
|
433
|
+
status = webhook_data.get('status', 'unknown')
|
434
|
+
|
435
|
+
# Include timestamp for additional uniqueness
|
436
|
+
timestamp = (
|
437
|
+
webhook_data.get('timestamp') or
|
438
|
+
webhook_data.get('created_at') or
|
439
|
+
webhook_data.get('updated_at') or
|
440
|
+
str(int(time.time()))
|
441
|
+
)
|
442
|
+
|
443
|
+
# Create secure hash
|
444
|
+
key_data = f"{provider}:{payment_id}:{status}:{timestamp}"
|
445
|
+
return hashlib.sha256(key_data.encode()).hexdigest()[:32]
|
446
|
+
|
447
|
+
def _log_security_event(self, event_type: str, provider: str, details: str):
|
448
|
+
"""Log security events for monitoring and alerting."""
|
449
|
+
|
450
|
+
try:
|
451
|
+
# Create security event log
|
452
|
+
PaymentEvent.objects.create(
|
453
|
+
event_type=f'security_{event_type}',
|
454
|
+
provider=provider,
|
455
|
+
metadata={
|
456
|
+
'event_type': event_type,
|
457
|
+
'provider': provider,
|
458
|
+
'details': details,
|
459
|
+
'timestamp': timezone.now().isoformat(),
|
460
|
+
'severity': 'HIGH' if 'attack' in event_type else 'MEDIUM'
|
461
|
+
}
|
462
|
+
)
|
463
|
+
|
464
|
+
# Log to application logger
|
465
|
+
if 'attack' in event_type or 'failed' in event_type:
|
466
|
+
logger.warning(f"🚨 Security Event [{event_type}] {provider}: {details}")
|
467
|
+
else:
|
468
|
+
logger.info(f"🔒 Security Event [{event_type}] {provider}: {details}")
|
469
|
+
|
470
|
+
except Exception as e:
|
471
|
+
logger.error(f"Failed to log security event: {e}")
|
472
|
+
|
473
|
+
|
474
|
+
# Singleton instance for import
|
475
|
+
webhook_validator = WebhookValidator()
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""
|
2
|
+
Universal Payment Signals.
|
3
|
+
|
4
|
+
Automatically imports all signal handlers when the payments app is loaded.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from .api_key_signals import * # noqa: F401,F403
|
8
|
+
from .payment_signals import * # noqa: F401,F403
|
9
|
+
from .subscription_signals import * # noqa: F401,F403
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
# Signal functions are automatically exported by Django
|
13
|
+
]
|
@@ -0,0 +1,160 @@
|
|
1
|
+
"""
|
2
|
+
🔄 Universal API Keys Auto-Creation Signals
|
3
|
+
|
4
|
+
Automatic API key creation and management via Django signals.
|
5
|
+
Enhanced version of CarAPI signals with universal support.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from django.db.models.signals import post_save, post_delete, pre_save
|
9
|
+
from django.dispatch import receiver
|
10
|
+
from django.contrib.auth import get_user_model
|
11
|
+
from django.db import transaction
|
12
|
+
from django.utils import timezone
|
13
|
+
import logging
|
14
|
+
|
15
|
+
from ..models import APIKey
|
16
|
+
|
17
|
+
User = get_user_model()
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
@receiver(post_save, sender=User)
|
22
|
+
def create_default_api_key(sender, instance, created, **kwargs):
|
23
|
+
"""
|
24
|
+
Automatically create default API key for new users.
|
25
|
+
This ensures every user can immediately start using the API.
|
26
|
+
"""
|
27
|
+
if created:
|
28
|
+
try:
|
29
|
+
with transaction.atomic():
|
30
|
+
import secrets
|
31
|
+
key_value = f"ak_{secrets.token_urlsafe(32)}"
|
32
|
+
|
33
|
+
api_key = APIKey.objects.create(
|
34
|
+
user=instance,
|
35
|
+
name="Default API Key",
|
36
|
+
key_value=key_value,
|
37
|
+
key_prefix=key_value[:8],
|
38
|
+
is_active=True
|
39
|
+
)
|
40
|
+
|
41
|
+
logger.info(
|
42
|
+
f"Created default API key for user {instance.email}: {api_key.key_prefix}***"
|
43
|
+
)
|
44
|
+
|
45
|
+
# Optional: Send welcome email with API key info
|
46
|
+
# This would be handled in custom project implementations
|
47
|
+
# from .tasks import send_api_key_welcome_email
|
48
|
+
# send_api_key_welcome_email.delay(instance.id, api_key.id)
|
49
|
+
|
50
|
+
except Exception as e:
|
51
|
+
logger.error(f"Failed to create default API key for user {instance.email}: {e}")
|
52
|
+
|
53
|
+
|
54
|
+
@receiver(post_save, sender=User)
|
55
|
+
def ensure_user_has_api_key(sender, instance, **kwargs):
|
56
|
+
"""
|
57
|
+
Ensure user always has at least one API key.
|
58
|
+
Creates one if user has no active keys.
|
59
|
+
"""
|
60
|
+
# Skip if this is a new user (handled by create_default_api_key)
|
61
|
+
if kwargs.get('created', False):
|
62
|
+
return
|
63
|
+
|
64
|
+
# Check if user has any active keys
|
65
|
+
if not APIKey.objects.filter(user=instance, is_active=True).exists():
|
66
|
+
try:
|
67
|
+
with transaction.atomic():
|
68
|
+
import secrets
|
69
|
+
key_value = f"ak_{secrets.token_urlsafe(32)}"
|
70
|
+
|
71
|
+
api_key = APIKey.objects.create(
|
72
|
+
user=instance,
|
73
|
+
name="Recovery API Key",
|
74
|
+
key_value=key_value,
|
75
|
+
key_prefix=key_value[:8],
|
76
|
+
is_active=True
|
77
|
+
)
|
78
|
+
logger.info(
|
79
|
+
f"Created recovery API key for user {instance.email}: {api_key.key_prefix}***"
|
80
|
+
)
|
81
|
+
except Exception as e:
|
82
|
+
logger.error(f"Failed to create recovery API key for user {instance.email}: {e}")
|
83
|
+
|
84
|
+
|
85
|
+
@receiver(pre_save, sender=APIKey)
|
86
|
+
def store_original_status(sender, instance, **kwargs):
|
87
|
+
"""Store original status for change detection."""
|
88
|
+
if instance.pk:
|
89
|
+
try:
|
90
|
+
old_instance = APIKey.objects.get(pk=instance.pk)
|
91
|
+
instance._original_is_active = old_instance.is_active
|
92
|
+
except APIKey.DoesNotExist:
|
93
|
+
instance._original_is_active = None
|
94
|
+
|
95
|
+
|
96
|
+
@receiver(post_save, sender=APIKey)
|
97
|
+
def log_api_key_changes(sender, instance, created, **kwargs):
|
98
|
+
"""Log API key creation and status changes for security monitoring."""
|
99
|
+
if created:
|
100
|
+
logger.info(
|
101
|
+
f"New API key created: {instance.name} ({instance.key_prefix}***) "
|
102
|
+
f"for user {instance.user.email}"
|
103
|
+
)
|
104
|
+
else:
|
105
|
+
# Check if status changed
|
106
|
+
if hasattr(instance, '_original_is_active'):
|
107
|
+
old_status = instance._original_is_active
|
108
|
+
new_status = instance.is_active
|
109
|
+
|
110
|
+
if old_status is not None and old_status != new_status:
|
111
|
+
status_text = "activated" if new_status else "deactivated"
|
112
|
+
logger.warning(
|
113
|
+
f"API key {status_text}: {instance.name} ({instance.key_prefix}***) "
|
114
|
+
f"for user {instance.user.email}"
|
115
|
+
)
|
116
|
+
|
117
|
+
|
118
|
+
@receiver(post_save, sender=APIKey)
|
119
|
+
def update_last_used_on_activation(sender, instance, created, **kwargs):
|
120
|
+
"""Update last_used when API key is activated."""
|
121
|
+
if not created and instance.is_active and hasattr(instance, '_original_is_active'):
|
122
|
+
if instance._original_is_active is False and instance.is_active is True:
|
123
|
+
# Key was just activated
|
124
|
+
APIKey.objects.filter(pk=instance.pk).update(
|
125
|
+
last_used=timezone.now()
|
126
|
+
)
|
127
|
+
|
128
|
+
|
129
|
+
@receiver(post_delete, sender=APIKey)
|
130
|
+
def log_api_key_deletion(sender, instance, **kwargs):
|
131
|
+
"""Log API key deletions for security audit."""
|
132
|
+
logger.warning(
|
133
|
+
f"API key deleted: {instance.name} ({instance.key_prefix}***) "
|
134
|
+
f"for user {instance.user.email} - Status was: {'active' if instance.is_active else 'inactive'}"
|
135
|
+
)
|
136
|
+
|
137
|
+
|
138
|
+
@receiver(post_delete, sender=APIKey)
|
139
|
+
def ensure_user_has_remaining_key(sender, instance, **kwargs):
|
140
|
+
"""
|
141
|
+
Ensure user still has at least one API key after deletion.
|
142
|
+
Creates a new one if this was the last active key.
|
143
|
+
"""
|
144
|
+
user = instance.user
|
145
|
+
|
146
|
+
# Check if user has any remaining active keys
|
147
|
+
if not APIKey.objects.filter(user=user, is_active=True).exists():
|
148
|
+
try:
|
149
|
+
with transaction.atomic():
|
150
|
+
api_key = APIKey.objects.create(
|
151
|
+
user=user,
|
152
|
+
name="Auto Recovery API Key",
|
153
|
+
is_active=True
|
154
|
+
)
|
155
|
+
logger.info(
|
156
|
+
f"Created auto-recovery API key for user {user.email}: {api_key.key_prefix}*** "
|
157
|
+
f"(previous key was deleted)"
|
158
|
+
)
|
159
|
+
except Exception as e:
|
160
|
+
logger.error(f"Failed to create auto-recovery API key for user {user.email}: {e}")
|