Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks

- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 04:52:25 +03:30
parent 645baf846b
commit a973c41db2
36 changed files with 944 additions and 85 deletions

View File

@@ -25,7 +25,7 @@ class OrderAdmin(admin.ModelAdmin):
inlines = [TransactionInline]
actions = ['cancel_pending', 'refund']
@admin.action(description='لغوِ سفارشِ در انتظار')
@admin.action(description='لغوِ سفارشِ در انتظار', permissions=['change'])
def cancel_pending(self, request, queryset):
from .services import OrderStateError, cancel_pending_order
@@ -39,7 +39,7 @@ class OrderAdmin(admin.ModelAdmin):
if done:
self.message_user(request, f'{done} سفارش لغو شد.', messages.SUCCESS)
@admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداخت‌شده)')
@admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداخت‌شده)', permissions=['change'])
def refund(self, request, queryset):
from django.template.response import TemplateResponse
@@ -97,7 +97,7 @@ class BazaarCredentialAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
return False
@admin.action(description='ساختِ لینکِ اتصال به بازار')
@admin.action(description='ساختِ لینکِ اتصال به بازار', permissions=['change'])
def make_authorize_link(self, request, queryset):
url = bazaar.start_authorization()
self.message_user(request, format_html(

View File

@@ -295,8 +295,9 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
try:
resp = requests.get(url, headers=_auth_headers(), timeout=10)
except requests.RequestException:
logger.exception('Bazaar verify-purchase request failed')
except requests.RequestException as exc:
# فقط نوعِ خطا: متنِ خطا آدرس را دارد و توکنِ خریدِ کاربر بخشی از آن است.
logger.error('Bazaar verify-purchase request failed: error=%s', type(exc).__name__)
raise BazaarAPIError()
try:
return resp.status_code, resp.json()

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-10-05 01:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0010_order_cancel_refund'),
]
operations = [
migrations.AddField(
model_name='order',
name='duration_days',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='order',
name='hearts_days',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='order',
name='question_credits',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='transaction',
name='gateway_ref_id',
field=models.CharField(blank=True, max_length=300),
),
]

View File

@@ -30,6 +30,12 @@ class Order(models.Model):
# پیش از ۲۰۲۶-۱۰-۰۱ این دو را ندارند (null / خالی).
list_price = models.PositiveIntegerField(null=True, blank=True)
plan_title = models.CharField(max_length=50, blank=True)
# آنچه این سفارش فروخته، در لحظه‌ی ساخت (create_order). پرداخت و بازپرداخت همین‌ها را می‌دهند و
# پس می‌گیرند، نه عددهای فعلیِ پلن: پیش‌تر اگر ادمین پلن را بینِ ساختِ سفارش و پرداخت عوض می‌کرد،
# خریدارِ بسته‌ی ۴۵ هزار تومانی ۱۵۰ سوال می‌گرفت. null یعنی سفارشِ بی‌پلن یا ساخته‌شده پیش از این ستون.
question_credits = models.PositiveIntegerField(null=True, blank=True)
hearts_days = models.PositiveIntegerField(null=True, blank=True)
duration_days = models.PositiveIntegerField(null=True, blank=True)
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway')
@@ -57,7 +63,8 @@ class Order(models.Model):
class Transaction(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions')
gateway = models.CharField(max_length=30, default='zarinpal')
gateway_ref_id = models.CharField(max_length=100, blank=True)
# ۳۰۰ مثلِ Order.bazaar_purchase_token: توکنِ خریدِ بازار همین‌جا می‌نشیند.
gateway_ref_id = models.CharField(max_length=300, blank=True)
status = models.CharField(max_length=30)
paid_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -100,12 +100,18 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
from subscription.models import Plan
plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
try:
with db_transaction.atomic():
order = Order.objects.create(
user_id=user_id,
subscription_id=subscription_id,
plan_id=plan_id,
question_credits=plan.question_credits if plan else None,
hearts_days=plan.hearts_days if plan else None,
duration_days=plan.duration_days if plan else None,
amount=amount,
list_price=amount,
plan_title=plan_title[:50],
@@ -206,7 +212,8 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
if newly_paid:
if order.subscription_id:
from subscription.services import activate_subscription
activate_subscription(order.subscription_id, plan_id=order.plan_id)
activate_subscription(order.subscription_id, plan_id=order.plan_id,
duration_days=order.duration_days)
elif order.plan_id:
# بسته‌ی سوال: اعتبار و روزهای قلبِ نامحدود، بی‌دست زدن به اشتراک.
from subscription.services import grant_pack
@@ -314,7 +321,7 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
if order.subscription_id:
sub = Subscription.objects.select_for_update().filter(pk=order.subscription_id).first()
days = plan.duration_days if plan else 30
days = order.duration_days if order.duration_days is not None else (plan.duration_days if plan else 30)
if sub is not None and sub.expires_at is not None:
sub.expires_at = max(sub.expires_at - timezone.timedelta(days=days), now)
if sub.expires_at <= now:
@@ -331,12 +338,13 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
if take:
QuestionCredit.objects.create(user_id=order.user_id, delta=-take, reason='refund',
order_id=order.id, note=f'بازپرداختِ سفارشِ {order.id}')
if plan is not None and plan.hearts_days:
hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0)
if hearts_days:
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
if hp is not None:
hp.expires_at = max(hp.expires_at - timezone.timedelta(days=plan.hearts_days), now)
hp.expires_at = max(hp.expires_at - timezone.timedelta(days=hearts_days), now)
hp.save(update_fields=['expires_at'])
summary['hearts_days'] = plan.hearts_days
summary['hearts_days'] = hearts_days
from affiliate.models import Conversion
Conversion.objects.filter(order_id=order.id, status='paid').update(

View File

@@ -46,7 +46,7 @@ class SecretDefaultsFailClosedTests(TestCase):
)
self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None))
def test_debug_mode_local_fallback_still_valid(self):
def test_debug_mode_has_no_default_internal_key(self):
# settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
# منطق باید خودِ ماژول رو reload کرد.
@@ -63,10 +63,8 @@ class SecretDefaultsFailClosedTests(TestCase):
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',
)
# رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ی بازِ روی شبکه را به ingest راه می‌داد.
self.assertEqual(settings_module.INTERNAL_SERVICE_KEY, '')
finally:
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.

