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

@@ -6,6 +6,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import ValidationError
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 .models import DiscountCode, Redemption
@@ -19,6 +20,41 @@ from .serializers import (
from .services import redeem_offer_code
class GuestAwareDiscountThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و
GuestAwareFamilyThrottle (family/views.py) اینجا هم هست.
ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده از
request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان (تا سقفِ
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
'discount_redeem' بگیره. ریسکِ این endpoint از بقیه هم بیشتره، چون
DiscountCode یک رکوردِ سراسری با max_uses مشترکِ بینِ همه‌ی کاربرهاست؛
یک حدسِ درست صرفاً اطلاعات لو نمی‌ده، بلکه یک Redemption واقعی
می‌سازه و مبلغِ سفارش رو کم می‌کنه — یعنی throttle دورزده‌شده به
مهاجم guessهای واقعاً بیشتری روی یک کدِ سراسری می‌ده.
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
lookup/redemption/order دست‌نخورده‌ست.
"""
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}
def _is_usable(discount: DiscountCode) -> bool:
return bool(
discount.redemptions.count() < discount.max_uses
@@ -33,6 +69,24 @@ class ValidateDiscountView(APIView):
# صورت کلاینت بدون احراز هویت می‌تواند کدهای تخفیف را probe کند.
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint خودِ type/value کد رو هم برمی‌گردونه (نه
# فقط valid/invalid)، پس بدونِ throttle یک کاربرِ احرازهویت‌شده می‌تونست
# بی‌نهایت کدهای تخفیف رو probe کنه و جزئیاتشون رو یاد بگیره. طبقِ
# همون الگویِ ScopedRateThrottle که برای RedeemOfferCodeView
# (offer_redeem) اضافه شد: چون کاربر احرازهویت‌شده، کلید بر اساسِ pk
# کاربره (نه IP). نرخ کمی بالاتر از offer_redeem انتخاب شده چون این
# یک چکِ read-only پیش از خریده (ممکنه کاربر چند کد رو قبل از تصمیمِ
# نهایی امتحان کنه)، نه خودِ مصرف کد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از همون
# GuestAwareDiscountThrottleِ مشترک (که RedeemDiscountView/
# RedeemOfferCodeView هم استفاده می‌کنن) استفاده می‌شه که برای
# کاربرهای مهمان کلید رو بر اساسِ IP می‌سازه و برای کاربرهای
# غیرمهمان رفتارِ per-user قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'discount_validate'
throttle_classes = [GuestAwareDiscountThrottle]
@extend_schema(
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
responses=ValidateResponseSerializer,
@@ -63,6 +117,26 @@ class RedeemDiscountView(APIView):
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint هم دقیقاً همون oracle ای که باعث شد
# ValidateDiscountView و RedeemOfferCodeView throttle بگیرن رو داره —
# کدِ ناموجود ۴۰۴ می‌گیره، کدِ موجود-ولی-نامعتبر ۴۰۰ می‌گیره، پس بدونِ
# throttle یک کاربرِ احرازهویت‌شده می‌تونست کدهای تخفیف رو روی سفارشِ
# خودش probe/brute-force کنه. اینجا ریسک از اون دوتا هم بیشتره، چون
# حدسِ درست صرفاً اطلاعات لو نمی‌ده؛ یک کدِ واقعی رو مصرف می‌کنه
# (سهمِ max_uses مشترک بین همه‌ی کاربرها رو می‌خوره) و باعثِ افتِ
# واقعیِ مبلغِ سفارش می‌شه؛ ضمناً هر تلاش دو تا select_for_update و یک
# تراکنشِ دیتابیسی اجرا می‌کنه که گرون‌تر از GET سبکِ validate است.
# طبقِ همون الگو، ScopedRateThrottle با کلیدِ per-user (نه IP) اضافه شد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareDiscountThrottle استفاده می‌شه که برای کاربرهای مهمان
# کلید رو بر اساسِ IP می‌سازه (دقیقاً همون الگویِ GuestAwareAIThrottle
# و GuestAwareFamilyThrottle) و برای کاربرهای غیرمهمان رفتارِ per-user
# قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'discount_redeem'
throttle_classes = [GuestAwareDiscountThrottle]
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
tags=['Discount (discount)'])
def post(self, request):
@@ -126,6 +200,27 @@ class RedeemOfferCodeView(APIView):
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: کدهای نامعتبر فقط یک ValidationError ساده (۴۰۰) برمی‌گردونن
# و هیچ throttleای اینجا نبود؛ یعنی یک کاربرِ احرازهویت‌شده می‌تونست
# بدونِ هیچ محدودیتی کدهای آفر رو حدس بزنه (offer-code probing). طبقِ
# همون الگویِ ScopedRateThrottle که در بقیه‌ی اکشن‌هایِ کم‌تکرار/حساسِ
# پروژه استفاده شده (مثلِ purchase در subscription، support_create در
# support)، اینجا هم یک throttle_scope مجزا اضافه شد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود — به‌خصوص اینجا
# حساس‌تره چون Offer.code سراسری/یکتاست و redeem_offer_code() فقط
# جلوی تکرارِ *همون* کاربر رو می‌گیره، نه کاربرهای مهمانِ جدید (یک
# حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو دوباره از
# apply_offer() بگیره). حالا از همون GuestAwareDiscountThrottle که
# برای RedeemDiscountView اضافه شد استفاده می‌شه (دقیقاً همون الگویِ
# GuestAwareAIThrottle، GuestAwareFamilyThrottle و
# GuestAwarePurchaseThrottle) که برای کاربرهای مهمان کلید رو بر
# اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ per-user قبلی رو
# دست‌نخورده نگه می‌داره.
throttle_scope = 'offer_redeem'
throttle_classes = [GuestAwareDiscountThrottle]
@extend_schema(
request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer,
tags=['Discount (discount)'],