throttling, abuse-protection & logging/monitoring pass

This commit is contained in:
2026-08-23 23:44:00 +03:30
parent cb18c93a3e
commit 80298b47b1
33 changed files with 4202 additions and 23 deletions

View File

@@ -1,8 +1,10 @@
from unittest.mock import patch
from django.conf import settings
from django.db.models import F
from django.test import TestCase, TransactionTestCase
from django.test import TestCase, TransactionTestCase, override_settings
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token
from accounts.models import User
@@ -296,3 +298,224 @@ class PurchaseIdenticalIntermediateStateRaceRegressionTests(TestCase):
self.assertEqual(subscription.plan_id, self.plan.id)
self.assertIsNone(subscription.order_id)
self.assertEqual(subscription.version, 2)
class PurchaseThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی PurchaseView (throttle_scope='purchase'):
تعدادِ زیادِ درخواستِ purchase با Idempotency-Key های *متفاوت* از یک
اکانتِ احرازهویت‌شده باید بعد از حدِ مجاز با ۴۲۹ رد بشه — چون محافظتِ
idempotency فقط جلوی reuse یک کلیدِ یکسان رو می‌گیره، نه حجمِ بالای
تلاش‌های *متفاوت*. ScopedRateThrottle این‌جا با pk کاربر (نه IP) کلید
می‌خوره، پس تغییرِ IP از همون اکانت این محدودیت رو دور نمی‌زنه.
"""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self, idempotency_key, remote_addr=None):
kwargs = {}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
**kwargs,
)
@override_settings(
REST_FRAMEWORK={
**settings.REST_FRAMEWORK,
'DEFAULT_THROTTLE_RATES': {
**settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'],
'purchase': '3/hour',
},
}
)
def test_requests_within_the_limit_still_succeed(self):
# ⚠️ مثلِ GuestSessionThrottleTests: override_settings به‌تنهایی روی
# ScopedRateThrottle.THROTTLE_RATES (که یک‌بار موقعِ import گرفته
# شده) اثر نداره؛ برای اینکه نرخِ واقعاً محدودشده اعمال بشه، مستقیماً
# دیکشنریِ زنده‌ی THROTTLE_RATES رو patch می‌کنیم.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
for i in range(3):
response = self._purchase(f'key-within-limit-{i}')
self.assertEqual(response.status_code, 200)
def test_excessive_purchase_attempts_from_same_user_are_blocked(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
for i in range(3):
response = self._purchase(f'key-block-{i}')
self.assertEqual(response.status_code, 200)
blocked_response = self._purchase('key-block-extra')
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwarePurchaseThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# PurchaseGuestThrottleBypassTests پایین‌تر) — این تست فقط رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که
# باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501')
registered_token = issue_access_token(registered_user.id)
def _purchase_as(idempotency_key, remote_addr):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
for i in range(3):
response = _purchase_as(f'key-ip-{i}', f'10.1.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _purchase_as('key-ip-extra', '10.1.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_order_and_does_not_mutate_subscription(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
first_response = self._purchase('key-mutate-1')
self.assertEqual(first_response.status_code, 200)
subscription = Subscription.objects.get(user_id=self.user.id)
snapshot = {
'status': subscription.status,
'plan_id': subscription.plan_id,
'order_id': subscription.order_id,
'version': subscription.version,
}
order_count_before = Order.objects.filter(user_id=self.user.id).count()
with patch('subscription.views.create_order') as mock_create_order:
blocked_response = self._purchase('key-mutate-2')
self.assertEqual(blocked_response.status_code, 429)
mock_create_order.assert_not_called()
self.assertEqual(Order.objects.filter(user_id=self.user.id).count(), order_count_before)
subscription.refresh_from_db()
self.assertEqual(subscription.status, snapshot['status'])
self.assertEqual(subscription.plan_id, snapshot['plan_id'])
self.assertEqual(subscription.order_id, snapshot['order_id'])
self.assertEqual(subscription.version, snapshot['version'])
class PurchaseGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='purchase' برای هر
کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه برای سقفِ ۱۰/ساعتِ purchase بگیره. این endpoint حساسِ مالیه: هر
درخواستِ پذیرفته‌شده Subscription می‌سازه/آپدیت می‌کنه و create_order()
رو صدا می‌زنه. حالا GuestAwarePurchaseThrottle برای کاربرهای مهمان
کلید رو بر اساسِ IP (همون get_ident که guest_session هم استفاده
می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ
throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده.
"""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self, idempotency_key, token):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
# سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)،
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '2/hour'}):
guest1 = User.objects.create(device_id='device-purchase-guest-rot-1', is_guest=True, name='')
response1 = self._purchase('key-guest-rot-1', issue_access_token(guest1.id))
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-purchase-guest-rot-2', is_guest=True, name='')
response2 = self._purchase('key-guest-rot-2', issue_access_token(guest2.id))
self.assertEqual(response2.status_code, 200)
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
# bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی
# سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های
# مهمانِ ساخته‌شده.
guest3 = User.objects.create(device_id='device-purchase-guest-rot-3', is_guest=True, name='')
response3 = self._purchase('key-guest-rot-3', issue_access_token(guest3.id))
self.assertEqual(response3.status_code, 429)
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
# (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP
# مشترک متصل است.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
guest1 = User.objects.create(device_id='device-purchase-guest-rot-4', is_guest=True, name='')
response1 = self._purchase('key-guest-rot-4', issue_access_token(guest1.id))
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-purchase-guest-rot-5', is_guest=True, name='')
blocked = self._purchase('key-guest-rot-5', issue_access_token(guest2.id))
self.assertEqual(blocked.status_code, 429)
guest3 = User.objects.create(device_id='device-purchase-guest-rot-6', is_guest=True, name='')
still_blocked = self._purchase('key-guest-rot-6', issue_access_token(guest3.id))
self.assertEqual(still_blocked.status_code, 429)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
user_a = User.objects.create(is_guest=False, name='', phone='+989120000601')
response_a = self._purchase('key-reg-a', issue_access_token(user_a.id))
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(is_guest=False, name='', phone='+989120000602')
response_b = self._purchase('key-reg-b', issue_access_token(user_b.id))
self.assertEqual(response_b.status_code, 200)
def test_blocked_guest_request_does_not_mutate_subscription_or_call_purchase_logic(self):
# درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید Subscription/Order
# بسازه یا create_order() رو صدا بزنه — throttle باید *قبل* از
# رسیدن به منطقِ purchase رد کنه، حتی با Idempotency-Key معتبر.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
guest1 = User.objects.create(device_id='device-purchase-guest-rot-7', is_guest=True, name='')
first_response = self._purchase('key-guest-rot-7', issue_access_token(guest1.id))
self.assertEqual(first_response.status_code, 200)
# حسابِ مهمانِ تازه‌ی دوم باید بلاک بشه (bucket مشترکِ IP از
# قبل مصرف شده) و نباید Subscription/Order برای آن بسازد.
guest2 = User.objects.create(device_id='device-purchase-guest-rot-8', is_guest=True, name='')
with patch('subscription.views.create_order') as mock_create_order:
blocked_response = self._purchase('key-guest-rot-8', issue_access_token(guest2.id))
self.assertEqual(blocked_response.status_code, 429)
mock_create_order.assert_not_called()
self.assertFalse(Subscription.objects.filter(user_id=guest2.id).exists())
self.assertEqual(Order.objects.filter(user_id=guest2.id).count(), 0)

