Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools

- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
  without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
  existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
  with the tests that hold them. Comments describe current behaviour, not history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 21:12:28 +03:30
parent b3f624a512
commit 645baf846b
22 changed files with 584 additions and 193 deletions

View File

@@ -5,6 +5,7 @@
پیاده‌سازی شده، نه یک HTTP call به خودمون.
"""
from django.conf import settings
from django.core import signing
from django.db import IntegrityError
from django.db import transaction as db_transaction
from django.utils import timezone
@@ -53,7 +54,29 @@ def call_payment_gateway(order: Order) -> str:
باری که کاربر واقعاً این URL را باز کند زده می‌شود (نگاه کنید به
payment/zarinpal.py و PaymentRedirectView در views.py).
"""
return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay'
return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay?t={pay_link_token(order.id)}'
# لینکِ پرداخت بی‌توکنِ ورود باز می‌شود (مرورگر هدرِ Bearer نمی‌فرستد) و شناسه‌ی سفارش ترتیبی
# است؛ امضای کوتاه‌عمر جلوی این را می‌گیرد که غریبه با پیمایشِ شناسه‌ها روی سفارشِ دیگران
# authority بسازد (و کدِ تخفیفِ صاحبش را ببندد) یا وضعیتِ سفارششان را از ریدایرکت بخواند.
# امضا با همان DJANGO_SECRET_KEY است؛ تا وقتی آن کلید روی سرور همان کلیدِ داخلِ ریپوست،
# این امضا هم برای کسی که ریپو را دیده جعل‌شدنی است. یک روز: لینک بلافاصله بعد از ساختِ
# سفارش باز می‌شود؛ یک روز صفحه‌ای را هم که باز مانده پوشش می‌دهد.
PAY_LINK_SALT = 'tadrisino-pay-link'
PAY_LINK_MAX_AGE_SECONDS = 24 * 3600
def pay_link_token(order_id: int) -> str:
return signing.TimestampSigner(salt=PAY_LINK_SALT).sign(str(order_id))
def pay_link_is_valid(order_id: int, token: str) -> bool:
try:
value = signing.TimestampSigner(salt=PAY_LINK_SALT).unsign(token, max_age=PAY_LINK_MAX_AGE_SECONDS)
except signing.BadSignature: # SignatureExpired هم زیرکلاسِ همین است
return False
return value == str(order_id)
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',

View File

