diff --git a/accounts/admin.py b/accounts/admin.py index eb23ec3..4451f38 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -56,47 +56,19 @@ class UserAdmin(admin.ModelAdmin): list_filter = ('is_guest',) actions = ['send_sms_to_selected'] - # permissions=['change']: بدونِ این، جنگو این اکشن رو بر اساسِ - # has_change_permission فیلتر نمی‌کنه (فقط اکشن‌هایی که با پارامترِ - # permissions تعریف شده باشن فیلتر می‌شن)، یعنی کاربری که فقط - # view_user داره (مثلِ گروهِ Supervisor) می‌تونه با یک POSTِ مستقیم - # همین اکشن رو صدا بزنه و واقعاً پیامک بفرسته، حتی بدونِ اینکه دکمه‌ی - # اکشن رو در UI ببینه. با permissions=['change']، ModelAdmin.response_action - # خودش (get_actions -> _filter_actions_by_permissions) این اکشن رو - # فقط برای کاربرانِ دارایِ change_user در دسترس می‌ذاره؛ برای بقیه - # (مثلِ Supervisor) اصلاً در actions ثبت‌شده نیست، پس درخواستِ مستقیم - # هم بی‌اثر می‌مونه (پیامِ هشدارِ استانداردِ «اکشنی انتخاب نشده»). + # permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor) + # می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛ + # این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش. @admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change']) def send_sms_to_selected(self, request, queryset): - """ - قدمِ اولیه‌ی مینیمالِ فیچرِ «ارسالِ پیامک از ادمین»: یک اکشنِ ادمینِ - معمولی روی UserAdmin که یک صفحه‌ی میانیِ ساده (فرمِ متنِ پیامک) رو - نشون می‌ده و بعد از تأیید، دقیقاً به همون کاربرهایی که در changelist - انتخاب شدن، همون متن رو با accounts.services.send_sms موجود ارسال - می‌کنه. + """اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخاب‌شده یا غیرفعال؛ + گیرنده با فیلدِ «target» انتخاب می‌شه (cutoffِ «غیرفعال» رو خودِ ادمین + در فرم وارد می‌کنه، چیزی hardcode نیست). - گیرنده‌ها با فیلدِ «target» در فرم انتخاب می‌شن: - - 'selected' (پیش‌فرض): همون queryset که ادمین در changelist - انتخاب کرده — رفتارِ قبلی، بدون تغییر. - - 'inactive': کاربرانِ غیرفعال، با استفاده از - accounts.services.get_inactive_users(since) — cutoff (تاریخ) - رو خودِ ادمین در فرم وارد می‌کنه، هیچ عددِ روزی hardcode نشده. - - عمداً محدود نگه داشته شده: - - هیچ مدلِ کمپین/بدنه‌ی جدیدی ساخته/persist نمی‌شه (نه رکوردی از - پیام، نه رکوردی از تحویل)؛ ارسال کاملاً بلادرنگه. - - هیچ زمان‌بندی، صفِ پس‌زمینه، یا فیلترِ هدف‌گیریِ دیگه‌ای (غیر از - selected/inactive) نیست. - - محافظتِ سقف: چون ارسال هنوز کاملاً synchronous و توی همون - request انجام می‌شه (نه صفِ پس‌زمینه)، اگر تعدادِ گیرنده‌ها از - settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشه، اکشن هیچ پیامکی - ارسال نمی‌کنه و فقط خطا نشون می‌ده — تا یک انتخاب/تاریخِ خیلی - بزرگ باعثِ تایم‌اوتِ request یا overload شدنِ پروایدر نشه. - - هیچ منطقِ provider تکرار نشده؛ send_sms عیناً همون تابعِ موجودیه - که OTP و campaigns هم ازش استفاده می‌کنن. - - کاربرانِ بدونِ شماره‌تلفن (مثلِ مهمان) به‌سادگی skip می‌شن. - - شکستِ ارسال برای یک کاربر (SMSDeliveryError) کلِ اکشن رو متوقف - نمی‌کنه؛ فقط شمارشِ failed رو زیاد می‌کنه و برای بقیه ادامه می‌ده. + ارسال کاملاً synchronous و توی همون requestه (نه صفِ پس‌زمینه)، پس + اگر گیرنده‌ها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن + هیچ پیامکی نمی‌فرسته تا request تایم‌اوت نکنه یا provider overload نشه. + شکستِ ارسال برای یک کاربر بقیه را متوقف نمی‌کنه. """ if 'apply' in request.POST: form = SendSMSForm(request.POST) @@ -123,10 +95,8 @@ class UserAdmin(admin.ModelAdmin): ), level=messages.ERROR, ) - # به‌جای return None، به رندرِ مشترکِ پایینِ تابع سقوط - # می‌کنیم تا فرم دوباره (بدونِ ارسالِ هیچ پیامکی) نشون - # داده بشه — دقیقاً همون الگویی که برای فرمِ نامعتبر - # استفاده می‌شه. + # سقوط به رندرِ مشترکِ پایین (نه return None) چون None از + # یک اکشنِ ادمین باعثِ ریدایرکتِ ۳۰۲ می‌شه، نه رندرِ مستقیم. else: sent_count = 0 failed_count = 0 @@ -158,12 +128,8 @@ class UserAdmin(admin.ModelAdmin): f'{skipped_no_phone} کاربر بدونِ شماره‌تلفن رد شدن (پیامک ارسال نشد).', level=messages.WARNING, ) - # توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در - # جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک - # ریدایرکتِ ۳۰۲ واقعی می‌شه (نه رندرِ مستقیمِ ۲۰۰)، - # درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود. - # حالا هم‌الگو با شاخه‌ی cap-exceeded، سقوط می‌کنیم به - # رندرِ مشترکِ پایینِ تابع. + # سقوط به رندرِ مشترکِ پایین (نه return None)، هم‌الگو با + # شاخه‌ی cap-exceeded. else: form = SendSMSForm() @@ -195,38 +161,15 @@ class OTPCodeAdmin(admin.ModelAdmin): list_filter = ('is_used',) -# ───────────────────────────────────────────────────────────────────────── -# صفحه‌ی «ناظر» (Supervisor) +# صفحه‌ی «ناظر» (Supervisor): یک ایندکسِ read-only از لینک‌ها به +# changelistهای موجود (کاربران/پرداخت‌ها/اشتراک‌ها)، داخلِ همون AdminSite +# پیش‌فرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید. # -# یک صفحه‌ی خیلی کوچیک و *read-only* داخلِ همون AdminSiteِ پیش‌فرضِ جنگو -# (django.contrib.admin.site) — نه یک AdminSite جدا، و نه CRUDِ موازی. -# این صفحه صرفاً یک ایندکسِ گروهی از لینک‌هایی به همون changelistهای -# ModelAdmin موجودِ پروژه‌ست: کاربران (accounts.UserAdmin)، پرداخت‌ها -# (payment.OrderAdmin / payment.TransactionAdmin) و اشتراک‌ها -# (subscription.SubscriptionAdmin). هیچ منطقِ کسب‌وکار یا ModelAdmin -# جدیدی اینجا ساخته نمی‌شه. -# -# محدودیتِ واقعیِ read-only از طریقِ سیستمِ استانداردِ Group/Permission -# جنگو تأمین می‌شه، نه با پنهون‌کردنِ لینک: -# - هر لینک در این صفحه فقط وقتی نشون داده می‌شه که کاربر پرمیشنِ -# view_ مربوطه رو داشته باشه (request.user.has_perm(...)). -# - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ -# changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ -# override‌ای نشدن) طبقِ has_view_permission/has_change_permission/ -# has_add_permission/has_delete_permission پیش‌فرضِ ModelAdmin — که -# مستقیماً از Permission مدلِ کاربر می‌خونن — چک می‌کنن؛ یعنی گروهِ -# Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ -# view_* می‌گیره) حتی با دسترسیِ مستقیم به URL هم نمی‌تونه add/change/ -# delete کنه. -# - خودِ صفحه هم مثلِ هر صفحه‌ی دیگه‌ی Admin با admin.site.admin_view -# wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛ -# ولی is_staff به‌تنهایی برای *ورود به خودِ صفحه* کافی نیست — پایین‌تر -# قبل از هر کارِ دیگه‌ای عضویتِ کاربر در گروهِ Supervisor هم چک می‌شه -# (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه -# PermissionDenied (۴۰۳) بالا می‌ره. این یک سیستمِ پرمیشنِ جدید نیست؛ -# فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group -# می‌سازه) برای گیت‌کیپینگِ ورود به صفحه استفاده می‌کنه. -# ───────────────────────────────────────────────────────────────────────── +# read-only بودن با پنهون‌کردنِ لینک تضمین نمی‌شه، بلکه با پرمیشنِ استانداردِ +# جنگو: هر لینک فقط با view_ نشون داده می‌شه و حتی دسترسیِ مستقیم به +# URLِ change/add/delete هم توسطِ پرمیشنِ پیش‌فرضِ همون ModelAdminها رد می‌شه +# (گروهِ Supervisor فقط view_* داره). is_staff به‌تنهایی برای ورود کافی +# نیست؛ عضویت در گروهِ Supervisor هم چک می‌شه، وگرنه PermissionDenied. SUPERVISOR_GROUP_NAME = 'Supervisor' diff --git a/accounts/services.py b/accounts/services.py index 5de5651..9c91e1b 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -104,12 +104,9 @@ def send_sms(*, phone: str, message: str) -> None: ) raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') - # ⚠️ کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار - # ناکافی، گیرنده‌ی نامعتبر و…) HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی - # بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ - # status_code == 200 «موفقیت» در نظر گرفته نمی‌شه؛ بدنه parse می‌شه و - # فقط return.status == 200 موفقیت محسوب می‌شه؛ هر پاسخِ ناقص/غیرمنتظره - # هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت). + # کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…) + # HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی body.return.status هست. فقط + # اون فیلد موفقیت رو تعیین می‌کنه، نه status_code. try: body = response.json() provider_status = body['return']['status'] @@ -158,23 +155,10 @@ def send_otp_sms(*, phone: str, code: str) -> None: raise OTPDeliveryError(str(exc)) from exc -# ───────────────────────────────────────────────────────────────────────── -# Targeting helpers (internal, read-only) -# -# هدف: صرفاً کوئری‌های قابل‌استفاده‌ی مجدد برای انتخاب زیرمجموعه‌ای از -# کاربران، تا فیچرهای آینده (مثلاً کمپین‌های پیامی) بدونِ تکرارِ منطقِ -# فیلتر، از همین توابع استفاده کنن. این ماژول: -# - هیچ پیامی نمی‌فرسته (نه SMS، نه notification). -# - هیچ جدول/مایگریشنِ جدیدی اضافه نمی‌کنه. -# - هیچ background job/endpoint‌ای نداره؛ فقط QuerySetِ User برمی‌گردونه. -# - هیچ رفتارِ موجودی رو تغییر نمی‌ده (فقط SELECT). -# -# ارجاع به subscription.Subscription عمداً با import محلی (داخلِ تابع) و -# بر اساسِ user_id انجام می‌شه — دقیقاً همون الگویی که payment/services.py -# و discount/services.py برای صداکردنِ subscription استفاده می‌کنن؛ چون -# accounts.User (طبقِ docstringِ خودش) هیچ‌وقت FK واقعی به مدل‌های اپ‌های -# دیگه نمی‌زنه. -# ───────────────────────────────────────────────────────────────────────── +# Targeting helpers (internal, read-only): کوئری‌های قابل‌استفاده‌ی مجدد برای +# انتخابِ زیرمجموعه‌ای از کاربران (مثلاً برای کمپین‌های پیامی)؛ فقط SELECT، +# بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و +# user_id انجام می‌شه چون accounts.User هیچ‌وقت FK واقعی به اپ‌های دیگه نمی‌زنه. def get_all_users(): """همه‌ی کاربران (مهمان و ثبت‌نام‌شده).""" diff --git a/accounts/tests.py b/accounts/tests.py index 603b0ac..19aa2be 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -837,27 +837,13 @@ class GuestSessionThrottleTests(TestCase): } ) def test_repeated_guest_session_requests_are_eventually_throttled(self): - # ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست دو مشکلِ جدا - # داشت: - # ۱) `type` رو نمی‌فرستاد (پایین‌تر فیکس شده) — قراردادِ واقعیِ - # API طبقِ README نیازش داره، پس هر درخواست قبل از throttle - # با 400 رد می‌شد. - # ۲) override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ - # ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ - # THROTTLE_RATES رو یک‌بار موقعِ import ماژولِ throttling - # به‌عنوانِ یک class attribute از api_settings. - # DEFAULT_THROTTLE_RATES می‌گیره (همون دیکشنریِ اصلیِ - # settings.py، با reference). override_settings یک دیکشنریِ - # *جدید* برای settings.REST_FRAMEWORK می‌سازه، اما این - # class attribute هنوز به همون دیکشنریِ قدیمی اشاره می‌کنه — - # سیگنالِ setting_changed فقط api_settings رو reload می‌کنه، - # نه این class attribute رو. نتیجه: نرخِ واقعیِ اعمال‌شده - # همیشه همون دیفالتِ 20/hour در settings.py می‌موند، نه - # 3/hour، پس ۵ درخواست هیچ‌وقت به سقف نمی‌رسیدن. برای اینکه - # این تست واقعاً نرخِ محدودشده رو بسنجه، مستقیماً همون - # دیکشنریِ زنده‌ی ScopedRateThrottle.THROTTLE_RATES رو - # patch.dict می‌کنیم (که override_settings بالا هم برای - # سازگاری/مستندسازیِ نیت نگه داشته می‌شه). + # override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ + # ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ THROTTLE_RATES رو + # یک‌بار موقعِ import، به‌عنوانِ class attribute، از دیکشنریِ اصلیِ + # settings.py می‌گیره؛ override_settings دیکشنریِ settings رو عوض + # می‌کنه ولی این class attribute هنوز به نسخه‌ی قدیمی اشاره می‌کنه. + # برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ + # زنده‌ی ScopedRateThrottle.THROTTLE_RATES لازمه. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}): for i in range(3): response = self.client.post( diff --git a/accounts/views.py b/accounts/views.py index 95da3f4..a2daf81 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -116,24 +116,10 @@ class RequestOTPView(APIView): serializer.is_valid(raise_exception=True) phone = serializer.validated_data['phone'] - # حداقل فاصله‌ی زمانی بین دو درخواست برای همون شماره (burst - # protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو می‌گیره، اما به‌تنهایی - # جلویِ این رو نمی‌گیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر - # از یک ثانیه مصرف بشن (که هم به‌معنایِ flood پیامکی روی یک شماره‌ی - # مشخصه، هم می‌تونه با invalidate کردنِ پی‌درپیِ OTPهای قبلی - # (رفتارِ «فقط آخرین کد معتبره» که پایین‌تره) عملاً یک DoS روی - # کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارنده‌ی - # ساعتی انجام می‌شه تا یک درخواستِ بلاک‌شده توسطِ cooldown سهمیه‌ی - # ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده - # (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView). - # - # کلید cooldown به‌جای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره - # می‌کنه؛ این‌طوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ - # هیچ خواندنِ ناامنی می‌شه دقیقاً محاسبه کرد چند ثانیه تا پایانِ - # cooldown مونده و اون رو هم در بدنه‌ی پاسخ (retryAfter) و هم در - # هدرِ استانداردِ Retry-After برگردوند — این‌طوری frontend - # countdown رو دقیقاً از رویِ همین پاسخِ backend شروع می‌کنه، نه - # از رویِ یک عددِ hardcode‌شده‌ی سمتِ کلاینت که مرجعِ امنیتی نیست. + # حداقل فاصله‌ی زمانی بین دو درخواستِ همون شماره (burst protection)؛ + # قبل از شمارنده‌ی ساعتی چک می‌شه تا یک درخواستِ بلاک‌شده سهمیه‌ی + # ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره می‌کنه تا + # retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه. phone_cooldown_key = f'otp_request_phone_cooldown:{phone}' cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS now_epoch = timezone.now().timestamp() @@ -161,13 +147,9 @@ class RequestOTPView(APIView): headers={'Retry-After': str(retry_after)}, ) - # محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ - # otp_request بالا. throttle IP-محور به‌تنهایی کافی نیست چون یک - # مهاجم می‌تونه درخواست‌های تکراری برای یک شماره رو بین چند IP - # مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr - # همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید - # وجود نداره مقدار می‌ذاره (اتمیک روی سطح backend cache)، پس دو - # درخواستِ هم‌زمان نمی‌تونن هر دو از یک non-atomic check رد بشن. + # محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا + # (وگرنه یه مهاجم می‌تونه بینِ چند IP پخش کنه). cache.add فقط وقتی + # کلید نیست مقدار می‌ذاره، پس اتمیکه. phone_requests_key = f'otp_request_phone_attempts:{phone}' if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS): attempt_count = 1 @@ -202,13 +184,9 @@ class RequestOTPView(APIView): try: send_otp_sms(phone=phone, code=code) except OTPDeliveryError: - # ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست - # خورده)، پس cooldownِ ۱۲۰ثانیه‌ای که بالاتر برای همین درخواست - # ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ - # این‌صورت کاربر ۲ دقیقه معطل می‌مونه بدونِ اینکه اصلاً کدی - # گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle - # IP-محور دست‌نخورده می‌مونن — این‌ها محدودیتِ حجمِ کلی‌ان، نه - # cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن. + # ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک می‌کنیم + # تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/ + # throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم، نه این cooldown). cache.delete(phone_cooldown_key) raise diff --git a/affiliate/views.py b/affiliate/views.py index 9ce0415..eb7ee47 100644 --- a/affiliate/views.py +++ b/affiliate/views.py @@ -63,24 +63,16 @@ class TrackConversionView(APIView): خودِ کاربرِ تازه‌ارجاع‌شده (بعد از لاگین/ثبت‌نام) زده بشه، هویتش رو از توکن احرازشده (request.user) می‌گیریم، نه از بدنه‌ی درخواست. - # ⚠️ اصلاح امنیتی: قبلاً این endpoint با AllowAny و یک - # referredUserId دلخواه از سمت کلاینت کار می‌کرد؛ یعنی هر کلاینتِ - # ناشناس می‌تونست برای هر کاربرِ واقعیِ دیگه‌ای (بدون اطلاع یا - # مشارکت خودش) به‌جای او Conversion بسازه، یا حتی برای فرار از - # قید unique روی referred_user_id، از قبل تمام userId های موجود - # رو برای یک کد افیلیتِ جعلی «اشغال» کنه و مسیر ارجاع واقعی رو - # برای همیشه مسدود کنه. با گرفتن هویت از request.user (نیازمند - # احراز هویت)، دیگه امکان جعل/انتساب ارجاع به کاربر دیگری وجود - # نداره؛ چک self-referral هم حفظ شده. + # هویت از request.user گرفته می‌شه (نه بدنه‌ی درخواست) چون یک + # referredUserIdِ کلاینت‌دار یعنی هر کلاینتِ ناشناس می‌تونست برای هر + # کاربرِ دیگه‌ای Conversion بسازه یا با اشغالِ همه‌ی userId ها (قیدِ + # unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه. """ permission_classes = [IsAuthenticated] - # ⚠️ اضافه شده: قبلاً این endpoint هیچ throttleای نداشت؛ referralCode - # نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe) و حدسِ درست یک - # Conversion واقعیِ متصل به کمیسیونِ affiliate می‌سازه. طبقِ همون - # الگویِ بقیه‌ی اپ‌ها، throttle_scope مجزا با GuestAwareAffiliateThrottle - # اضافه شد تا هم probe کردنِ کد رو کند کنه، هم چرخشِ حساب مهمان - # دیگه bucket تازه نده. + # referralCode نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe)، پس throttle + # مجزا هم probe رو کند می‌کنه هم (با GuestAwareAffiliateThrottle) جلویِ + # bucket تازه از چرخشِ حساب مهمان رو می‌گیره. throttle_scope = 'affiliate_track' throttle_classes = [GuestAwareAffiliateThrottle] diff --git a/ai_tutor/models.py b/ai_tutor/models.py index 31c4bde..e38d6fc 100644 --- a/ai_tutor/models.py +++ b/ai_tutor/models.py @@ -54,15 +54,10 @@ class AskIdempotency(models.Model): conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+') user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+') assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+') - # ⚠️ اصلاح: قبلاً suggestedFollowUps هیچ‌جا ذخیره نمی‌شد و در مسیرِ - # replay (idempotent retry) دوباره با فراخوانیِ generate_tutor_answer - # بازسازی می‌شد. این فقط تا وقتی generate_tutor_answer یک stub خالی از - # اثر جانبیه بی‌ضرره؛ وقتی به یک AI provider واقعی وصل بشه، هر replay - # یک درخواستِ واقعیِ دیگه به provider می‌زنه (هزینه/تأخیرِ تکراری، و - # پیشنهادهای follow-up متفاوت در هر retry — دقیقاً برخلافِ هدفِ - # idempotency). حالا خروجیِ follow-up های همون درخواستِ اولِ موفق، - # همینجا ذخیره می‌شه تا replay بتونه بدونِ هیچ فراخوانیِ دیگری به - # generate_tutor_answer، همون مقدارِ اصلی رو برگردونه. + # ذخیره می‌شه تا replayِ idempotent بتونه بدونِ فراخوانیِ دوباره‌ی + # generate_tutor_answer همون مقدارِ اصلی رو برگردونه — وقتی provider + # واقعی جایگزینِ stub بشه، هر replay یعنی هزینه/تأخیرِ تکراری و + # follow-upهای متفاوت در هر retry، برخلافِ هدفِ idempotency. suggested_follow_ups = models.JSONField(default=list) created_at = models.DateTimeField(auto_now_add=True) diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 4d54909..ab35456 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -139,11 +139,8 @@ class AskAIView(APIView): """POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)""" permission_classes = [IsAuthenticated] - # ⚠️ افزوده شده: قبلاً این endpoint هیچ throttle مخصوصی نداشت؛ DAILY_AI_QUOTA - # فقط سقفِ روزانه است و جلوی burst کوتاه‌مدت (مثلاً چند ده درخواست در چند - # ثانیه به generate_tutor_answer) را نمی‌گیرد. throttle_scope مجزا - # (ai_ask) این burst را محدود می‌کند، بدون اینکه به کوتای روزانه/idempotency - # یا منطق AI دست بزند. + # DAILY_AI_QUOTA فقط سقفِ روزانه‌ست و جلوی burstِ کوتاه‌مدت رو نمی‌گیره؛ + # این throttle_scope مجزا اون burst رو محدود می‌کنه. throttle_scope = 'ai_ask' throttle_classes = [GuestAwareAIThrottle] diff --git a/campaigns/admin.py b/campaigns/admin.py index 6ba7c30..ac56ac7 100644 --- a/campaigns/admin.py +++ b/campaigns/admin.py @@ -47,15 +47,10 @@ class CampaignDeliveryAdmin(admin.ModelAdmin): readonly_fields = fields def has_view_permission(self, request, obj=None): - # صریحاً True: نباید به fallbackِ مبتنی‌بر codename پرمیشنِ - # پیش‌فرضِ Django (has_perm('view_...') or has_perm('change_...')) - # تکیه کنیم. بدونِ این override، یک کاربرِ staff که پرمیشنِ - # view/change روی این مدل رو نداره، حتی برای دیدنِ صفحه‌ی - # جزئیاتِ read-only هم 403 می‌گیره — یعنی مدل عملاً "view-only" - # نیست بلکه کاملاً غیرقابلِ‌دسترسه. این متد مستقل از - # has_change_permission (که پایین همیشه False برمی‌گردونه) - # تضمین می‌کنه که دیدن همیشه مجازه، صرفِ‌نظر از پرمیشن‌های - # دیتابیسی. + # صریحاً True: بدونِ override، fallbackِ پیش‌فرضِ Django به کاربرِ + # staffِ بدونِ پرمیشنِ view/change حتی اجازه‌ی دیدنِ این صفحه‌ی + # read-only رو هم نمی‌ده — یعنی مدل عملاً غیرقابلِ‌دسترس می‌شه، نه + # فقط view-only. return True def has_add_permission(self, request): diff --git a/campaigns/models.py b/campaigns/models.py index 1a45441..946a47a 100644 --- a/campaigns/models.py +++ b/campaigns/models.py @@ -96,24 +96,12 @@ class CampaignDelivery(models.Model): ordering = ['-created_at'] constraints = [ # جلوگیریِ race condition: اگه execute_campaign() برای همون - # campaign هم‌زمان (دو پراسس/ریکوئستِ موازی) اجرا بشه، هر دو - # می‌تونن قبل از اینکه هرکدوم رکوردی بسازن از چکِ - # already_sent رد بشن (TOCTOU) و برای یک کاربر دو بار - # send_sms/notify صدا زده بشه. این unique constraint دقیقاً - # همون چیزی رو در سطحِ دیتابیس تضمین می‌کنه که چکِ اپلیکیشن‌ - # لایه فرض می‌کرد ولی گارانتی نمی‌داد: برای هر - # (campaign, user_id, channel) در آنِ واحد حداکثر یک رکوردِ - # «فعال یا موفق» (pending یا sent) می‌تونه وجود داشته باشه. - # وقتی دو تلاشِ هم‌زمان می‌خوان برای همون کاربر رکورد pending - # بسازن، دومی روی این constraint با IntegrityError شکست - # می‌خوره و در execute_campaign() به‌عنوانِ «این کاربر توسطِ - # یک اجرای دیگه claim شده» skip می‌شه — پس send_sms/notify - # دوباره صدا زده نمی‌شه. - # - # status=failed عمداً از این constraint مستثنا شده تا رفتارِ - # retryِ فعلی (ساختنِ یک رکوردِ pending جدید بعد از یک تلاشِ - # ناموفق) دست‌نخورده بمونه: یک رکوردِ failed دیگه «فعال» حساب - # نمی‌شه، پس جای رکوردِ جدید رو برای تلاشِ بعدی باز می‌ذاره. + # campaign هم‌زمان اجرا بشه، این constraint تضمین می‌کنه حداکثر + # یک رکوردِ «فعال یا موفق» (pending/sent) به‌ازای هر + # (campaign, user_id, channel) باشه؛ تلاشِ دومِ هم‌زمان با + # IntegrityError شکست می‌خوره و به‌عنوانِ claim‌شده skip می‌شه. + # status=failed عمداً مستثناست تا retry (ساختِ pendingِ جدید + # بعد از شکست) دست‌نخورده بمونه. UniqueConstraint( fields=['campaign', 'user_id', 'channel'], condition=Q(status__in=['pending', 'sent']), diff --git a/campaigns/services.py b/campaigns/services.py index 9e8eed2..ac1cdb7 100644 --- a/campaigns/services.py +++ b/campaigns/services.py @@ -39,12 +39,10 @@ def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05): raise time.sleep(base_delay * (attempt + 1)) -# نوعِ نوتیفیکیشنِ ثبت‌شده برای اعلان‌های ناشی از کمپین. عمداً در -# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن -# choices فقط رویدادهایی‌ان که notifications به‌عنوانِ event-listener -# بهشون گوش می‌ده (طبق داکیومنتِ همون فایل)؛ این‌جا notify() مستقیماً و -# بدونِ رویداد صدا زده می‌شه، پس تغییرِ آن مدل لازم نیست — Notification.type -# یک CharField معمولیه و مقداردهی‌اش خارج از choices هم مجاز است. +# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن +# choices فقط رویدادهاییه که notifications به‌عنوانِ listener گوش می‌ده، +# اینجا notify() مستقیم صدا زده می‌شه؛ Notification.type یک CharField +# معمولیه، مقداردهیِ خارج از choices مجازه. CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign' diff --git a/config/settings.py b/config/settings.py index b28bd40..28ee39d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -227,21 +227,11 @@ REST_FRAMEWORK = { 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.ScopedRateThrottle', ], - # ⚠️ اضافه شده (امنیتی): بدونِ NUM_PROXIES، DRF (SimpleRateThrottle.get_ident) - # هدرِ X-Forwarded-For رو - اگه در درخواست وجود داشته باشه - عیناً و - # بدون‌چک به‌عنوانِ IP کلاینت قبول می‌کنه؛ یعنی هر کلاینتِ ناشناس می‌تونست - # با فرستادنِ یک X-Forwarded-For دلخواه/تصادفی در هر درخواست، برای - # هر GuestAware*Throttle (content/ai_tutor/subscription/payment/discount/ - # support/family/affiliate) یک IP-bucket تازه بگیره - دقیقاً همون - # bypassِ چرخشِ حساب مهمان که این throttle-classها قرار بود ببندن، فقط - # از طریقِ هدر به‌جای deviceId جدید. - # با NUM_PROXIES=1 (پیش‌فرض؛ برای دیپلویِ پشتِ یک لایه‌ی پراکسی/LB که - # خودش X-Forwarded-For رو ست/rewrite می‌کنه، نه پاس‌دادنِ مقدارِ - # کلاینت)، DRF از انتهای زنجیره‌ی XFF دقیقاً همون تعداد hop رو - # می‌خونه. اگه دیپلویِ پروداکشن تعداد لایه‌ی پراکسیِ دیگه‌ای داره - # (مثلاً Cloudflare + Nginx = ۲ لایه)، این مقدار باید با env - # NUM_PROXIES مطابقِ همون تعداد ست بشه، وگرنه یا IP واقعی اشتباه - # خونده می‌شه یا (اگه صفر باشه) دوباره همون مشکلِ بالا برمی‌گرده. + # بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدون‌چک به‌عنوانِ IP + # کلاینت قبول می‌کنه؛ یعنی هر کلاینت می‌تونست با XFF دلخواه، bucket + # تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این + # مقدار باید دقیقاً با تعدادِ لایه‌ی پراکسی/LB جلوی اپ یکی باشه (مثلاً + # Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده می‌شه. 'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')), 'DEFAULT_THROTTLE_RATES': { 'otp_request': '5/hour', @@ -259,9 +249,6 @@ REST_FRAMEWORK = { 'discount_redeem': '10/hour', 'family_invite_accept': '10/hour', 'family_invite_create': '5/hour', - # ⚠️ اضافه شده: چهار endpointی که در ممیزیِ throttleِ امروز بدونِ - # throttle_scope پیدا شدن (نگاه کنید به توضیحاتِ throttle_scope - # مربوطه در accounts/views.py، affiliate/views.py، payment/views.py). 'user_profile_write': '60/hour', 'affiliate_track': '10/hour', 'payment_webhook': '60/hour', @@ -292,13 +279,9 @@ SPECTACULAR_SETTINGS = { ACCESS_TOKEN_TTL_SECONDS = 3600 REFRESH_TOKEN_TTL_DAYS = 30 -# سقفِ تعدادِ گیرنده برای اکشنِ ادمینِ send_sms_to_selected (هم برای -# هدف‌گیریِ 'selected' و هم 'inactive'). این اکشن هنوز کاملاً بلادرنگ و -# synchronous توی همون request اجرا می‌شه (بدون صفِ پس‌زمینه)، پس این سقف -# صرفاً جلویِ یک ارسالِ ناخواسته‌ی خیلی بزرگ (که می‌تونه request رو تایم‌اوت -# کنه یا پروایدرِ پیامک رو overload کنه) رو می‌گیره؛ اگر تعدادِ گیرنده‌ها -# بیشتر از این باشه، اکشن هیچ پیامکی ارسال نمی‌کنه و از ادمین می‌خواد -# بازه/انتخاب رو محدودتر کنه. +# سقفِ گیرنده‌ی اکشنِ ادمینِ send_sms_to_selected؛ این اکشن synchronous توی +# همون requestه (بدون صفِ پس‌زمینه)، پس این سقف جلویِ تایم‌اوت/overloadِ +# provider رو می‌گیره — بیشتر از سقف یعنی هیچ پیامکی ارسال نمی‌شه. ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200)) # محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login. @@ -311,15 +294,9 @@ OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600 OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5 OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600 -# حداقل فاصله‌ی زمانی بین دو درخواستِ صدورِ OTP برای همون شماره. این مستقل -# از سقفِ ساعتیِ بالاست: سقفِ ساعتی جلویِ حجمِ کلی رو می‌گیره اما جلویِ -# burst رو نمی‌گیره (مثلاً هر ۵ تلاشِ مجاز در عرضِ کمتر از یک ثانیه). این -# cooldown دقیقاً همون burst رو هدف می‌گیره؛ نه جایگزینِ سقفِ ساعتیه، نه -# باهاش redundant — هر کدوم یک بُعدِ متفاوتِ سوءاستفاده رو می‌بندن. -# ⚠️ تصمیمِ محصول/امنیت: کاربر واقعاً باید هر ۲ دقیقه بتونه دوباره OTP -# بگیره؛ enforcement سمتِ سرور هم باید دقیقاً همین مقدار باشه — countdown -# سمتِ فرانت صرفاً UX هست و نباید مرجعِ امنیتی باشه، پس این مقدار همیشه -# باید با چیزی که فرانت به کاربر نشون می‌ده یکی باشه. +# حداقل فاصله بین دو درخواستِ OTP برای همون شماره؛ مستقل از سقفِ ساعتیِ بالا +# (که جلویِ burst رو نمی‌گیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار +# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی. OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120 # در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود. @@ -368,14 +345,10 @@ PAYMENT_WEBHOOK_SECRET = os.environ.get( # ----------------------------------------------------------------------- # Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2) # ----------------------------------------------------------------------- -# طبق داکیومنت رسمی (developers.cafebazaar.ir/fa/guidelines/in-app-billing)، -# صحت‌سنجی پرداخت بازار یک OAuth2 سرور-به-سرور‌ه: یک بار با CLIENT_ID/ -# CLIENT_SECRET و کد بازگشتی از پنل devapi، REFRESH_TOKEN گرفته می‌شه (یک‌بار، -# دستی)؛ از اون به بعد بک‌اند با همین REFRESH_TOKEN، ACCESS_TOKEN تازه -# می‌گیره و با اون به endpoint وضعیت خرید/اشتراک درخواست می‌زنه. -# ⚠️ کافه‌بازار اواخر ۱۴۰۳ روش صحت‌سنجی رو تغییر داد؛ قبل از اتصال واقعی، -# حتماً آخرین مسیر endpointها رو از همون صفحه‌ی رسمی/پنل devapi خودتون -# تایید کنید (BAZAAR_API_BASE پایینی رو بر همون اساس ست کنید). +# صحت‌سنجی OAuth2 سرور-به-سروره: REFRESH_TOKEN یک‌بار دستی از پنل devapi +# گرفته می‌شه، بعد بک‌اند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه می‌گیره. +# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار +# این روش رو قبلاً تغییر داده. BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app') BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '') BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '') @@ -388,18 +361,11 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # ----------------------------------------------------------------------- -# Logging (آماده‌سازیِ لاگ‌های موجود برای جمع‌آوریِ متمرکز — Loki/ELK-style) +# Logging — بدونِ یک LOGGING صریح، loggerهای موجودِ اپ (config.middleware، +# accounts.services، ...) فقط به rootِ پیش‌فرض می‌رسیدن که برای INFO عملاً +# خروجی‌ای نداره. خروجی: یک JSON در هر خط روی stdout، سازگار با +# Promtail/Filebeat برای جمع‌آوریِ بعدی توسطِ Loki/ELK. # ----------------------------------------------------------------------- -# ⚠️ افزوده شده: پروژه از قبل چندین logger (config.middleware، -# accounts.services، ai_tutor.views، ...) با logging.getLogger(__name__) -# داشت، اما بدونِ یک LOGGING صریح در settings.py، اینا فقط به rootِ -# پیش‌فرضِ کتابخانه‌ی logging می‌رسیدن (که برای سطحِ INFO عملاً هیچ خروجی‌ای -# تولید نمی‌کنه). این تنظیم، بدونِ اضافه/حذفِ هیچ loggerِ اپلیکیشنی یا -# eventِ لاگی، همون پیام‌های موجود رو روی stdout و به‌صورتِ یک JSON در هر -# خط (فرمتِ سازگار با agentِ جمع‌آوریِ بیرونی مثلِ Promtail/Filebeat که -# بعداً می‌تونه به Loki/ELK بفرسته) چاپ می‌کنه. هیچ appِ جدید، ذخیره‌سازیِ -# دیتابیسی، API/UIِ لاگ، یا وابستگیِ Elasticsearch/Loki/Grafana به خودِ -# کدِ Django اضافه نشده. LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -420,16 +386,9 @@ LOGGING = { }, } -# ⚠️ افزوده شده (یکپارچه‌سازیِ Docker + Promtail/Loki/Grafana): وقتی -# env var LOG_DIR ست شده باشه (فقط داخلِ docker-compose.yml، روی یک -# ولومِ مشترک با کانتینرِ promtail)، همون خطوطِ JSON که از قبل روی -# stdout چاپ می‌شدن، *علاوه* بر stdout در یک فایل هم نوشته می‌شن؛ -# Promtail همون فایل رو tail می‌کنه و به Loki می‌فرسته. -# این فقط یک هندلرِ خروجیِ اضافه با همون formatter (json) هست — نه -# پیام‌های لاگ، نه محافظت‌های حساسیتیِ موجود (بدونِ OTP/token/رمزعبور و -# غیره در پیام‌ها)، و نه هیچ loggerِ اپلیکیشنی تغییر نمی‌کنه. -# وقتی LOG_DIR ست نباشه (یعنی اجرای عادیِ خارج از Docker، طبق README)، -# رفتار دقیقاً همون چیزیه که از قبل بود: فقط stdout. +# وقتی LOG_DIR ست باشه (فقط داخلِ docker-compose.yml، روی ولومِ مشترک با +# promtail)، همون خطوطِ JSON علاوه بر stdout در یک فایل هم نوشته می‌شن تا +# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout. LOG_DIR = os.environ.get('LOG_DIR', '') if LOG_DIR: diff --git a/config/tests.py b/config/tests.py index 45269d5..4f131b9 100644 --- a/config/tests.py +++ b/config/tests.py @@ -52,15 +52,9 @@ class SecretKeyFailClosedTests(SimpleTestCase): ) def test_production_configured_secret_key_used(self): - # ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست فقط رفتارِ - # SECRET_KEY رو می‌سنجه، اما چون CACHE_URL رو ست نمی‌کرد، وقتی - # فیکسِ جداگانه‌ی CACHE_URL fail-closed (پایین‌ترِ همین فایل، نگاه - # کنید به CacheUrlFailClosedTests) اضافه شد، همین import/reload با - # DEBUG=False و بدونِ CACHE_URL به‌جای رسیدن به assertion، با - # ImproperlyConfigured مربوط به CACHE_URL (نه SECRET_KEY) fail - # می‌شد — یک false negative روی چیزی که این تست اصلاً قرار نیست - # بسنجه. یک CACHE_URL معتبر هم ست می‌کنیم تا این تست فقط دقیقاً - # همون چیزی که اسمش می‌گه (SECRET_KEY) رو بسنجه. + # CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون + # ImproperlyConfiguredِ CACHE_URL رخ می‌ده، نه رسیدن به assertionِ + # SECRET_KEY که این تست واقعاً می‌سنجه. settings_module = self._reload_settings_with_env( env_overrides={ 'DJANGO_DEBUG': '', @@ -196,24 +190,11 @@ class APIRequestLoggingMiddlewareTests(TestCase): self.assertEqual(records, []) def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self): - # 🔒 اثبات فیکسِ این دور: اگه self.get_response(request) خودش یک - # exceptionِ دست‌نخورده raise کنه، میان‌افزار باید همچنان یک خطِ - # لاگِ سطحِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ جزئیاتِ - # حساس) ثبت کنه و بعد دقیقاً همون exceptionِ اصلی رو دوباره raise - # کنه — بدونِ اینکه هیچ رفتارِ Django/DRF یا exceptionِ دیگه‌ای رو - # تغییر بده. مستقیماً خودِ میان‌افزار (نه کلِ چرخه‌ی - # exception-handlingِ Django) تست می‌شه، چون همون لایه‌ای‌ست که این - # فیکس روش اعمال شده. - # - # ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): قبلاً این تست فقط - # record.getMessage() رو چک می‌کرد. getMessage() خودش داخلی - # msg %% args رو انجام می‌ده، پس اگه شمارِ آرگومان‌ها با - # placeholderهای رشته جور نباشه، همون‌جا با یک exception (نه یک - # پیامِ نادرست) fail می‌شد و اصلاً به assertion نمی‌رسید. برای اینکه - # این تست واقعاً *صحتِ formatting* رو بسنجه (نه فقط اینکه یک‌جوری - # fail نشه)، حالا با logging.Formatter().format(record) دقیقاً - # همون مسیری که یک handlerِ واقعی (کنسول/فایل) طی می‌کنه رو صدا - # می‌زنیم و روی متنِ نهاییِ فرمت‌شده assert می‌کنیم. + # میان‌افزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ + # جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه. + # با logging.Formatter().format(record) دقیقاً همون مسیری که یک + # handlerِ واقعی طی می‌کنه رو صدا می‌زنیم (نه فقط getMessage()، که + # یه mismatchِ %%-args رو با exceptionِ خودش می‌پوشونه، نه fail واقعی). request = RequestFactory().get('/api/some/failing/path') def failing_get_response(inner_request): diff --git a/content/tests.py b/content/tests.py index b908221..43ffb6e 100644 --- a/content/tests.py +++ b/content/tests.py @@ -12,13 +12,8 @@ from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, U class LessonCompleteImpossibleCountTests(TestCase): - """ - 🔒 تستِ رگرسیونِ فیکسِ امنیتی: LessonCompleteView قبلاً correctCount رو - بدونِ مقایسه با تعدادِ واقعیِ سوال‌های درس می‌پذیرفت، پس یک کلاینتِ - مخرب می‌تونست با correctCount بزرگ‌تر از تعدادِ واقعیِ سوال‌ها، - xp/coins متورم بگیره. این تست هم ردِ درخواست‌های غیرممکن رو، هم - رفتارِ صحیحِ تکمیل با مقادیرِ معتبر رو تضمین می‌کنه. - """ + """رگرسیون: correctCount باید با تعدادِ واقعیِ سوال‌های درس مقایسه بشه، وگرنه + کلاینتِ مخرب می‌تونه xp/coins رو متورم کنه.""" def setUp(self): self.client = APIClient() @@ -67,14 +62,9 @@ class LessonCompleteImpossibleCountTests(TestCase): class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase): - """ - 🔒 تستِ رگرسیونِ فیکسِ دوم: قبلاً ratio از totalQuestions ارسالی‌ی - خودِ کلاینت محاسبه می‌شد، نه از تعدادِ واقعیِ سوال‌های درس. یعنی حتی - بعد از سقف‌زدنِ correctCount، کلاینت می‌تونست با totalQuestions=1 - (به‌جای ۳ سوالِ واقعی)، ratio رو مصنوعاً ۱۰۰٪ کنه و ۳ ستاره/coins - کامل بگیره درحالی‌که فقط ۱ از ۳ سوال رو درست جواب داده. حالا ratio - همیشه بر اساسِ actual_question_count محاسبه می‌شه. - """ + """رگرسیون: ratio باید از actual_question_count محاسبه بشه نه از totalQuestیِ + ارسالیِ کلاینت، وگرنه حتی بعدِ سقف‌زدنِ correctCount کلاینت می‌تونه با + totalQuestions دروغین ratio رو مصنوعاً ۱۰۰٪ کنه.""" def setUp(self): self.client = APIClient() @@ -114,13 +104,8 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase): class QuestionAnswerThrottleTests(TestCase): - """ - 🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView - (throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با - همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم - یک UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن. - ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP. - """ + """محدودیتِ per-user روی QuestionAnswerView (throttle_scope='question_answer')؛ + Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت.""" def setUp(self): from django.core.cache import cache @@ -242,14 +227,8 @@ class QuestionAnswerThrottleTests(TestCase): class LessonCompleteThrottleTests(TestCase): - """ - 🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView - (throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با - همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم - یک UserLessonCompletion جدید می‌سازن و سیگنالِ post_save - (NodeProgress) + به‌روزرسانیِ UserStats رو اجرا می‌کنن. - ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP. - """ + """محدودیتِ per-user روی LessonCompleteView (throttle_scope='lesson_complete')؛ + Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت.""" def setUp(self): from django.core.cache import cache @@ -361,17 +340,9 @@ class LessonCompleteThrottleTests(TestCase): class QuestionAnswerGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='question_answer' برای - هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView - با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم - می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle - تازه بگیره و منطقِ هارت/NodeProgress رو بی‌محدودیت اجرا کنه. حالا - GuestAwareContentThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP - (همون get_ident که guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ - حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرِ - غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. - """ + """اثباتِ فیکسِ throttle: چرخشِ حساب مهمان (هر deviceId یه pk تازه) نباید + bucket تازه‌ی question_answer بده — GuestAwareContentThrottle برای + مهمان‌ها کلید رو بر اساسِ IP می‌سازه، نه user.pk.""" def setUp(self): from django.core.cache import cache @@ -446,17 +417,9 @@ class QuestionAnswerGuestThrottleBypassTests(TestCase): class LessonCompleteGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای - هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView - با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم - می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle - تازه بگیره و UserLessonCompletion/xp/coins/progress رو بی‌محدودیت - تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو - بر اساسِ IP می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. - رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده - مونده. - """ + """اثباتِ فیکسِ throttle: چرخشِ حساب مهمان نباید bucket تازه‌ی + lesson_complete بده — GuestAwareContentThrottle برای مهمان‌ها کلید رو + بر اساسِ IP می‌سازه، نه user.pk.""" def setUp(self): from django.core.cache import cache diff --git a/content/views.py b/content/views.py index dbe6738..55d9505 100644 --- a/content/views.py +++ b/content/views.py @@ -26,24 +26,11 @@ from .serializers import ( class GuestAwareContentThrottle(ScopedRateThrottle): """ - ⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، - GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/ - GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py) - اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده - از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون - GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) - می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر - حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete' - بگیره و محدودیتِ واقعی رو دور بزنه — درحالی‌که هر دو endpoint اثرِ - جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion + - xp/coins/progress). - - دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ - cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی - حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب - دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان - (ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ - answer/heart/progress/completion/idempotency دست‌نخورده‌ست. + مشترک با GuestAwareAIThrottle/GuestAwareFamilyThrottle/GuestAwareDiscountThrottle + و مشابه‌هاش: چون GuestSessionView با هر deviceId جدید یک pk تازه می‌سازه، + throttleِ استانداردِ بر اساسِ user.pk با چرخشِ حسابِ مهمان دور زده می‌شه — + درحالی‌که answer/complete اثرِ جانبیِ ماندگار (هارت، xp/coins/progress) دارن. + برای همین فقط کاربرهای مهمان با IP کلید می‌خورن؛ کاربرهای غیرمهمان مثلِ قبل بر اساسِ pk. """ def get_cache_key(self, request, view): @@ -165,18 +152,9 @@ class QuestionAnswerView(APIView): # userId همیشه از request.user.id گرفته می‌شود، نه ورودی کلاینت. permission_classes = [IsAuthenticated] - # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای - # درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک - # UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن — - # حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user - # احرازهویت‌شده باشه با pk کاربر کلید می‌خوره (نه فقط IP)، پس تغییرِ - # IP این محدودیت رو دور نمی‌زنه. - # ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId - # جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ - # user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از - # GuestAwareContentThrottleِ مشترک استفاده می‌شه که برای کاربرهای - # مهمان کلید رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ - # per-user قبلی رو دست‌نخورده نگه می‌داره. + # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های + # *متفاوت* که هرکدوم UserAnswer جدید می‌سازن و هارت/NodeProgress رو تغییر می‌دن؛ + # برای همون GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). throttle_scope = 'question_answer' throttle_classes = [GuestAwareContentThrottle] @@ -274,20 +252,12 @@ class QuestionAnswerView(APIView): class LessonCompleteView(APIView): """POST /api/lessons/{id}/complete — اتمام درس + آمار جدید""" - # ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به - # کلاینت اجازه می‌داد به‌جای هر کاربر دیگری xp/coins ثبت کنه. حالا auth - # اجباریه و همیشه از request.user.id استفاده می‌شه. + # userId همیشه از request.user.id گرفته می‌شود (نه ورودیِ کلاینت)، وگرنه کلاینت + # می‌تونست به‌جای هر کاربرِ دیگری xp/coins ثبت کنه. permission_classes = [IsAuthenticated] - # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای - # درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک - # UserLessonCompletion جدید می‌سازن و سیگنالِ post_save (NodeProgress) + - # به‌روزرسانیِ UserStats رو اجرا می‌کنن. ScopedRateThrottle با pk - # کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP. - # ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به - # GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ - # مشترک استفاده می‌شه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این - # endpoint رکوردِ completion ماندگار می‌سازه و xp/coins/progress رو - # واقعاً تغییر می‌ده (برخلافِ question_answer که فرکانسِ طبیعی‌اش بالاتره). + # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره؛ برای همینه که + # GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour + # (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار می‌سازه. throttle_scope = 'lesson_complete' throttle_classes = [GuestAwareContentThrottle] @@ -352,21 +322,12 @@ class LessonCompleteView(APIView): } return Response(LessonCompleteResponseSerializer(data).data) - # ⚛️ atomic: UserLessonCompletion.create پایین‌تر یک سیگنال post_save - # رو تریگر می‌کنه (content/receivers.py → NodeProgress + احتمالاً - # StatusChangeLog)؛ به‌علاوه‌ی get_or_create/save روی UserStats. - # بدون atomic، اگه save روی stats fail کنه، تکمیلِ درس ثبت شده ولی - # xp/coins/progress_percent به‌روز نشده — یعنی چون تکمیل هر درس - # یک‌بارمصرفه، کاربر دیگه هیچ‌وقت اون xp رو نمی‌گیره. - # - # ⚛️🔒 race condition: چکِ «is_first_completion» قبلاً قبل از atomic - # و بدون قفل انجام می‌شد — دو درخواستِ همزمان (دابل‌کلیک/رتریِ - # کلاینت) برای تکمیلِ همون درس می‌تونستن هر دو ببینن که هنوز کامل - # نشده و هر دو xp/coins کامل بگیرن (دوبرابر شدنِ جایزه). حالا ردیفِ - # UserStats کاربر قفل می‌شه (select_for_update) و چکِ - # is_first_completion داخلِ همون قفل، بعد از لاک‌شدن، دوباره انجام - # می‌شه — یعنی درخواستِ دومِ همزمان، حالا که اولی commit شده، به‌درستی - # می‌بینه که دیگه first completion نیست. + # atomic لازمه چون create پایین‌تر سیگنالِ post_save رو تریگر می‌کنه + # (NodeProgress) و UserStats هم آپدیت می‌شه؛ بدون اون، fail شدنِ save + # روی stats یعنی تکمیل ثبت شده ولی xp/coins یک‌بارمصرف از دست رفته. + # ردیفِ UserStats قفل می‌شه (select_for_update) و چکِ is_first_completion + # داخلِ همون قفل انجام می‌شه تا دو درخواستِ همزمان (دابل‌کلیک) هردو + # جایزه‌ی کاملِ اول رو نگیرن. try: with transaction.atomic(): UserStats.objects.get_or_create(user_id=user_id) diff --git a/discount/services.py b/discount/services.py index 996bf57..6ea96ec 100644 --- a/discount/services.py +++ b/discount/services.py @@ -27,18 +27,11 @@ def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None: if not offer.is_active: return None - # ⚠️ اصلاح: قبلاً هر IntegrityError داخل کل بلوکِ atomic (که شاملِ - # grant_free_subscription هم می‌شد) به‌عنوان «duplicate UserOffer» - # تفسیر می‌شد و None برمی‌گردوند — یعنی اگه یک IntegrityError واقعیِ - # دیگه (مثلاً داخل grant_free_subscription) اتفاق می‌افتاد، خطا قورت - # داده می‌شد و caller فکر می‌کرد «کاربر قبلاً این آفر رو گرفته»، در - # حالی که اصلاً چنین چیزی نبود. حالا فقط IntegrityError ناشی از - # UniqueConstraintِ خودِ UserOffer.create (duplicate واقعی) گرفته - # می‌شه — با یک savepoint جداگانه (atomic تودرتو) که فقط همون create - # رو rollback می‌کنه، نه کل تراکنش رو — و خطاهای دیگه (مثلاً از داخل - # grant_free_subscription) عادی propagate می‌شن. atomic بودنِ کلِ - # عملیات (UserOffer + Subscription + expires_at) هم دست‌نخورده باقی - # مونده، دقیقاً طبق همون دلیلی که در docstring بالا توضیح داده شده. + # فقط IntegrityErrorِ ناشیِ از UniqueConstraintِ خودِ UserOffer.create + # (duplicate واقعی) اینجا گرفته می‌شه، با یک savepoint مجزا که فقط + # همون create رو rollback می‌کنه — وگرنه یک IntegrityErrorِ دیگه (مثلاً + # داخلِ grant_free_subscription) قورت داده می‌شد و caller اشتباهاً فکر + # می‌کرد کاربر قبلاً این آفر رو گرفته. with transaction.atomic(): try: with transaction.atomic(): # savepoint مجزا فقط برای create diff --git a/discount/tests.py b/discount/tests.py index 0c4acdb..a7da18d 100644 --- a/discount/tests.py +++ b/discount/tests.py @@ -102,12 +102,7 @@ class RedeemOfferCodeThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_throttle_is_scoped_per_user_not_shared_globally(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ - # دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های - # مهمان دیگه bucket جداگانه ندارن (نگاه کنید به - # RedeemOfferCodeGuestThrottleBypassTests پایین‌تر) — این تست فقط - # رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید - # دقیقاً مثلِ قبل دست‌نخورده بمونه. + # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). other_user = User.objects.create(is_guest=False, name='', phone='+989120000701') other_token = issue_access_token(other_user.id) @@ -299,12 +294,7 @@ class ValidateDiscountThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_throttle_is_scoped_per_user_not_shared_globally(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ - # دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های - # مهمان دیگه bucket جداگانه ندارن (نگاه کنید به - # ValidateDiscountGuestThrottleBypassTests پایین‌تر) — این تست فقط - # رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید - # دقیقاً مثلِ قبل دست‌نخورده بمونه. + # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). other_user = User.objects.create(is_guest=False, name='', phone='+989120000731') other_token = issue_access_token(other_user.id) @@ -387,12 +377,7 @@ class RedeemDiscountThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_throttle_is_scoped_per_user_not_shared_globally(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ - # دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های - # مهمان دیگه bucket جداگانه ندارن (نگاه کنید به - # RedeemDiscountGuestThrottleBypassTests پایین‌تر) — این تست فقط - # رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید - # دقیقاً مثلِ قبل دست‌نخورده بمونه. + # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). other_user = User.objects.create(is_guest=False, name='', phone='+989120000301') other_token = issue_access_token(other_user.id) other_order = Order.objects.create( diff --git a/discount/views.py b/discount/views.py index 259e4a2..d8fec5a 100644 --- a/discount/views.py +++ b/discount/views.py @@ -69,21 +69,11 @@ 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 قبلی رو دست‌نخورده نگه می‌داره. + # این endpoint type/value کد رو برمی‌گردونه، پس بدونِ throttle یک + # کاربر می‌تونست بی‌نهایت کد رو probe کنه؛ نرخ کمی بالاتر از + # offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle + # مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای + # مهمان‌ها کلید رو بر اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده. throttle_scope = 'discount_validate' throttle_classes = [GuestAwareDiscountThrottle] @@ -117,23 +107,11 @@ 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 - # قبلی رو دست‌نخورده نگه می‌داره. + # همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ + # درست یه کدِ واقعی رو مصرف می‌کنه (سهمِ max_uses مشترک) و مبلغِ سفارش + # رو واقعاً کم می‌کنه، ضمن اینکه هر تلاش دو select_for_update و یه + # تراکنشه. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر اساسِ IP + # می‌سازه تا چرخشِ حساب bucket تازه نده. throttle_scope = 'discount_redeem' throttle_classes = [GuestAwareDiscountThrottle] @@ -200,24 +178,11 @@ 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، کدهای آفر قابلِ probe بودن؛ اینجا حساس‌تره چون + # Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون + # کاربر رو می‌گیره — یک حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو + # دوباره بگیره. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر + # اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده. throttle_scope = 'offer_redeem' throttle_classes = [GuestAwareDiscountThrottle] diff --git a/family/tests.py b/family/tests.py index dcf5302..28d5d36 100644 --- a/family/tests.py +++ b/family/tests.py @@ -58,11 +58,7 @@ class FamilyLinkAcceptThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_throttle_is_scoped_per_user_not_shared_globally(self): - # این تست مخصوصِ کاربرهای *غیرمهمان* (ثبت‌نام‌شده) است — طبقِ فیکسِ - # GuestAwareFamilyThrottle، حساب‌های مهمان دیگه bucket جداگانه - # ندارن (نگاه کنید به FamilyLinkAcceptGuestThrottleBypassTests پایین‌تر)، - # اما رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده باید دقیقاً مثلِ - # قبل دست‌نخورده بمونه. + # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). other_user = User.objects.create( is_guest=False, name='', phone='+989120000099', ) @@ -241,12 +237,8 @@ class FamilyLinkInviteThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.parent که - # مهمان است)، چون طبقِ فیکسِ GuestAwareFamilyThrottle حالا کلیدِ - # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به - # FamilyLinkInviteGuestThrottleBypassTests پایین‌تر) — این تست - # فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین - # می‌کنه. + # کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ + # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300') registered_token = issue_access_token(registered_parent.id) diff --git a/family/views.py b/family/views.py index 570ac24..f05b644 100644 --- a/family/views.py +++ b/family/views.py @@ -67,17 +67,10 @@ class FamilyLinkInviteView(APIView): permission_classes = [IsAuthenticated] - # ⚠️ اضافه شده: این endpoint قبلاً هیچ throttle مخصوصی نداشت، درحالی‌که - # یک FamilyLink ماندگار می‌سازه و یک کدِ دعوتِ ۶رقمیِ تازه تولید می‌کنه. - # بدونِ throttle، یک کاربرِ احرازهویت‌شده می‌تونست با فرستادنِ - # پی‌درپیِ شماره‌های مختلف، تعدادِ زیادی FamilyLink/کدِ دعوت بسازه. - # طبقِ همون الگویِ GuestAwareFamilyThrottleِ مشترک (که - # FamilyLinkAcceptView هم استفاده می‌کنه): برای کاربرهای مهمان کلید - # بر اساسِ IP و برای کاربرهای غیرمهمان بر اساسِ user.pk است، پس - # چرخشِ حسابِ مهمان نمی‌تونه bucket تازه بگیره. یک throttle_scope - # مجزا ('family_invite_create'، نه 'family_invite_accept') استفاده - # شده چون تولید و مصرفِ کدِ دعوت باید مستقل از هم قابل‌تنظیم بمونن؛ - # منطقِ generation/authorization دست‌نخورده‌ست. + # بدونِ throttle، یک کاربر می‌تونست با فرستادنِ پی‌درپیِ شماره‌های مختلف + # تعدادِ زیادی FamilyLink بسازه. GuestAwareFamilyThrottle برای مهمان‌ها + # کلید رو بر اساسِ IP می‌سازه؛ scopeِ جدا از family_invite_accept چون + # تولید و مصرفِ کدِ دعوت باید مستقل قابل‌تنظیم باشن. throttle_scope = 'family_invite_create' throttle_classes = [GuestAwareFamilyThrottle] @@ -131,15 +124,9 @@ class FamilyLinkAcceptView(APIView): permission_classes = [IsAuthenticated] - # ⚠️ اضافه شده: inviteCode یک رازِ عددیِ ۶رقمی تولیدشده‌ی سرور است - # (family/views.py::FamilyLinkInviteView)، و این endpoint دقیقاً همون - # کد رو با select_for_update() جست‌وجو می‌کنه؛ بدونِ throttle، یک - # کاربرِ احرازهویت‌شده می‌تونست بدونِ هیچ محدودیتی فضای ۶رقمی رو - # brute-force کنه. طبقِ همون الگویِ ScopedRateThrottle که برای بقیه‌ی - # اندپوینت‌هایِ حدس‌پذیر پروژه اضافه شد، اینجا هم یک scope مجزا اضافه - # شد (نه scope مشترک با discount/offer) تا نرخِ محدودسازی مستقل از اون‌ها - # قابل‌تنظیم باشه. چون DRF throttle check قبل از dispatch به post() در - # APIView.initial() اجرا می‌شه، حدس‌های بلاک‌شده اصلاً به select_for_update() + # inviteCode یک رازِ عددیِ ۶رقمیه؛ بدونِ throttle یه کاربر می‌تونست فضای + # ۶رقمی رو brute-force کنه. throttle در APIView.initial() قبل از + # post() اجرا می‌شه، پس حدس‌های بلاک‌شده اصلاً به select_for_update() # نمی‌رسن. throttle_scope = 'family_invite_accept' throttle_classes = [GuestAwareFamilyThrottle] diff --git a/payment/models.py b/payment/models.py index 67bf94b..b0195dc 100644 --- a/payment/models.py +++ b/payment/models.py @@ -8,12 +8,9 @@ class Order(models.Model): """ STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')] - # دو مدل پرداخت طبق درخواست سینیور: 'gateway' (زرین‌پال/زیبال و مشابه) و - # 'bazaar' (پرداخت درون‌برنامه‌ای اپلیکیشن بازار). این دو منطق پرداخت و - # صحت‌سنجی کاملاً متفاوتی دارن (webhook درگاه در برابر verify سرور-به- - # سرور با access_token بازار)؛ برای همین provider جداست، نه فقط یک مقدار - # دیگه در Transaction.gateway (که فقط برای گزارش‌گیریِ درگاه واقعی زرین‌پال/ - # زیبال باقی می‌مونه). + # 'gateway' و 'bazaar' منطق و صحت‌سنجیِ کاملاً متفاوتی دارن (webhook در + # برابرِ verifyِ سرور-به-سرور با access_token)؛ برای همین provider جداست + # از Transaction.gateway (که فقط برای گزارش‌گیریِ درگاهِ واقعیه). PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون‌برنامه‌ای بازار')] user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User @@ -48,18 +45,11 @@ class Transaction(models.Model): class Meta: ordering = ['created_at'] constraints = [ - # 🔒 یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای - # غیرخالی: این قید سطح DB تضمین می‌کنه یک gateway_ref_id مشخص - # (مثلاً Authority زرین‌پال یا purchaseToken بازار) در کل جدول - # Transaction فقط می‌تونه متعلق به یک Order باشه — هم جلوی - # duplicate/idempotency برای همون Order رو می‌گیره (چون در کل - # جدول حداکثر یک ردیف با این (gateway, ref) می‌تونه وجود داشته - # باشه) و هم جلوی reuse/replay همون ref برای یک Order دیگه رو - # (نگاه کنید به apply_order_payment_result). این قید، قیدِ - # قدیمی‌ترِ (order, gateway_ref_id, status) رو هم در برمی‌گیره - # (چون سخت‌گیرانه‌تره)، برای همین دیگه لازم نیست جدا نگه داشته - # بشه. gateway_ref_id__gt='' یعنی رفرنس‌های خالی (که provider - # واقعی هنوز رفرنسی برنگردونده) مشمول این قید نمی‌شن. + # یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای غیرخالی: + # یک ref مشخص (مثلاً Authorityِ زرین‌پال) فقط می‌تونه متعلقِ یک + # Order باشه — هم جلوی duplicate رو می‌گیره هم جلوی reuse/replay + # برای Orderِ دیگه (نگاه کنید به apply_order_payment_result). + # gateway_ref_id__gt='' یعنی refِ خالی مشمول نیست. models.UniqueConstraint( fields=['gateway', 'gateway_ref_id'], condition=models.Q(gateway_ref_id__gt=''), diff --git a/payment/tests.py b/payment/tests.py index 5c339cb..28bccc0 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -56,12 +56,9 @@ class SecretDefaultsFailClosedTests(TestCase): self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None)) def test_debug_mode_local_fallback_still_valid(self): - # settings.py این مقادیر رو یک‌بار موقعِ import، بر اساسِ DEBUG واقعیِ - # همون پروسه محاسبه می‌کنه (نه چیزی که override_settings در تست - # می‌تونه بعداً toggle کنه). برای تستِ صریحِ همین منطق، ماژولِ - # config.settings رو با DJANGO_DEBUG=1 و بدونِ ست‌بودنِ - # INTERNAL_SERVICE_KEY/PAYMENT_WEBHOOK_SECRET دوباره reload می‌کنیم - # و مطمئن می‌شیم دیفالتِ محلیِ قبلیِ dev هنوز برقراره. + # settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه + # چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این + # منطق باید خودِ ماژول رو reload کرد. from config import settings as settings_module env_overrides = {'DJANGO_DEBUG': '1'} @@ -227,12 +224,8 @@ class BazaarVerifyPurchaseThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که - # مهمان است)، چون طبقِ فیکسِ GuestAwareBazaarThrottle حالا کلیدِ - # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به - # BazaarVerifyPurchaseGuestThrottleBypassTests پایین‌تر) — این تست - # فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین - # می‌کنه. + # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ + # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751') registered_token = issue_access_token(registered_user.id) diff --git a/payment/views.py b/payment/views.py index 894d6ce..bb8cba2 100644 --- a/payment/views.py +++ b/payment/views.py @@ -23,25 +23,10 @@ from .services import apply_order_payment_result, create_order class GuestAwareBazaarThrottle(ScopedRateThrottle): - """ - ⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، - GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/ - GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py) - اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده - از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون - GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) - می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر - حساب یک bucket تازه از throttle_scope 'bazaar_verify' بگیره — درحالی‌که - هر درخواستِ پذیرفته‌شده یک فراخوانیِ سرور-به-سرور به API بازار رو - تریگر می‌کنه. - - دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ - cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی - حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب - دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان - (ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ - verify/order ownership/transaction/apply-payment دست‌نخورده‌ست. - """ + """همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ + حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify + بده، چون هر درخواستِ پذیرفته‌شده یه فراخوانیِ سرور-به-سرور به بازاره. + برای مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk.""" def get_cache_key(self, request, view): user = request.user @@ -121,14 +106,10 @@ class PaymentWebhookView(APIView): """ permission_classes = [HasWebhookSecret] - # ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت، درحالی‌که فقط - # با مقایسه‌ی یک هدرِ ثابت (X-Webhook-Secret) محافظت می‌شه؛ بدونِ - # throttle، تعدادِ نامحدودی تلاش برای حدسِ این سکرت (یا fuzz کردنِ - # orderId بعد از افشای احتمالیِ سکرت) ممکن بود، درحالی‌که یک حدسِ - # درست باعثِ paid شدنِ واقعیِ سفارش و فعال‌سازیِ اشتراک می‌شه. چون - # این caller یک کاربرِ اپ نیست (خودِ درگاهِ پرداخته)، از یک - # ScopedRateThrottleِ سادهٔ IP-محور استفاده شده، نه الگویِ - # GuestAware*Throttle (که نیازمندِ request.user است). + # این endpoint فقط با مقایسه‌ی هدرِ ثابتِ X-Webhook-Secret محافظت می‌شه، + # پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون + # caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور + # استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است. throttle_scope = 'payment_webhook' throttle_classes = [ScopedRateThrottle] @@ -182,19 +163,11 @@ class BazaarVerifyPurchaseView(APIView): """ permission_classes = [IsAuthenticated] - # ⚠️ افزوده شده: این endpoint یک فراخوانیِ سرور-به-سرور به API بازار - # (verify_purchase) رو تریگر می‌کنه؛ بدون throttle مخصوص، یک کاربر - # می‌تونست با ارسال مکرر purchaseToken نامعتبر، بار زیادی روی سرویس - # بازار و بک‌اند بندازه. throttle_scope مجزا (bazaar_verify) جدا از - # نرخ‌های دیگه (مثل otp_*) تنظیم شده. - # ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId - # جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ - # user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از - # GuestAwareBazaarThrottle استفاده می‌شه که برای کاربرهای مهمان کلید - # رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ per-user - # قبلی رو دست‌نخورده نگه می‌داره. نرخ هم از ۲۰/hour به ۱۰/hour - # سخت‌گیرانه‌تر شد چون هر درخواستِ پذیرفته‌شده یک تماسِ سرور-به-سرورِ - # واقعی با بازار می‌زنه. + # این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase) + # تریگر می‌کنه، برای همین throttle_scope مجزا و نرخِ سخت‌گیرانه‌تر (۱۰/hour) + # داره. کاربرهای مهمان با هر deviceId یک pk تازه می‌گیرن، پس + # GuestAwareBazaarThrottle برای اون‌ها بر اساسِ IP کلید می‌سازه تا چرخشِ + # حساب bucket تازه نگیره؛ برای کاربرهای غیرمهمان رفتارِ per-user حفظ می‌شه. throttle_scope = 'bazaar_verify' throttle_classes = [GuestAwareBazaarThrottle] @@ -208,13 +181,10 @@ class BazaarVerifyPurchaseView(APIView): serializer.is_valid(raise_exception=True) v = serializer.validated_data - # ⚛️🔒 اصلاح: فراخوانی HTTP به API بازار (تا ~۲۰ ثانیه، دو درخواست - # پشت‌سرهم برای refresh token + verify) قبلاً *داخل* transaction.atomic - # و زیر select_for_update روی Order انجام می‌شد؛ یعنی ردیف Order (و - # هر تراکنش دیگه‌ای که بخواد همون Order یا Subscription مرتبط رو لمس - # کنه) تا برگشتِ پاسخ بازار قفل می‌موند. حالا verify_purchase قبل از - # باز شدن atomic/lock صدا زده می‌شه؛ لاک فقط برای اعمال نتیجه (چند - # میلی‌ثانیه) گرفته می‌شه. + # verify_purchase (تا ~۲۰ ثانیه، دو درخواستِ پشت‌سرهم) عمداً *قبل* از + # بازشدنِ atomic/lock صدا زده می‌شه، نه داخلش — وگرنه ردیفِ Order (و هر + # تراکنشِ دیگه‌ای روی همون Order/Subscription) تا برگشتِ پاسخِ بازار + # قفل می‌موند. لاک فقط برای اعمالِ نتیجه (چند میلی‌ثانیه) گرفته می‌شه. order_lookup = get_object_or_404( Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar', ) diff --git a/subscription/models.py b/subscription/models.py index ec1b61b..d6fe940 100644 --- a/subscription/models.py +++ b/subscription/models.py @@ -14,31 +14,21 @@ class Plan(models.Model): price = models.PositiveIntegerField() currency = models.CharField(max_length=10, default='IRT') discount_percent = models.PositiveSmallIntegerField(default=0) - popular = models.BooleanField(default=False) # <-- اصلاح/افزوده شده - # ⚠️ اصلاح دیگه: سند طراحی هیچ فیلد duration‌ای برای Plan نداره، در - # نتیجه‌ی این نبود، activate_subscription قبلاً مدت اشتراک رو با یک - # قاعده‌ی stub (چک کردن کلمه‌ی «سالانه»/yearly در عنوان) حدس می‌زد که - # شکننده و وابسته به متن title بود. duration_days اینجا به‌عنوان منبع - # صریح و قابل‌اتکای مدت اشتراک اضافه شده؛ پیش‌فرض ۳۰ روز. - duration_days = models.PositiveIntegerField(default=30) # <-- اصلاح/افزوده شده + popular = models.BooleanField(default=False) + # منبعِ صریحِ مدتِ اشتراک؛ بدونش activate_subscription مجبور بود مدت رو + # از رویِ متنِ title (مثلاً کلمه‌ی «سالانه») حدس بزنه. + duration_days = models.PositiveIntegerField(default=30) def __str__(self): return self.title class Subscription(models.Model): - """ - ⚠️ اصلاح نسبت به سند اولیه: STATUS_CHOICES در سند فقط شامل - active/expired/canceled بود. اما در جریان POST /api/subscription/purchase - یک Subscription قبل از تایید پرداخت (که با payment webhook میاد) ساخته - می‌شه؛ بدون یک وضعیت میانی، راهی برای نمایش «سفارش ثبت شده ولی هنوز - پرداخت تایید نشده» وجود نداره. برای همین وضعیت pending اضافه و به‌عنوان - پیش‌فرض ساخت (به‌جای active در سند) تنظیم شده؛ فعال‌سازی واقعی توسط - subscription.services.activate_subscription بعد از webhook پرداخت انجام - می‌شه. - """ + """وضعیتِ pending برای فاصله‌ی بینِ ساختنِ Subscription (در purchase) و + تاییدِ پرداخت (webhook)؛ فعال‌سازیِ واقعی توسطِ + subscription.services.activate_subscription انجام می‌شه.""" STATUS_CHOICES = [ - ('pending', 'در انتظار پرداخت'), # <-- اصلاح/افزوده شده + ('pending', 'در انتظار پرداخت'), ('active', 'فعال'), ('expired', 'منقضی'), ('canceled', 'لغوشده'), @@ -50,19 +40,11 @@ class Subscription(models.Model): started_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField(null=True, blank=True) order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order - # 🔒 اصلاحِ رگرسیونِ concurrency در PurchaseView.post: چون Subscription - # فقط یک ردیف در ازای هر user_id داره، دو درخواستِ هم‌زمانِ خریدِ همون - # کاربر (مثلاً با همون plan) می‌تونن دقیقاً به یک وضعیتِ میانیِ یکسان - # برسن (status='pending', همون plan_id, order_id=None) — یعنی مقایسه‌ی - # مقادیرِ فیلدها (که قبلاً به‌عنوانِ CAS guard در cleanup استفاده می‌شد) - # نمی‌تونه بینِ «ردیفِ نوشته‌شده توسطِ خودِ همین درخواست» و «ردیفِ - # نوشته‌شده توسطِ یک درخواستِ هم‌زمانِ دیگه» تمایز بذاره. version یک - # شمارنده‌ی صریحه که PurchaseView.post هر بار که واقعاً روی این ردیف - # می‌نویسه (با UPDATE اتمیکِ version=F('version')+1، بدونِ نیاز به - # select_for_update/نگه‌داشتنِ قفل) افزایشش می‌ده؛ پس حتی وقتی محتوای - # semantic فیلدهای دیگه بینِ دو نویسنده‌ی هم‌زمان یکسانه، version همیشه - # بینِ نوشته‌های مجزا فرق می‌کنه و می‌تونه به‌عنوانِ CAS token قابل‌اتکا - # برای cleanup استفاده بشه. + # چون Subscription فقط یک ردیف در ازای هر user_id داره، دو خریدِ هم‌زمانِ + # همون کاربر می‌تونن به وضعیتِ میانیِ کاملاً یکسانی برسن (مقایسه‌ی + # فیلدها کافی نیست برای CAS). version با UPDATEِ اتمیکِ + # version=F('version')+1 (بدونِ select_for_update) هر نوشتنِ واقعی رو + # از نوشتنِ هم‌زمانِ دیگری تمیز می‌ده. version = models.PositiveIntegerField(default=0) def __str__(self): diff --git a/subscription/tests.py b/subscription/tests.py index 2e70d13..49ba75c 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -362,12 +362,9 @@ class PurchaseThrottleTests(TestCase): 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) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که - # باید دقیقاً مثلِ قبل دست‌نخورده بمونه. + # کاربرِ غیرمهمان لازمه، نه self.user، چون کلیدِ bucketِ مهمان‌ها + # عمداً بر اساسِ IP است (نگاه کنید به PurchaseGuestThrottleBypassTests) + # و این تست رفتارِ per-user برای ثبت‌نام‌شده‌ها را می‌سنجد. registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501') registered_token = issue_access_token(registered_user.id) diff --git a/subscription/views.py b/subscription/views.py index 0e53b31..ff716ec 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -21,27 +21,10 @@ 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 دست‌نخورده‌ست. - """ + """همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ + حساب مهمان نباید bucket تازه‌ی purchase بده — این endpoint حساسِ مالیه + (Subscription می‌سازه/آپدیت می‌کنه، create_order رو صدا می‌زنه). برای + مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk.""" def get_cache_key(self, request, view): user = request.user @@ -85,20 +68,12 @@ 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 مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون* + # کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت. کاربرهای مهمان با هر + # deviceId یک pk تازه می‌گیرن، پس کلیدِ per-user ساده قابل‌دورزدنه؛ + # GuestAwarePurchaseThrottle برای مهمان‌ها بر اساسِ IP کلید می‌سازه + # (مثلِ GuestAwareAIThrottle/FamilyThrottle/DiscountThrottle) و برای + # کاربرهای غیرمهمان رفتارِ per-user را حفظ می‌کنه. throttle_scope = 'purchase' throttle_classes = [GuestAwarePurchaseThrottle] diff --git a/support/tests.py b/support/tests.py index 144f936..d3ded87 100644 --- a/support/tests.py +++ b/support/tests.py @@ -54,12 +54,8 @@ class BugReportCreateThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که - # مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ - # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به - # SupportCreateGuestThrottleBypassTests پایین‌تر) — این تست فقط - # رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه - # که باید دقیقاً مثلِ قبل دست‌نخورده بمونه. + # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ + # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901') registered_token = issue_access_token(registered_user.id) @@ -131,11 +127,8 @@ class TicketCreateThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که - # مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان - # عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به - # SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ - # per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه. + # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ + # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902') registered_token = issue_access_token(registered_user.id) @@ -208,11 +201,8 @@ class TicketMessageCreateThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که - # مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان - # عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به - # SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ - # per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه. + # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ + # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903') registered_token = issue_access_token(registered_user.id) registered_ticket = Ticket.objects.create( diff --git a/support/views.py b/support/views.py index 316dd58..f0fafb5 100644 --- a/support/views.py +++ b/support/views.py @@ -60,12 +60,9 @@ class BugReportCreateView(APIView): """POST /api/support/bug-reports — ثبت گزارش باگ""" permission_classes = [IsAuthenticated] - # هیچ مکانیزمِ idempotency ای اینجا نیست، پس بدونِ throttle یک کاربرِ - # احرازهویت‌شده می‌تونه تعدادِ نامحدودی BugReport با درخواست‌های - # پرتکرار بسازه. ScopedRateThrottle با pk کاربر کلید می‌خوره (نه IP). - # ⚠️ اصلاح شده: چون کاربرهای مهمان با هر deviceId جدید یک pk تازه - # می‌گیرن، از GuestAwareSupportThrottle استفاده می‌شه تا چرخشِ حساب - # مهمان دیگه bucket تازه ندهد (نگاه کنید به تعریفِ کلاس بالا). + # بدونِ idempotency/throttle، یک کاربر می‌تونست تعدادِ نامحدودی BugReport + # بسازه؛ GuestAwareSupportThrottle برای مهمان‌ها کلید رو بر اساسِ IP + # می‌سازه تا چرخشِ حساب bucket تازه نده. throttle_scope = 'support_create' throttle_classes = [GuestAwareSupportThrottle]