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:
@@ -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 غیرخالیِ ارسالی از کلاینت
|
||||
|
||||
Reference in New Issue
Block a user