Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

@@ -15,9 +15,11 @@
⚠️ کافه‌بازار روش صحت‌سنجی رو تغییر داده (نگاه کنید به
developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api-new)؛
قبل از اتصال واقعی به production، دقیق‌ترین مسیر endpoint را از همون صفحه یا
پنل devapi خودتون تایید کنید — این فایل فقط اسکلتِ همون جریان رسمی رو
پیاده‌سازی کرده تا بدون کلید واقعی هم (مثل call_payment_gateway در
services.py) از طریق Swagger قابل تست باشه.
پنل devapi خودتون تایید کنید.
🔒 بدون BAZAAR_REFRESH_TOKEN (کلیدهای بازار ست نشده)، verify_purchase همیشه
fail-closed عمل می‌کنه و invalid برمی‌گردونه؛ هیچ‌وقت به purchaseToken
ارسالیِ کلاینت، بدون صحت‌سنجیِ واقعی با سرور بازار، اعتماد نمی‌شه.
"""
from dataclasses import dataclass
@@ -48,10 +50,13 @@ def _get_access_token() -> str:
return cached
if not settings.BAZAAR_REFRESH_TOKEN:
# بدون کلید واقعی (مثلاً محیط توسعه)، استاب: دقیقاً مثل
# call_payment_gateway در payment/services.py، تا جریان کامل بدون
# کلید واقعی بازار هم قابل تست باشه.
return 'stub-access-token'
# بدون REFRESH_TOKEN، امکان گرفتن access_token واقعی از بازار نیست؛
# قبلاً اینجا یک 'stub-access-token' برمی‌گشت که باعث می‌شد
# verify_purchase هر purchaseToken غیرخالی رو valid فرض کنه
# (باگ fail-open). این تابع نباید در این حالت صدا زده بشه —
# verify_purchase قبل از فراخوانیِ این تابع، خودش fail-closed
# برمی‌گرده — ولی برای دفاع در عمق، اینجا هم صراحتاً خطا می‌ده.
raise RuntimeError('BAZAAR_REFRESH_TOKEN is not configured; cannot obtain a Bazaar access token.')
resp = requests.post(
f'{settings.BAZAAR_API_BASE}/auth/token/',
@@ -79,10 +84,17 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
تک‌محصولی)، طبق سند رسمی.
"""
if not settings.BAZAAR_REFRESH_TOKEN:
# استاب توسعه: هر token غیرخالی معتبر فرض می‌شه تا کل جریان
# (ساخت سفارش bazaar → verify → فعال‌سازی اشتراک) بدون کلید واقعی
# از طریق Swagger قابل تست باشه.
return BazaarPurchaseResult(is_valid=bool(purchase_token), purchase_state=0)
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ
# بازار)، امکان صحت‌سنجیِ واقعیِ purchaseToken با سرور بازار وجود
# نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت
# مستقیماً valid فرض می‌شد (باگ fail-open) که یعنی یک کلاینت
# می‌تونست با فرستادن هر مقدار غیرخالی، بدون خرید واقعی، سفارش رو
# paid کنه. حالا در نبودِ کلیدهای بازار، هیچ‌وقت به گفته‌ی کلاینت
# اعتماد نمی‌کنیم و صریحاً invalid برمی‌گردونیم.
return BazaarPurchaseResult(
is_valid=False,
raw={'error': 'bazaar_credentials_not_configured'},
)
access_token = _get_access_token()
kind = 'subscriptions' if is_subscription else 'purchases'

View File

