let the Bazaar verify URL double as the OAuth redirect URI

The Bazaar account owner registered /api/payment/bazaar/verify as the
redirect URI. POST stays the app's purchase check; GET now takes Bazaar's
?code=, exchanges it for a refresh token and stores it in the new
BazaarCredential row, so connecting needs no SSH or .env edit.

The GET is unauthenticated, so it only acts inside a window opened from
the admin (or bazaar_authorize_link): 72 h by default, closed by the first
successful exchange, state checked when Bazaar echoes it. Authorize/token
URL shapes follow the known devapi v2 flow; not checked against Bazaar's
current docs, which did not load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 21:30:44 +03:30
parent 5d490edaf9
commit 8763ffd7bf
9 changed files with 414 additions and 9 deletions

View File

@@ -1,6 +1,8 @@
from django.contrib import admin
from django.contrib import admin, messages
from django.utils.html import format_html
from .models import Order, Transaction
from . import bazaar
from .models import BazaarCredential, Order, Transaction
class TransactionInline(admin.TabularInline):
@@ -19,3 +21,35 @@ class OrderAdmin(admin.ModelAdmin):
class TransactionAdmin(admin.ModelAdmin):
list_display = ('id', 'order', 'gateway', 'status', 'paid_at')
list_filter = ('gateway', 'status')
@admin.register(BazaarCredential)
class BazaarCredentialAdmin(admin.ModelAdmin):
"""
اتصالِ یک‌باره به API بازار، بدونِ SSH: action لینکِ «اجازه» را می‌سازد؛
صاحبِ حسابِ بازار بازش می‌کند و بازار به verify برمی‌گردد. خودِ توکن
هیچ‌جا نمایش داده نمی‌شود.
"""
list_display = ('__str__', 'authorized_at', 'pending_since')
fields = ('connected', 'authorized_at', 'pending_since')
readonly_fields = fields
actions = ['make_authorize_link']
@admin.display(boolean=True, description='توکن دارد')
def connected(self, obj):
return bool(obj.refresh_token)
def changelist_view(self, request, extra_context=None):
BazaarCredential.objects.get_or_create(pk=1)
return super().changelist_view(request, extra_context)
def has_add_permission(self, request):
return False
@admin.action(description='ساختِ لینکِ اتصال به بازار')
def make_authorize_link(self, request, queryset):
url = bazaar.start_authorization()
self.message_user(request, format_html(
'این لینک را صاحبِ حسابِ بازار باز کند و «اجازه» بزند: <a href="{0}">{0}</a>', url,
), messages.SUCCESS)

View File