@@ -1,5 +1,6 @@
import importlib
import os
import time
from unittest.mock import MagicMock, patch
from django.core.cache import cache
@@ -14,7 +15,7 @@ from . import bazaar, zarinpal
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
from .permissions import HasInternalServiceKey
from .services import call_payment_gateway
from .services import PAY_LINK_MAX_AGE_SECONDS, call_payment_gateway, pay_link_token
class SecretDefaultsFailClosedTests(TestCase):
@@ -467,7 +468,13 @@ class CallPaymentGatewayTests(TestCase):
url = call_payment_gateway(order)
self.assertEqual(url, f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay')
self.assertTrue(url.startswith(f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay?t='))
def _pay_path(order):
"""همان paymentUrlی که کلاینت می‌گیرد، بی‌دامنه."""
url = call_payment_gateway(order)
return url[url.index('/api/'):]
class PaymentRedirectViewTests(TestCase):
@@ -495,14 +502,14 @@ class PaymentRedirectViewTests(TestCase):
mock_request_payment.return_value = zarinpal.ZarinpalRequestResult(
authority='A-ref', payment_url='https://www.zarinpal.com/pg/StartPay/A-ref',
)
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Referrer-Policy'], 'strict-origin-when-cross-origin')
self.assertEqual(self.client.get('/api/health')['Referrer-Policy'], 'same-origin')
@override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='')
def test_unconfigured_gateway_returns_503(self):
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 503)
@override_settings(
@@ -515,7 +522,7 @@ class PaymentRedirectViewTests(TestCase):
authority='A-first', payment_url='https://www.zarinpal.com/pg/StartPay/A-first',
)
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-first')
@@ -532,7 +539,7 @@ class PaymentRedirectViewTests(TestCase):
self.order.gateway_authority = 'A-existing'
self.order.save(update_fields=['gateway_authority'])
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-existing')
@@ -543,13 +550,35 @@ class PaymentRedirectViewTests(TestCase):
self.order.status = 'paid'
self.order.save(update_fields=['status'])
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302)
mock_request_payment.assert_not_called()
@patch('payment.views.zarinpal.request_payment')
def test_link_without_or_with_foreign_signature_is_404(self, mock_request_payment):
# شناسه‌ی سفارش ترتیبی است؛ غریبه نباید با پیمایشِ /orders/<n>/pay روی سفارشِ دیگران
# authority بسازد یا وضعیتش را از ریدایرکت بخواند.
other = Order.objects.create(
user_id=2, amount=100000, currency='IRT', provider='gateway', idempotency_key='pay-view-other',
)
for path in (
f'/api/payment/orders/{self.order.id}/pay',
f'/api/payment/orders/{self.order.id}/pay?t={pay_link_token(other.id)}',
f'/api/payment/orders/{self.order.id}/pay?t=garbage',
):
self.assertEqual(self.client.get(path).status_code, 404, path)
mock_request_payment.assert_not_called()
self.order.refresh_from_db()
self.assertEqual(self.order.gateway_authority, '')
def test_expired_link_is_404(self):
path = _pay_path(self.order)
with patch('django.core.signing.time.time', return_value=time.time() + PAY_LINK_MAX_AGE_SECONDS + 5):
self.assertEqual(self.client.get(path).status_code, 404)
def test_unknown_order_is_404(self):
response = self.client.get('/api/payment/orders/999999/pay')
response = self.client.get(f'/api/payment/orders/999999/pay?t={pay_link_token(999999)}')
self.assertEqual(response.status_code, 404)
def test_bazaar_order_is_404_on_gateway_pay_endpoint(self):
@@ -557,7 +586,7 @@ class PaymentRedirectViewTests(TestCase):
user_id=1, amount=100000, provider='bazaar',
bazaar_product_sku='sku', idempotency_key='pay-view-bazaar-1',
)
response = self.client.get(f'/api/payment/orders/{bazaar_order.id}/pay')
response = self.client.get(_pay_path(bazaar_order))
self.assertEqual(response.status_code, 404)
@@ -690,7 +719,7 @@ class PaymentReturnsToTheAppTests(TestCase):
status='paid', idempotency_key='ret-2',
)
response = self.client.get(f'/api/payment/orders/{order.id}/pay')
response = self.client.get(_pay_path(order))
self.assertEqual(response.status_code, 302)
self.assertEqual(

View File

@@ -2,7 +2,7 @@ from urllib.parse import urlsplit
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse, HttpResponseRedirect
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.utils.html import escape
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
@@ -25,7 +25,7 @@ from .serializers import (
OrderDetailResponseSerializer,
ZarinpalCallbackResponseSerializer,
)
from .services import apply_order_payment_result, create_order, reconcile_pending_order
from .services import apply_order_payment_result, create_order, pay_link_is_valid, reconcile_pending_order
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
@@ -142,10 +142,10 @@ class PaymentRedirectView(APIView):
(مثلاً دکمه‌ی back مرورگر) همان authority قبلی را دوباره استفاده
می‌کند، نه یک PaymentRequest تازه.
⚠️ AllowAny است و مالکیتِ سفارش را نمی‌سنجد (مرورگر هدرِ Bearer نمی‌فرستد)؛
شناسه‌ی سفارش ترتیبی است. هر کسی با باز کردنِ `/orders/<n>/pay` برای سفارشِ
دیگران authority می‌سازد و از آن به بعد صاحبِ سفارش دیگر نمی‌تواند کدِ
تخفیف بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد).
AllowAny است چون مرورگر هدرِ Bearer نمی‌فرستد؛ به‌جایش پارامترِ `t`، امضای
کوتاه‌عمری که فقط در paymentUrlِ صاحبِ سفارش می‌آید (payment/services.py،
pay_link_token)، سنجیده می‌شود. بی‌امضا یا امضای سفارشِ دیگر همان ۴۰۴ِ سفارشِ
ناموجود را می‌گیرد تا وجودِ شناسه هم لو نرود.
"""
permission_classes = [AllowAny]
@@ -164,11 +164,17 @@ class PaymentRedirectView(APIView):
return response
@extend_schema(
parameters=[OpenApiParameter(
't', OpenApiTypes.STR, required=True,
description='امضای همراهِ paymentUrl؛ کلاینت خودش نمی‌سازد، همان آدرسِ کامل را باز می‌کند.',
)],
responses={302: OpenApiTypes.NONE},
description='به درگاهِ زرین‌پال ریدایرکت می‌کند؛ بدنه‌ی JSON ندارد.',
tags=['Payment (payment)'],
)
def get(self, request, order_id):
if not pay_link_is_valid(order_id, request.query_params.get('t', '')):
raise Http404
order = get_object_or_404(Order, pk=order_id, provider='gateway')
if order.status != 'pending':