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:
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user