@@ -16,15 +16,7 @@ from .models import Order, Transaction
class IdempotencyKeyConflict(APIException):
"""
⚠️ اصلاح امنیتی: قبلاً create_order فقط با idempotency_key سفارش رو
get_or_create می‌کرد، بدون بررسی اینکه صاحب سفارش موجود همون کاربرِ
درخواست‌دهنده‌ست یا پارامترها (amount/currency/subscription) یکی‌ان.
این یعنی یک کاربر می‌تونست با حدس زدن/استفاده‌ی مجدد از یک
Idempotency-Key، سفارشِ کاربر دیگه‌ای رو برگردونه/استفاده کنه، یا با
همون کلید ولی amount متفاوت، رفتار غیرمنتظره بگیره. حالا این حالت‌ها
409 Conflict برمی‌گردونن.
"""
"""اگر Idempotency-Key موجود متعلق به کاربر یا پارامترهای دیگری باشد، 409 برمی‌گرداند."""
status_code = 409
default_detail = 'این Idempotency-Key قبلاً برای درخواست دیگری استفاده شده است.'
@@ -46,12 +38,9 @@ def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currenc
def call_payment_gateway(order: Order) -> str:
"""
استاب فراخوانی درگاه پرداخت واقعی (zarinpal و غیره) — دقیقاً مثل الگوی
generate_tutor_answer در ai_tutor/views.py. فعلاً فقط یک paymentUrl
قطعی و ساختگی برمی‌گردونه تا کل جریان (ساخت سفارش → پرداخت → webhook)
بدون نیاز به کلید API واقعی درگاه، از طریق Swagger قابل تست باشه.
برای اتصال واقعی، این تابع را با فراخوانی HTTP به API درگاه (مثلاً
zarinpal.com/pg/rest/WebGate/PaymentRequest.json) جایگزین کنید.
استاب فراخوانی درگاه پرداخت واقعی؛ فعلاً یک paymentUrl ساختگی برمی‌گرداند
تا کل جریان (ساخت سفارش → پرداخت → webhook) بدون کلید API واقعی قابل تست باشد.
برای اتصال واقعی، این تابع با فراخوانی HTTP به API درگاه جایگزین شود.
"""
return f'https://payment.gateway/pay/{order.id}'
@@ -60,11 +49,9 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id: int | None = None, idempotency_key: str | None = None,
provider: str = 'gateway', bazaar_product_sku: str = ''):
"""
ساخت سفارش با پشتیبانی از Idempotency-Key (بخش ۴.۳ سند API): اگه سفارشی
با همین کلید قبلاً ساخته شده باشه، همون رکورد برگردونده می‌شه، نه یک
رکورد تکراری (مثلاً وقتی کلاینت درخواست رو به‌خاطر timeout دوباره می‌فرسته)
— اما فقط وقتی سفارشِ موجود واقعاً متعلق به همین کاربر و همین پارامترهاست
(وگرنه IdempotencyKeyConflict می‌گیره؛ نگاه کنید به توضیح بالای این فایل).
ساخت سفارش با پشتیبانی از Idempotency-Key: اگر سفارشی با همین کلید قبلاً
ساخته شده باشد، همان رکورد برگردانده می‌شود (نه رکورد تکراری)، فقط اگر
متعلق به همین کاربر و همین پارامترها باشد.
"""
if not idempotency_key:
idempotency_key = get_random_string(32)
@@ -91,7 +78,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
idempotency_key=idempotency_key,
)
except IntegrityError:
# race condition: درخواست هم‌زمان دیگه‌ای با همین کلید زودتر ساخت.
# درخواست هم‌زمان دیگری با همین کلید زودتر ساخت.
order = Order.objects.filter(idempotency_key=idempotency_key).first()
if order is None:
raise
@@ -100,11 +87,8 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id=subscription_id,
)
# برای provider='bazaar' هیچ paymentUrlی وجود نداره: کلاینت اندروید خودش
# مستقیماً با SDK بازار (IInAppBillingService) خرید رو انجام می‌ده و بعدش
# purchaseToken رو به /api/payment/bazaar/verify می‌فرسته؛ ما اینجا فقط
# سفارش رو از قبل با bazaarProductSku ثبت می‌کنیم تا verify بتونه ازش
# امنش (amount/user) رو تطبیق بده.
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً
# با SDK بازار خرید را انجام می‌دهد و purchaseToken را به verify می‌فرستد.
payment_url = None if order.provider == 'bazaar' else call_payment_gateway(order)
return order, payment_url
@@ -112,29 +96,15 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: str,
new_status: str, event: str) -> bool:
"""
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای PaymentWebhookView (درگاه
معمولی) و هم BazaarVerifyPurchaseView (پرداخت درون‌برنامه‌ای بازار).
قبلاً این منطق فقط داخل PaymentWebhookView تکرار می‌شد؛ چون بازار جریان
verify کاملاً متفاوتی داره (بدون webhook، با فراخوانی سرور-به-سرور از
کلاینت)، اما اثرات نهایی (Transaction، status، فعال‌سازی اشتراک، notify،
ثبت در statuslog) دقیقاً یکی‌ان، این تابع مشترک شده تا دو بار پیاده‌سازی
و احتمال ناهماهنگی نداشته باشیم.
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای PaymentWebhookView و هم
BazaarVerifyPurchaseView استفاده می‌شود.
idempotent: اگه Transaction مشابه (order/status/gateway_ref_id) از قبل
موجود باشه، دوباره اثر جانبی اجرا نمی‌شه. خروجی: True اگه این فراخوانی
واقعاً باعث paid شدن تازه شده باشه.
idempotent: اگر Transaction مشابه (order/status/gateway_ref_id) از قبل
موجود باشد، دوباره اثر جانبی اجرا نمی‌شود. خروجی: True اگر این فراخوانی
واقعاً باعث paid شدن تازه شده باشد.
⚛️🔒 اصلاح race condition: قبلاً already_paid/is_duplicate روی همون
آبجکتِ order ورودی محاسبه می‌شدن، و تابع کاملاً به این متکی بود که
caller از قبل select_for_update() گرفته باشه (که الان درسته، ولی
تضمین‌شده نبود — هر caller جدیدی که این فرض رو رعایت نکنه، race رو
برمی‌گردوند). حالا خودِ این تابع، صرف‌نظر از caller، ردیفِ Order رو
دوباره با select_for_update قفل و رفرش می‌کنه (تودرتو بودنِ
select_for_update با atomic بیرونی مشکلی نداره) و سپس already_paid/
is_duplicate را *بعد* از قفل محاسبه می‌کنه. به‌علاوه، ساختِ Transaction
حالا در برابر IntegrityQrror ناشیِ از UniqueConstraint سطح DB (دفاع در
عمق، مخصوصاً روی بک‌اندی مثل SQLite که select_for_update لاک واقعی
row-level نمی‌ده) هم محافظت شده.
Order زیر select_for_update قفل و رفرش می‌شود و already_paid/is_duplicate
*بعد* از قفل محاسبه می‌شوند تا در برابر فراخوانی‌های هم‌زمان ایمن باشد.
"""
with db_transaction.atomic():
order = Order.objects.select_for_update().get(pk=order.pk)
@@ -146,24 +116,12 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
order=order, status=new_status, gateway_ref_id=gateway_ref_id,
).exists()
# 🔒 اصلاح race-regression: اگه سفارش زیرِ لاک همین الان paid باشه،
# یک callback دیرهنگام/خارج از ترتیب با new_status='failed' (یا هر
# چیزی غیر از 'paid') نباید status رو به عقب برگردونه. چنین
# callbackی رو نسبت به وضعیتِ Order و اثرات جانبیِ پرداخت کاملاً
# idempotent/no-op در نظر می‌گیریم (نه تغییر status، نه Transaction
# جدید، نه سیگنال/نوتیفای). این مسیر برای انتقالِ مشروعِ
# pending/failed → paid دست نخورده باقی می‌مونه.
# callback دیرهنگام/خارج از ترتیب با status غیر از paid نباید سفارشِ
# از قبل paid شده را به عقب برگرداند؛ چنین callback ای idempotent/no-op است.
if already_paid and new_status != 'paid':
is_duplicate = True
# 🔒 اصلاح duplicate/idempotency روی reuse رفرنسِ پرداخت: اگه همین
# (gateway, gateway_ref_id) غیرخالی از قبل متعلق به یک Order دیگه
# باشه (replay/reuse یک authority یا purchaseToken برای سفارشِ
# دیگه)، این callback رو هم مثل یک duplicate/idempotent no-op در
# نظر می‌گیریم — نه status این سفارش عوض می‌شه، نه Transaction جدید
# و نه اثرات جانبی (activate_subscription/notify). این چک قبل از
# هر تغییری روی order انجام می‌شه تا حتی در بدترین حالت هم status
# این سفارش دست‌نخورده بمونه.
# reuse رفرنسِ پرداخت روی سفارش دیگر هم duplicate/no-op در نظر گرفته می‌شود.
if gateway_ref_id and Transaction.objects.filter(
gateway=gateway, gateway_ref_id=gateway_ref_id,
).exclude(order=order).exists():
@@ -174,21 +132,9 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
order.save(update_fields=['status'])
try:
# 🔒 اصلاح: قبلاً این create بدونِ هیچ savepointِ تودرتویی
# داخلِ همون transaction.atomic بیرونی (خط ۱۳۹) اجرا می‌شد.
# روی بک‌اندهایی مثل PostgreSQL، وقتی یک IntegrityError داخلِ
# یک atomic block رخ می‌ده، کلِ اون تراکنش (نه فقط همین
# کوئری) «broken»/aborted علامت می‌خوره؛ گرفتنِ exception با
# except بدونِ rollback به یک savepoint، این وضعیتِ broken رو
# پاک نمی‌کنه — یعنی هر کوئریِ بعدی در همین atomic (مثلِ
# order.save بالاتر که قبلاً commit نشده، یا status_changed/
# notify/activate_subscription پایین‌تر) با خطای دیگه‌ای
# (InFailedSqlTransaction) fail می‌کرد، نه با رفتارِ
# idempotentِ موردنظر. حالا خودِ این create داخلِ یک
# transaction.atomic تودرتو (savepoint) اجرا می‌شه: اگه
# IntegrityError بده، فقط همین savepoint rollback می‌شه و
# atomic بیرونی سالم می‌مونه، پس ادامه‌ی تابع (خطِ
# newly_paid و برگردوندنِ نتیجه) به‌درستی اجرا می‌شه.
# savepoint تودرتو: اگر IntegrityError بدهد فقط همین savepoint
# rollback می‌شود و atomic بیرونی سالم می‌ماند (مخصوصاً روی PostgreSQL
# که یک تراکنش بعد از IntegrityError به‌طور کامل broken می‌شود).
with db_transaction.atomic():
Transaction.objects.create(
order=order,
@@ -198,16 +144,9 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
paid_at=timezone.now() if new_status == 'paid' else None,
)
except IntegrityError:
# دفاع در عمق: با اینکه بالاتر لاک گرفتیم و چک کردیم، اگه
# به هر دلیلی (مثلاً DB بدون row-level lock واقعی، یا یک
# race هم‌زمانِ reuse رفرنس روی یک Order دیگه) یک Transaction
# با همون (gateway, gateway_ref_id) هم‌زمان ساخته شده باشه،
# UniqueConstraintِ سراسریِ uniq_transaction_gateway_ref_global
# جلوش رو گرفته؛ این حالت رو duplicate در نظر می‌گیریم و
# اثرات جانبی رو دوباره اجرا نمی‌کنیم. همچنین چون Transaction
# ساخته نشد، status‌ای که بالاتر روی order ست شده بود رو به
# مقدار قبلی برمی‌گردونیم تا این سفارش هیچ‌وقت با یک رفرنسِ
# رد شده paid/تغییر‌یافته باقی نمونه.
# دفاع در عمق: قید یکتاییِ سراسری (uniq_transaction_gateway_ref_global)
# جلوی ساخت Transaction تکراری با همین رفرنس را می‌گیرد؛ در این حالت
# status سفارش به مقدار قبل از تغییر برمی‌گردد.
is_duplicate = True
if order.status != old_status:
order.status = old_status