View File

@@ -0,0 +1,120 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (پرداخت). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود.
"""
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from discount.models import DiscountCode
from subscription.models import Plan
from subscription.services import credit_balance
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
from .services import apply_order_payment_result
def client_for(user):
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return c
class _Base(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120009901', is_guest=False)
self.app = client_for(self.user)
self.pack = Plan.objects.get(kind='pack', question_credits=20)
def tearDown(self):
cache.clear()
def pack_order(self, **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), **extra}, format='json')
return Order.objects.get(pk=r.json()['orderId'])
class SupervisorCannotRunMoneyActionsTests(_Base):
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بی‌permissions برای هر بیننده‌ی فهرست اجرا می‌شد."""
def setUp(self):
super().setUp()
call_command('setup_supervisor_group', verbosity=0)
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
staff.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(staff)
def test_refund_is_refused(self):
order = self.pack_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', new_status='paid', event='t')
self.client.post('/admin/payment/order/', {'action': 'refund', '_selected_action': [order.pk], 'confirm': 'yes'})
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
def test_cancel_is_refused(self):
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-c1')
self.client.post('/admin/payment/order/', {'action': 'cancel_pending', '_selected_action': [order.pk]})
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class CallbackWithoutAuthorityTests(_Base):
def test_missing_authority_is_400_and_touches_no_order(self):
DiscountCode.objects.create(code='SEC50', type='percent', value=50, max_uses=10)
order = self.pack_order()
self.assertEqual(self.app.post('/api/discount/redeem', {'code': 'SEC50', 'orderId': str(order.id)},
format='json').status_code, 200)
Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-b') # دومین سفارشِ بازنشده
resp = APIClient().get('/api/payment/zarinpal/callback', {'Status': 'NOK'})
self.assertEqual(resp.status_code, 400)
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class PlanEditedBetweenOrderAndPaymentTests(_Base):
def test_buyer_gets_what_the_order_sold(self):
order = self.pack_order()
Plan.objects.filter(pk=self.pack.pk).update(price=order.amount * 5, question_credits=150, hearts_days=60)
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertEqual(order.question_credits, 20)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarSecondTokenOnPaidOrderTests(_Base):
def setUp(self):
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
def test_second_token_is_409_and_stays_usable(self):
order = self.pack_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True, purchase_state=0)
with patch('payment.bazaar.verify_purchase', return_value=valid):
first = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
again = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
second = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(first.json()['status'], 'paid')
self.assertEqual(again.status_code, 200) # همان توکن دوباره: بی‌اثر و بی‌خطا
self.assertEqual(second.status_code, 409)
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOKEN-2').exists())
r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY='sec-new')
with patch('payment.bazaar.verify_purchase', return_value=valid):
fresh = self.app.post('/api/payment/bazaar/verify',
{'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(fresh.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 40)

View File

@@ -7,6 +7,7 @@ from django.utils.html import escape
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
@@ -107,7 +108,7 @@ class CreateOrderView(APIView):
if v.get('planId'):
from subscription.models import Plan
plan = Plan.objects.filter(pk=v['planId']).first() if str(v['planId']).isdigit() else None
plan = Plan.objects.filter(pk=v['planId']).first() if (str(v['planId']).isascii() and str(v['planId']).isdigit()) else None
if plan is None:
raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'})
@@ -225,6 +226,10 @@ class ZarinpalCallbackView(APIView):
def get(self, request):
authority = request.query_params.get('Authority', '')
status_param = request.query_params.get('Status', '')
# بی‌Authority، جستجو هر سفارشی را می‌گرفت که صفحه‌ی پرداختش هنوز باز نشده (authority خالی):
# یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد.
if not authority:
raise ValidationError({'Authority': 'لازم است.'})
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
if status_param != 'OK':
@@ -338,6 +343,8 @@ class BazaarVerifyPurchaseView(APIView):
# می‌شد؛ یعنی توکنِ خریدِ ارزان‌ترین محصول سفارشِ گران‌ترین پلن را «paid» می‌کرد.
if not order_lookup.bazaar_product_sku:
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
return self._second_purchase_response()
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'],
@@ -349,6 +356,8 @@ class BazaarVerifyPurchaseView(APIView):
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
)
if self._second_purchase_on_paid_order(order, v['purchaseToken']):
return self._second_purchase_response()
new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
@@ -367,6 +376,20 @@ class BazaarVerifyPurchaseView(APIView):
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
return Response(BazaarVerifyResponseSerializer(data).data)
@staticmethod
def _second_purchase_on_paid_order(order, purchase_token: str) -> bool:
"""
خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «paid» بود، چیزی داده
نمی‌شد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود.
"""
return (order.status == 'paid' and bool(order.bazaar_purchase_token)
and order.bazaar_purchase_token != purchase_token)
@staticmethod
def _second_purchase_response():
return Response({'detail': 'این سفارش با خریدِ دیگری پرداخت شده است؛ برای این خرید سفارشِ تازه بساز.'},
status=status.HTTP_409_CONFLICT)
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""