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:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
33
payment/migrations/0011_order_plan_snapshot.py
Normal file
33
payment/migrations/0011_order_plan_snapshot.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمیگردونیم تا روی
|
||||
# بقیهی تستها اثر نذاره.
|
||||
|
||||
120
payment/tests_security_review.py
Normal file
120
payment/tests_security_review.py
Normal 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)
|
||||
@@ -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} — وضعیت سفارش"""
|
||||
|
||||
Reference in New Issue
Block a user