@@ -3,9 +3,10 @@
رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing
جریان رسمی:
۱) یک‌بار (دستی، از پنل devapi توسعه‌دهنده) با CLIENT_ID/CLIENT_SECRET یک
authorization code گرفته می‌شه و با اون REFRESH_TOKEN ساخته می‌شه —
این مرحله در این فایل نیست چون یک‌بار و دستی‌ست، نه بخشی از API عمومی.
۱) یک‌بار: صاحبِ حسابِ بازار لینکِ start_authorization را باز می‌کند و
«اجازه» می‌زند؛ بازار با `?code=` به Redirect URI (همان
`/api/payment/bazaar/verify`، GET) برمی‌گردد و complete_authorization
آن را با REFRESH_TOKEN عوض می‌کند و در BazaarCredential نگه می‌دارد.
۲) بک‌اند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی
شده) یک ACCESS_TOKEN تازه می‌گیره (POST /auth/token با
grant_type=refresh_token).
@@ -22,11 +23,15 @@ fail-closed عمل می‌کنه و invalid برمی‌گردونه؛ هیچ‌
ارسالیِ کلاینت، بدون صحت‌سنجیِ واقعی با سرور بازار، اعتماد نمی‌شه.
"""
import logging
import secrets
from dataclasses import dataclass
from datetime import timedelta
from urllib.parse import urlencode
import requests
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from rest_framework.exceptions import APIException
logger = logging.getLogger(__name__)
@@ -53,6 +58,119 @@ class BazaarPurchaseResult:
raw: dict | None = None
def _authorize_window() -> timedelta:
# پنجره‌ی اتصال: از ساختِ لینک تا برگشتن از بازار. پیش‌فرضِ سه روز چون
# صاحبِ حسابِ بازار آدمِ دیگری است و ممکن است یکی دو روز طول بکشد
# (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۶). بعد از اتصالِ موفق پنجره خودش بسته
# می‌شود، پس بلندی‌اش فقط تا همان لحظه در معرض است.
return timedelta(hours=settings.BAZAAR_AUTHORIZE_WINDOW_HOURS)
def get_refresh_token() -> str:
"""مقدارِ `.env` اگر باشد، وگرنه آن‌چه بعد از «اجازه» در دیتابیس ذخیره شد."""
if settings.BAZAAR_REFRESH_TOKEN:
return settings.BAZAAR_REFRESH_TOKEN
from .models import BazaarCredential
cred = BazaarCredential.objects.filter(pk=1).first()
return cred.refresh_token if cred else ''
def start_authorization() -> str:
"""
پنجره‌ی اتصال را باز می‌کند و لینکی برمی‌گرداند که صاحبِ حسابِ بازار باید
باز کند و «اجازه» بزند. بازار بعدش به BAZAAR_REDIRECT_URI (همان verify)
با `?code=` برمی‌گردد.
⚠️ شکلِ این آدرس از روندِ شناخته‌شده‌ی devapi v2 است؛ سندِ فعلیِ بازار
(صفحه‌ی JS) خوانده نشد. این‌که بازار `state` را برمی‌گرداند یا نه هم
دیده نشده — برای همین complete_authorization نبودِ state را تحمل می‌کند.
"""
from .models import BazaarCredential
state = secrets.token_urlsafe(24)
BazaarCredential.objects.update_or_create(
pk=1, defaults={'pending_state': state, 'pending_since': timezone.now()},
)
query = urlencode({
'response_type': 'code',
'access_type': 'offline',
'redirect_uri': settings.BAZAAR_REDIRECT_URI,
'client_id': settings.BAZAAR_CLIENT_ID,
'state': state,
})
return f'{settings.BAZAAR_API_BASE}/auth/authorize/?{query}'
class BazaarAuthorizationError(Exception):
"""پیامش مستقیم به کسی که در مرورگر است نشان داده می‌شود؛ رازی در آن نیست."""
def complete_authorization(*, code: str, state: str = '') -> None:
"""
codeی که بازار به Redirect URI فرستاد را با refresh token عوض و ذخیره می‌کند.
فقط داخلِ پنجره‌ی باز کار می‌کند؛ اگر بازار state را برگرداند باید با
همانی که ساختیم یکی باشد. پنجره فقط با اتصالِ موفق بسته می‌شود، تا یک
درخواستِ خراب (یا عمدی با codeِ بی‌معنی) پنجره‌ی چندروزه را نسوزاند؛
تلاش‌ها با throttleِ `bazaar_oauth` محدودند.
"""
from django.db import transaction
from .models import BazaarCredential
if not (settings.BAZAAR_CLIENT_ID and settings.BAZAAR_CLIENT_SECRET):
raise BazaarAuthorizationError('کلیدهای بازار (CLIENT_ID/SECRET) روی سرور تنظیم نشده‌اند.')
cred = BazaarCredential.objects.filter(pk=1).first()
if (
not cred or not cred.pending_since
or timezone.now() - cred.pending_since > _authorize_window()
):
raise BazaarAuthorizationError('پنجره‌ی اتصال باز نیست؛ لینکِ تازه بسازید.')
if state and not secrets.compare_digest(state, cred.pending_state):
raise BazaarAuthorizationError('state با لینکی که ساخته شد یکی نیست.')
opened_at = cred.pending_since
try:
resp = requests.post(
f'{settings.BAZAAR_API_BASE}/auth/token/',
data={
'grant_type': 'authorization_code',
'code': code,
'client_id': settings.BAZAAR_CLIENT_ID,
'client_secret': settings.BAZAAR_CLIENT_SECRET,
'redirect_uri': settings.BAZAAR_REDIRECT_URI,
},
timeout=10,
)
except requests.RequestException:
logger.exception('Bazaar code exchange failed')
raise BazaarAuthorizationError('بازار در دسترس نبود؛ دوباره لینک بسازید و امتحان کنید.')
try:
data = resp.json()
except ValueError:
data = {}
refresh_token = data.get('refresh_token') if isinstance(data, dict) else None
if resp.status_code != 200 or not refresh_token:
# بدنه را لاگ نمی‌کنیم: ممکن است توکن داشته باشد.
logger.error('Bazaar code exchange rejected: status=%s keys=%s',
resp.status_code, sorted(data) if isinstance(data, dict) else '?')
raise BazaarAuthorizationError(f'بازار code را نپذیرفت (HTTP {resp.status_code}).')
# بستنِ پنجره شرطی است: از دو درخواستِ هم‌زمان فقط اولی ذخیره می‌کند.
with transaction.atomic():
stored = BazaarCredential.objects.filter(pk=1, pending_since=opened_at).update(
refresh_token=refresh_token, authorized_at=timezone.now(),
pending_state='', pending_since=None,
)
if not stored:
raise BazaarAuthorizationError('این لینک همین الان یک بار استفاده شد.')
cache.delete(ACCESS_TOKEN_CACHE_KEY)
if data.get('access_token'):
cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'],
timeout=max(int(data.get('expires_in', 3600)) - 60, 60))
logger.info('Bazaar API authorized; refresh token stored')
def _get_access_token() -> str:
"""
ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک
@@ -63,7 +181,8 @@ def _get_access_token() -> str:
if cached:
return cached
if not settings.BAZAAR_REFRESH_TOKEN:
refresh_token = get_refresh_token()
if not refresh_token:
# بدون REFRESH_TOKEN، امکان گرفتن access_token واقعی از بازار نیست؛
# قبلاً اینجا یک 'stub-access-token' برمی‌گشت که باعث می‌شد
# verify_purchase هر purchaseToken غیرخالی رو valid فرض کنه
@@ -79,7 +198,7 @@ def _get_access_token() -> str:
'grant_type': 'refresh_token',
'client_id': settings.BAZAAR_CLIENT_ID,
'client_secret': settings.BAZAAR_CLIENT_SECRET,
'refresh_token': settings.BAZAAR_REFRESH_TOKEN,
'refresh_token': refresh_token,
},
timeout=10,
)
@@ -107,7 +226,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
is_subscription=True برای اشتراک‌های دوره‌ای بازار (endpoint جدا از خرید
تک‌محصولی)، طبق سند رسمی.
"""
if not settings.BAZAAR_REFRESH_TOKEN:
if not get_refresh_token():
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ
# بازار)، امکان صحت‌سنجیِ واقعیِ purchaseToken با سرور بازار وجود
# نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت

View File

@@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from payment import bazaar
class Command(BaseCommand):
help = 'لینکِ «اجازه»ی بازار را می‌سازد (همان actionِ ادمینِ «اتصال به API بازار»).'
def handle(self, *args, **options):
self.stdout.write(bazaar.start_authorization())

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.17 on 2026-09-26 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0007_order_plan_id'),
]
operations = [
migrations.CreateModel(
name='BazaarCredential',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('refresh_token', models.TextField(blank=True)),
('authorized_at', models.DateTimeField(blank=True, null=True)),
('pending_state', models.CharField(blank=True, max_length=64)),
('pending_since', models.DateTimeField(blank=True, null=True)),
],
options={
'verbose_name': 'اتصال به API بازار',
'verbose_name_plural': 'اتصال به API بازار',
},
),
]

View File

@@ -69,3 +69,29 @@ class Transaction(models.Model):
def __str__(self):
return f"txn-{self.pk}-order{self.order_id}"
class BazaarCredential(models.Model):
"""
refresh tokenِ API بازار، که با اولین «اجازه دادن» در پنل بازار ساخته
می‌شود. در دیتابیس است نه `.env` تا گرفتنش کسی را مجبور به SSH نکند:
بازار کاربر را با `?code=` به همان `/api/payment/bazaar/verify`
برمی‌گرداند (Redirect URIای که در پنل ثبت شده) و ویو خودش code را عوض
می‌کند. یک ردیف بیشتر ندارد (pk=1).
`pending_state`/`pending_since` پنجره‌ی اتصال است: GETِ بی‌پنجره هیچ
کاری نمی‌کند، تا کسی نتواند با codeِ حسابِ بازارِ خودش توکنِ ما را
عوض کند. `BAZAAR_REFRESH_TOKEN` در `.env`، اگر باشد، بر این ردیف مقدم است.
"""
refresh_token = models.TextField(blank=True)
authorized_at = models.DateTimeField(null=True, blank=True)
pending_state = models.CharField(max_length=64, blank=True)
pending_since = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = 'اتصال به API بازار'
verbose_name_plural = 'اتصال به API بازار'
def __str__(self):
return 'متصل' if self.refresh_token else 'متصل نیست'

View File

@@ -0,0 +1,124 @@
from datetime import timedelta
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from . import bazaar
from .models import BazaarCredential
URL = '/api/payment/bazaar/verify'
@override_settings(
BAZAAR_CLIENT_ID='cid', BAZAAR_CLIENT_SECRET='csecret', BAZAAR_REFRESH_TOKEN='',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
BAZAAR_REDIRECT_URI='https://api.example/api/payment/bazaar/verify',
)
class BazaarOAuthCallbackTests(TestCase):
"""GETِ verify همان Redirect URIِ بازار است؛ POSTش خریدِ اپ."""
def setUp(self):
cache.clear()
self.client = APIClient()
def _token_response(self, mock_post, status=200, body=None):
mock_post.return_value.status_code = status
mock_post.return_value.json.return_value = body if body is not None else {
'access_token': 'acc', 'refresh_token': 'ref-123', 'expires_in': 3600,
}
def test_authorize_link_points_back_to_verify(self):
q = parse_qs(urlsplit(bazaar.start_authorization()).query)
self.assertEqual(q['redirect_uri'], ['https://api.example/api/payment/bazaar/verify'])
self.assertEqual(q['client_id'], ['cid'])
self.assertEqual(q['state'], [BazaarCredential.objects.get(pk=1).pending_state])
@patch('payment.bazaar.requests.post')
def test_code_is_exchanged_and_stored(self, mock_post):
self._token_response(mock_post)
state = parse_qs(urlsplit(bazaar.start_authorization()).query)['state'][0]
resp = self.client.get(URL, {'code': 'the-code', 'state': state})
self.assertEqual(resp.status_code, 200)
self.assertNotIn('ref-123', resp.content.decode())
sent = mock_post.call_args.kwargs['data']
self.assertEqual(sent['grant_type'], 'authorization_code')
self.assertEqual(sent['code'], 'the-code')
self.assertEqual(sent['redirect_uri'], 'https://api.example/api/payment/bazaar/verify')
self.assertEqual(bazaar.get_refresh_token(), 'ref-123')
cred = BazaarCredential.objects.get(pk=1)
self.assertEqual(cred.pending_state, '')
@patch('payment.bazaar.requests.post')
def test_without_open_window_nothing_is_called(self, mock_post):
resp = self.client.get(URL, {'code': 'attacker-code'})
self.assertEqual(resp.status_code, 400)
mock_post.assert_not_called()
self.assertEqual(bazaar.get_refresh_token(), '')
@patch('payment.bazaar.requests.post')
def test_expired_window_is_refused(self, mock_post):
bazaar.start_authorization()
BazaarCredential.objects.filter(pk=1).update(
pending_since=timezone.now() - timedelta(days=4),
)
self.assertEqual(self.client.get(URL, {'code': 'c'}).status_code, 400)
mock_post.assert_not_called()
@patch('payment.bazaar.requests.post')
def test_wrong_state_is_refused_and_closes_nothing_new(self, mock_post):
bazaar.start_authorization()
self.assertEqual(self.client.get(URL, {'code': 'c', 'state': 'forged'}).status_code, 400)
mock_post.assert_not_called()
@patch('payment.bazaar.requests.post')
def test_window_is_single_use(self, mock_post):
self._token_response(mock_post)
bazaar.start_authorization()
self.assertEqual(self.client.get(URL, {'code': 'c1'}).status_code, 200)
self.assertEqual(self.client.get(URL, {'code': 'c2'}).status_code, 400)
self.assertEqual(mock_post.call_count, 1)
@patch('payment.bazaar.requests.post')
def test_rejected_code_stores_nothing_and_keeps_window_open(self, mock_post):
self._token_response(mock_post, status=400, body={'error': 'invalid_grant'})
bazaar.start_authorization()
self.assertEqual(self.client.get(URL, {'code': 'bad'}).status_code, 400)
self.assertEqual(bazaar.get_refresh_token(), '')
self._token_response(mock_post)
self.assertEqual(self.client.get(URL, {'code': 'good'}).status_code, 200)
self.assertEqual(bazaar.get_refresh_token(), 'ref-123')
@patch('payment.bazaar.requests.post')
def test_two_days_later_the_link_still_works(self, mock_post):
self._token_response(mock_post)
bazaar.start_authorization()
BazaarCredential.objects.filter(pk=1).update(
pending_since=timezone.now() - timedelta(days=2),
)
self.assertEqual(self.client.get(URL, {'code': 'c'}).status_code, 200)
def test_bazaar_error_param_is_reported(self):
self.assertEqual(self.client.get(URL, {'error': 'access_denied'}).status_code, 400)
def test_post_still_requires_auth(self):
resp = self.client.post(URL, {'orderId': '1', 'purchaseToken': 't'}, format='json')
self.assertIn(resp.status_code, (401, 403))
@patch('payment.bazaar.requests.get')
@patch('payment.bazaar.requests.post')
def test_stored_token_is_used_for_purchase_verification(self, mock_post, mock_get):
BazaarCredential.objects.create(pk=1, refresh_token='db-ref')
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {'access_token': 'acc', 'expires_in': 3600}
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {'purchaseState': 0}
self.assertTrue(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid)
self.assertEqual(mock_post.call_args.kwargs['data']['refresh_token'], 'db-ref')

View File

@@ -2,7 +2,8 @@ from urllib.parse import urlsplit
from django.conf import settings
from django.db import transaction
from django.http import HttpResponseRedirect
from django.http import HttpResponse, HttpResponseRedirect
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
@@ -219,9 +220,24 @@ class ZarinpalCallbackView(APIView):
return Response(ZarinpalCallbackResponseSerializer(data).data)
def _bazaar_oauth_page(message: str, *, status: int) -> HttpResponse:
# این را صاحبِ حسابِ بازار در مرورگر می‌بیند، نه اپ؛ پس HTML ساده.
return HttpResponse(
'<!doctype html><meta charset="utf-8"><title>اتصال به بازار</title>'
f'<p dir="rtl" style="font:16px sans-serif;margin:2em">{escape(message)}</p>',
status=status,
)
class BazaarVerifyPurchaseView(APIView):
"""
POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار
GET /api/payment/bazaar/verify?code=… — Redirect URIِ OAuthِ بازار
همین آدرس در پنلِ بازار Redirect URI ثبت شده (خواستِ مدیرِ حساب،
۲۰۲۶-۰۹-۲۶)، پس دو کار دارد که با متد از هم جدا می‌شوند: GET را مرورگرِ
صاحبِ حساب بعد از «اجازه» می‌زند و بی‌احراز است (فقط داخلِ پنجره‌ای که
start_authorization باز کرده اثر دارد)؛ POST را اپِ اندروید بعد از خرید.
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید می‌شه، در پرداخت
درون‌برنامه‌ای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
@@ -241,6 +257,33 @@ class BazaarVerifyPurchaseView(APIView):
throttle_scope = 'bazaar_verify'
throttle_classes = [GuestAwareBazaarThrottle]
def get_permissions(self):
if self.request.method == 'GET':
return [AllowAny()]
return super().get_permissions()
def get_throttles(self):
if self.request.method == 'GET':
self.throttle_scope = 'bazaar_oauth'
return super().get_throttles()
@extend_schema(exclude=True)
def get(self, request):
if request.query_params.get('error'):
return _bazaar_oauth_page(
f'بازار اجازه نداد: {request.query_params.get("error")}', status=400,
)
code = request.query_params.get('code', '')
if not code:
return _bazaar_oauth_page(
'این آدرس برای برگشت از بازار است و بدون code کاری نمی‌کند.', status=400,
)
try:
bazaar.complete_authorization(code=code, state=request.query_params.get('state', ''))
except bazaar.BazaarAuthorizationError as exc:
return _bazaar_oauth_page(str(exc), status=400)
return _bazaar_oauth_page('اتصال به بازار انجام شد. می‌توانید این صفحه را ببندید.', status=200)
@extend_schema(
request=BazaarVerifyRequestSerializer,
responses=BazaarVerifyResponseSerializer,