View File

@@ -1,3 +1,158 @@
from django.test import TestCase
import importlib
import os
from unittest.mock import patch
# Create your tests here.
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory
from . import bazaar
from .permissions import HasInternalServiceKey, HasWebhookSecret
class SecretDefaultsFailClosedTests(TestCase):
"""
تست‌های متمرکز روی این فیکس: INTERNAL_SERVICE_KEY و PAYMENT_WEBHOOK_SECRET
دیگه در پروداکشن (DEBUG=False) دیفالتِ قابل‌حدس‌زدن ندارن. اگه env تنظیم
نشده باشه، مقدار خالی می‌مونه و پرمیشن‌های مربوطه صراحتاً رد می‌کنن
(fail closed)، نه اینکه با یک مقدارِ شناخته‌شده قبول کنن.
"""
def setUp(self):
self.factory = APIRequestFactory()
@override_settings(DEBUG=False, INTERNAL_SERVICE_KEY='')
def test_production_missing_internal_key_rejects(self):
request = self.factory.post('/api/payment/orders')
# حتی اگه کلاینت هیچ هدری هم نفرسته یا مقدارِ خالی بفرسته، چون
# settings.INTERNAL_SERVICE_KEY خودش خالیه، درخواست باید رد بشه.
self.assertFalse(HasInternalServiceKey().has_permission(request, None))
@override_settings(DEBUG=False, PAYMENT_WEBHOOK_SECRET='')
def test_production_missing_webhook_secret_rejects(self):
request = self.factory.post('/api/payment/webhook')
self.assertFalse(HasWebhookSecret().has_permission(request, None))
@override_settings(
DEBUG=False,
INTERNAL_SERVICE_KEY='prod-internal-key-xyz',
PAYMENT_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
def test_production_configured_secrets_accepts(self):
internal_request = self.factory.post(
'/api/payment/orders', HTTP_X_INTERNAL_KEY='prod-internal-key-xyz',
)
self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None))
webhook_request = self.factory.post(
'/api/payment/webhook', HTTP_X_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
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 هنوز برقراره.
from config import settings as settings_module
env_overrides = {'DJANGO_DEBUG': '1'}
env_removed = {'INTERNAL_SERVICE_KEY', 'PAYMENT_WEBHOOK_SECRET'}
clean_env = {
k: v for k, v in os.environ.items() if k not in env_removed
}
clean_env.update(env_overrides)
try:
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.INTERNAL_SERVICE_KEY,
'dev-internal-service-key-change-me',
)
self.assertEqual(
settings_module.PAYMENT_WEBHOOK_SECRET,
'dev-webhook-secret-change-me',
)
finally:
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.
importlib.reload(settings_module)
class BazaarVerifyPurchaseFailClosedTests(TestCase):
"""
تست‌های متمرکز روی فیکسِ باگ fail-open در bazaar.verify_purchase: قبلاً
وقتی BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ بازار) ست نشده بود، هر
purchaseTokenِ غیرخالیِ ارسالی از کلاینت valid فرض می‌شد. حالا باید در
این حالت همیشه invalid برگرده، و مسیر واقعیِ صحت‌سنجی با بازار (وقتی
کلیدها ست شدن) دست‌نخورده کار کنه.
"""
def setUp(self):
# کش access_token رو بین تست‌ها پاک می‌کنیم تا یک تست روی تست بعدی
# اثر نذاره.
cache.clear()
@override_settings(BAZAAR_REFRESH_TOKEN='')
def test_missing_credentials_never_trusts_client_token(self):
# حتی با یک purchaseToken دلخواهِ غیرخالی، بدون کلیدهای بازار نباید
# valid برگرده.
result = bazaar.verify_purchase(
product_sku='some_sku', purchase_token='arbitrary-client-supplied-token',
)
self.assertFalse(result.is_valid)
@override_settings(
BAZAAR_REFRESH_TOKEN='refresh-token-xyz',
BAZAAR_CLIENT_ID='client-id',
BAZAAR_CLIENT_SECRET='client-secret',
BAZAAR_PACKAGE_NAME='ir.tadrisino.app',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
)
@patch('payment.bazaar.requests.get')
@patch('payment.bazaar.requests.post')
def test_configured_credentials_and_successful_response_is_valid(self, mock_post, mock_get):
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {
'access_token': 'real-access-token', 'expires_in': 3600,
}
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {
'purchaseState': 0, 'consumptionState': 0, 'developerPayload': '',
}
result = bazaar.verify_purchase(product_sku='sku1', purchase_token='real-token')
self.assertTrue(result.is_valid)
mock_get.assert_called_once()
_, get_kwargs = mock_get.call_args
self.assertEqual(get_kwargs['headers']['Authorization'], 'Bearer real-access-token')
@override_settings(
BAZAAR_REFRESH_TOKEN='refresh-token-xyz',
BAZAAR_CLIENT_ID='client-id',
BAZAAR_CLIENT_SECRET='client-secret',
BAZAAR_PACKAGE_NAME='ir.tadrisino.app',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
)
@patch('payment.bazaar.requests.get')
@patch('payment.bazaar.requests.post')
def test_configured_credentials_provider_reports_invalid(self, mock_post, mock_get):
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {
'access_token': 'real-access-token', 'expires_in': 3600,
}
mock_get.return_value.status_code = 200
# purchaseState=1 یعنی لغوشده (طبق سند بازار)، یعنی خودِ سرور بازار
# می‌گه این خرید معتبر نیست.
mock_get.return_value.json.return_value = {
'purchaseState': 1, 'consumptionState': 0, 'developerPayload': '',
}
result = bazaar.verify_purchase(product_sku='sku1', purchase_token='cancelled-token')
self.assertFalse(result.is_valid)

View File

@@ -7,7 +7,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from . import bazaar
from .models import Order, Transaction
from .models import Order
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .serializers import (
BazaarVerifyRequestSerializer,