diff --git a/CLAUDE.md b/CLAUDE.md index 2dab0ac..675c624 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -420,11 +420,11 @@ anyone who can read this repo can forge a token for any user.** ## وضعیت ۲۰۲۶-۰۹-۲۶ — اتصالِ API بازار -`8763ffd` روی سرور است (ری‌استارت ۲۱:۳۳، migration `payment.0008`). Client ID/Secretِ بازار +`bdd5aba` روی سرور است (ری‌استارت ۲۲:۰۱، migration `payment.0008`؛ `/api/health` → `integrations.bazaar`). Client ID/Secretِ بازار در `.env` سرور است (بکاپِ پیشش: `.deploy-backups/env-before-bazaar-20260926-213108`). Redirect URIِ ثبت‌شده در پنلِ بازار خودِ `/api/payment/bazaar/verify` است: GET کدِ بازار را -می‌گیرد، POST خریدِ اپ. لینکِ «اجازه» ۲۶ سپتامبر ۲۱:۳۳ ساخته شد و ۷۲ ساعت باز است؛ تا -صاحبِ حسابِ بازار بازش نکند refresh token نداریم. **هنوز نداریم:** اسمِ پکیجِ اپ (کد حدسِ +می‌گیرد، POST خریدِ اپ. لینکِ «اجازه» ۲۶ سپتامبر ۲۱:۳۳ ساخته شد، ۷۲ ساعت باز است و همان شب برای سنیور فرستاده شد؛ +تا صاحبِ حسابِ بازار تأیید نزند refresh token نداریم (`bazaar: false`). **هنوز نداریم:** اسمِ پکیجِ اپ (کد حدسِ `ir.tadrisino.app` را دارد)، SKUِ پلن‌ها، و اپِ اندرویدیِ دارای Poolakey. آدرسِ سنجشِ خرید با سندِ فعلیِ بازار مقایسه شد و **هر دو آدرسِ سنجشِ خرید غلط بودند** (یعنی هیچ خریدِ واقعی‌ای تأیید نمی‌شد)؛ درست شدند، به‌علاوه‌ی روشِ جدیدِ بازار (توکنِ ثابتِ پیشخان در diff --git a/accounts/services.py b/accounts/services.py index 7bbe38c..1744d71 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -76,6 +76,27 @@ def send_sms(*, phone: str, message: str) -> None: می‌زنه، و پاسخ رو اعتبارسنجی می‌کنه. کلیدِ API هیچ‌وقت داخل کد نیست؛ همیشه از env خونده می‌شه (settings.KAVENEGAR_API_KEY). """ + payload = {'receptor': phone, 'message': message} + if settings.KAVENEGAR_SENDER: + payload['sender'] = settings.KAVENEGAR_SENDER + _kavenegar_post(phone=phone, method='sms/send.json', payload=payload) + + +def send_verify_lookup(*, phone: str, template: str, token: str) -> None: + """ + ارسال با الگوی تأییدشده‌ی کاوه‌نگار (verify/lookup) — متن را الگوی پنل می‌سازد + و ما فقط token را می‌فرستیم. کاوه‌نگارِ این حساب پیامکِ متنِ آزاد را از خطِ + پیش‌فرض لغو می‌کند (وضعیتِ ۱۳، ۲۰۲۶-۰۹-۲۵) و کدِ ورود را فقط از این راه می‌پذیرد. + token نباید فاصله داشته باشد (قاعده‌ی کاوه‌نگار)؛ کدِ OTP شش رقمِ پیوسته است. + """ + _kavenegar_post( + phone=phone, method='verify/lookup.json', + payload={'receptor': phone, 'template': template, 'token': token}, + ) + + +def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None: + """فراخوانیِ مشترکِ کاوه‌نگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است.""" if not settings.KAVENEGAR_API_KEY: logger.error( 'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)', @@ -83,13 +104,9 @@ def send_sms(*, phone: str, message: str) -> None: ) raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.') - payload = {'receptor': phone, 'message': message} - if settings.KAVENEGAR_SENDER: - payload['sender'] = settings.KAVENEGAR_SENDER - try: response = requests.post( - f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/sms/send.json', + f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}', data=payload, timeout=5, ) @@ -146,7 +163,8 @@ def send_otp_sms(*, phone: str, code: str) -> None: واقعی‌ای نمی‌فرسته — چون در این محیط، RequestOTPView خودِ کد رو در فیلدِ devOtp پاسخ برمی‌گردونه و SMS واقعی لازم نیست (رفتار قبلی dev حفظ شده). - - DEBUG=False (production): send_sms صدا زده می‌شه؛ هر SMSDeliveryError + - DEBUG=False (production): اگر KAVENEGAR_OTP_TEMPLATE تنظیم باشد با الگو + (send_verify_lookup)، وگرنه متنِ آزادِ OTP_SMS_TEMPLATE با send_sms؛ هر SMSDeliveryError به OTPDeliveryError نگاشت می‌شه تا رفتار/نوعِ استثنایی که RequestOTPView انتظارش رو داره حفظ بمونه (کاربر پاسخ ۵۰۳ صریح می‌گیره، نه یک success دروغین). @@ -158,9 +176,11 @@ def send_otp_sms(*, phone: str, code: str) -> None: ) return - message = settings.OTP_SMS_TEMPLATE.format(code=code) try: - send_sms(phone=phone, message=message) + if settings.KAVENEGAR_OTP_TEMPLATE: + send_verify_lookup(phone=phone, template=settings.KAVENEGAR_OTP_TEMPLATE, token=code) + else: + send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code)) except SMSDeliveryError as exc: raise OTPDeliveryError(str(exc)) from exc diff --git a/accounts/tests_otp_limits.py b/accounts/tests_otp_limits.py index 5f2867a..10da464 100644 --- a/accounts/tests_otp_limits.py +++ b/accounts/tests_otp_limits.py @@ -128,3 +128,39 @@ class ProviderLoggingTests(TestCase): joined = '\n'.join(logs.output) self.assertIn('messageid=8792343', joined) self.assertNotIn('123456', joined) + + +def _kavenegar_reply(http=200, status=200): + return type('R', (), {'status_code': http, 'json': lambda self: { + 'return': {'status': status}, 'entries': [{'messageid': 1, 'status': 1}] if status == 200 else None}})() + + +@override_settings(DEBUG=False, KAVENEGAR_API_KEY='k', TEST_LOGIN_PHONES={}) +class VerifyLookupTests(TestCase): + """خطِ حسابِ فعلی متنِ آزاد را لغو می‌کند (وضعیتِ ۱۳)؛ کدِ ورود باید با الگو برود.""" + + def setUp(self): + cache.clear() + + @override_settings(KAVENEGAR_OTP_TEMPLATE='tadrisino-otp') + def test_otp_goes_through_the_template_with_only_the_code(self): + with patch('accounts.views.secrets.randbelow', return_value=4321), \ + patch('accounts.services.requests.post', return_value=_kavenegar_reply()) as post: + self.assertEqual(_request('09121113301').status_code, 200) + url, data = post.call_args.args[0], post.call_args.kwargs['data'] + self.assertTrue(url.endswith('/verify/lookup.json')) + self.assertEqual(data, {'receptor': '09121113301', 'template': 'tadrisino-otp', 'token': '004321'}) + + @override_settings(KAVENEGAR_OTP_TEMPLATE='') + def test_without_a_template_the_plain_send_is_kept(self): + with patch('accounts.services.requests.post', return_value=_kavenegar_reply()) as post: + self.assertEqual(_request('09121113302').status_code, 200) + self.assertTrue(post.call_args.args[0].endswith('/sms/send.json')) + self.assertIn('message', post.call_args.kwargs['data']) + + @override_settings(KAVENEGAR_OTP_TEMPLATE='misspelled') + def test_unknown_template_is_an_explicit_503_and_frees_the_cooldown(self): + # کاوه‌نگار برای الگوی ناموجود/تأییدنشده HTTP ۴۲۴ می‌دهد (۲۰۲۶-۰۹-۲۵ زنده دیده شد). + with patch('accounts.services.requests.post', return_value=_kavenegar_reply(424, 424)): + self.assertEqual(_request('09121113303').status_code, 503) + self.assertFalse(OTPCode.objects.filter(phone='09121113303').exists()) diff --git a/config/settings.py b/config/settings.py index adba986..4cc8c6e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -674,6 +674,10 @@ TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', # بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد. KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '') KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '') +# نامِ الگوی تأییدشده در پنلِ کاوه‌نگار برای کدِ ورود (verify/lookup، با %token%). +# وقتی تنظیم باشد OTP_SMS_TEMPLATE پایین استفاده نمی‌شود و متن را خودِ الگو می‌سازد. +# خطِ پیش‌فرضِ حسابِ فعلی پیامکِ متنِ آزاد را لغو می‌کند، پس روی سرور لازم است. +KAVENEGAR_OTP_TEMPLATE = os.environ.get('KAVENEGAR_OTP_TEMPLATE', '').strip() # جمله‌ی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامک‌های بانکی)؛ کلِ متن زیرِ # ۷۰ نویسه می‌ماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه. OTP_SMS_TEMPLATE = os.environ.get(