View File

@@ -5,6 +5,7 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from payment.models import Order
@@ -19,6 +20,41 @@ from .serializers import (
)
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle
(discount/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای
درخواستِ احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle
استفاده می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ
مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ
حساب‌های مهمان (تا سقفِ guest_session=20/hour)، برای هر حساب یک
bucket تازه از throttle_scope 'purchase' بگیره — یعنی سقفِ ۱۰/ساعتِ
درخواستِ purchase عملاً با چرخشِ حساب مهمان چند برابر می‌شد. این
endpoint حساسِ مالیه: هر درخواستِ پذیرفته‌شده وارد جریانِ purchase
می‌شه، یک Subscription می‌سازه/آپدیت می‌کنه و create_order() رو صدا
می‌زنه (که در پروداکشن با درگاهِ واقعی جایگزینِ stub فعلی می‌شه).
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
purchase/subscription/order/Idempotency-Key/plan-validation دست‌نخورده‌ست.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
class PlansListView(APIView):
"""GET /api/subscription/plans — list available plans."""
@@ -49,6 +85,22 @@ class PurchaseView(APIView):
to create an Order."""
permission_classes = [IsAuthenticated]
# scope کیدهای cache رو با pk کاربرِ احرازهویت‌شده می‌سازه (نه فقط IP)؛
# چون Idempotency-Key به‌تنهایی جلوی حجمِ بالای درخواست‌های *متفاوت*
# (با کلیدهای idempotency متفاوت) رو نمی‌گیره، این throttle مستقل و
# مکملِ اون محافظته. ScopedRateThrottle خودش وقتی request.user
# احرازهویت‌شده باشه (is_authenticated=True) به‌جای IP از
# request.user.pk به‌عنوانِ ident استفاده می‌کنه، پس تغییرِ IP از
# همون اکانت این محدودیت رو دور نمی‌زنه.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwarePurchaseThrottle استفاده می‌شه که برای کاربرهای مهمان
# کلید رو بر اساسِ IP می‌سازه (دقیقاً همون الگویِ GuestAwareAIThrottle،
# GuestAwareFamilyThrottle و GuestAwareDiscountThrottle) و برای
# کاربرهای غیرمهمان رفتارِ per-user قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'purchase'
throttle_classes = [GuestAwarePurchaseThrottle]
@extend_schema(
request=PurchaseRequestSerializer,