diff --git a/accounts/migrations/0005_normalize_user_phones.py b/accounts/migrations/0005_normalize_user_phones.py new file mode 100644 index 0000000..1a020f4 --- /dev/null +++ b/accounts/migrations/0005_normalize_user_phones.py @@ -0,0 +1,40 @@ +""" +شماره‌های موجود را به همان شکلِ 09XXXXXXXXX می‌برد که serializerها حالا می‌سازند +(accounts/phone.py)؛ وگرنه کسی که با ‎+98…‎ ثبت‌نام کرده بود دیگر به حسابِ خودش +نمی‌رسید و حسابِ تازه می‌گرفت. + +ردیفی که شکلِ نرمالش از قبل مالِ کاربرِ دیگری است دست نمی‌خورد (unique است و +ادغامِ دو حساب تصمیمِ انسانی است)؛ ردیفی هم که موبایلِ ایران نیست همان‌طور می‌ماند. +هر دو فقط شمرده و چاپ می‌شوند. برگشت‌ناپذیر است چون شکلِ قبلی ذخیره نمی‌شود. +""" +from django.db import migrations + + +def normalize(apps, schema_editor): + from accounts.phone import normalize_phone + + User = apps.get_model('accounts', 'User') + changed = skipped_taken = skipped_invalid = 0 + for user in User.objects.exclude(phone__isnull=True).exclude(phone=''): + target = normalize_phone(user.phone) + if target is None: + skipped_invalid += 1 + elif target != user.phone: + if User.objects.filter(phone=target).exists(): + skipped_taken += 1 + else: + User.objects.filter(pk=user.pk).update(phone=target) + changed += 1 + if changed or skipped_taken or skipped_invalid: + print(f'\n normalize phones: changed={changed} taken={skipped_taken} invalid={skipped_invalid}') + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_otpcode_failed_attempts_user_tokens_valid_after'), + ] + + operations = [ + migrations.RunPython(normalize, migrations.RunPython.noop), + ] diff --git a/accounts/phone.py b/accounts/phone.py new file mode 100644 index 0000000..33cc0e7 --- /dev/null +++ b/accounts/phone.py @@ -0,0 +1,31 @@ +""" +نرمال‌سازیِ شماره‌ی موبایلِ ایران به شکلِ یکتای ‎09XXXXXXXXX‎. + +چرا: پیش از این `phone` رشته‌ی خام بود و ‎0912…‎، ‎+98912…‎، ‎98912…‎ و همان +شماره با ارقامِ فارسی چهار «شماره»ی جدا بودند — هر کدام cooldown و سقفِ پیامکِ +خودش را داشت و حسابِ جدا می‌ساخت (security-before-production.md، بندِ ۱۷). +هرچه شکلِ ‎09\\d{9}‎ نگیرد رد می‌شود، پس شماره‌ی خارجی یا خطِ ثابت هم به +کاوه‌نگار نمی‌رسد (پیامکِ بین‌المللی گران است و هدفِ SMS pumping). + +عمداً بی‌وابستگی به DRF است تا config/settings.py هم بتواند واردش کند +(کلیدهای TEST_LOGIN_PHONES)؛ فیلدِ serializer در accounts/serializers.py است. +""" +import re + +_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789') +_SEPARATORS = re.compile(r'[\s\-().\u200c\u200e\u200f]') +_MOBILE = re.compile(r'09\d{9}') + + +def normalize_phone(value) -> str | None: + """شکلِ یکتای ‎09XXXXXXXXX‎، یا None اگر موبایلِ ایران نباشد.""" + s = _SEPARATORS.sub('', str(value or '').translate(_DIGITS)) + if s.startswith('+98'): + s = '0' + s[3:] + elif s.startswith('0098'): + s = '0' + s[4:] + elif s.startswith('98') and len(s) == 12: + s = '0' + s[2:] + elif s.startswith('9') and len(s) == 10: + s = '0' + s + return s if _MOBILE.fullmatch(s) else None diff --git a/accounts/serializers.py b/accounts/serializers.py index 3dbe7a3..098208d 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -2,6 +2,8 @@ import re from rest_framework import serializers +from .phone import normalize_phone + # لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر (شماره‌ی تلفن با # فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم. _CONTACT = re.compile( @@ -15,6 +17,20 @@ def clean_display_name(value: str) -> str: return ' '.join(_CONTACT.sub(' ', value or '').split())[:30] +class IranMobileField(serializers.CharField): + """CharField که خروجی‌اش همیشه ‎09XXXXXXXXX‎ است.""" + + def __init__(self, **kwargs): + kwargs.setdefault('max_length', 20) + super().__init__(**kwargs) + + def to_internal_value(self, data): + phone = normalize_phone(super().to_internal_value(data)) + if phone is None: + raise serializers.ValidationError('شماره‌ی موبایل معتبر نیست؛ مثلاً 09121234567.') + return phone + + # ---- Request serializers ---- class GuestSessionRequestSerializer(serializers.Serializer): @@ -23,7 +39,7 @@ class GuestSessionRequestSerializer(serializers.Serializer): class LoginRequestSerializer(serializers.Serializer): - phone = serializers.CharField(max_length=15) + phone = IranMobileField() otp = serializers.CharField(max_length=6) @@ -91,7 +107,7 @@ class UpdateProfileRequestSerializer(serializers.Serializer): class RequestOTPRequestSerializer(serializers.Serializer): """اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد).""" - phone = serializers.CharField(max_length=15) + phone = IranMobileField() # ---- Response serializers ---- diff --git a/accounts/services.py b/accounts/services.py index 9c91e1b..7bbe38c 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -125,6 +125,16 @@ def send_sms(*, phone: str, message: str) -> None: ) raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') + # پذیرفته‌شدن ≠ رسیدن. شناسه و وضعیتِ پیام لاگ می‌شود تا اگر کسی گفت «کد + # نیامد»، همین شناسه در پنلِ کاوه‌نگار (گزارشِ ارسال) دنبال شود — مثلاً + # شماره‌ای که پیامکِ تبلیغاتی را مسدود کرده. متنِ پیام لاگ نمی‌شود. + entries = body.get('entries') if isinstance(body, dict) else None + first = entries[0] if isinstance(entries, list) and entries and isinstance(entries[0], dict) else {} + logger.info( + 'SMS accepted by provider: phone_suffix=%s messageid=%s message_status=%s', + _phone_suffix(phone), first.get('messageid'), first.get('status'), + ) + def send_otp_sms(*, phone: str, code: str) -> None: """ diff --git a/accounts/tests.py b/accounts/tests.py index efac68b..42d8892 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -27,14 +27,14 @@ class SendOtpSmsTests(TestCase): @override_settings(DEBUG=True) def test_dev_mode_does_not_call_provider(self): with patch('accounts.services.requests.post') as mock_post: - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') mock_post.assert_not_called() @override_settings(DEBUG=False, KAVENEGAR_API_KEY='') def test_production_without_api_key_raises_instead_of_silent_success(self): with patch('accounts.services.requests.post') as mock_post: with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') mock_post.assert_not_called() @override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key', KAVENEGAR_SENDER='') @@ -49,11 +49,11 @@ class SendOtpSmsTests(TestCase): 'entries': [{'messageid': 1, 'status': 1}], } with patch('accounts.services.requests.post', return_value=mock_response) as mock_post: - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') mock_post.assert_called_once() args, kwargs = mock_post.call_args self.assertIn('test-key', args[0]) - self.assertEqual(kwargs['data']['receptor'], '+989120000000') + self.assertEqual(kwargs['data']['receptor'], '09120000000') self.assertIn('1234', kwargs['data']['message']) @override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key') @@ -61,7 +61,7 @@ class SendOtpSmsTests(TestCase): mock_response = Mock(status_code=400, text='bad request') with patch('accounts.services.requests.post', return_value=mock_response): with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') @override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key') def test_production_http_200_with_application_level_rejection_raises(self): @@ -77,7 +77,7 @@ class SendOtpSmsTests(TestCase): } with patch('accounts.services.requests.post', return_value=mock_response): with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') @override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key') def test_production_malformed_provider_body_raises(self): @@ -87,20 +87,20 @@ class SendOtpSmsTests(TestCase): mock_response.json.side_effect = ValueError('No JSON object could be decoded') with patch('accounts.services.requests.post', return_value=mock_response): with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') @override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key') def test_production_network_failure_raises(self): import requests as requests_module with patch('accounts.services.requests.post', side_effect=requests_module.ConnectionError('boom')): with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') @override_settings(DEBUG=True) def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self): # 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچ‌وقت شماره‌تلفنِ کامل رو # نشون بده؛ فقط ۴ رقمِ آخر (شناسه‌ی امن) باید در پیامِ لاگ باشه. - phone = '+989120000000' + phone = '09120000000' with self.assertLogs('accounts.services', level='INFO') as captured: send_otp_sms(phone=phone, code='1234') @@ -124,7 +124,7 @@ class SendOtpSmsTests(TestCase): with patch('accounts.services.requests.post', return_value=mock_response): with self.assertLogs('accounts.services', level='ERROR') as captured: with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') message = captured.records[0].getMessage() self.assertNotIn('bad request body secret-ish', message) self.assertIn('phone_suffix=0000', message) @@ -136,7 +136,7 @@ class SendOtpSmsTests(TestCase): with patch('accounts.services.requests.post', return_value=mock_response): with self.assertLogs('accounts.services', level='ERROR') as captured: with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') message = captured.records[0].getMessage() self.assertNotIn('not json body secret-ish', message) self.assertIn('phone_suffix=0000', message) @@ -154,7 +154,7 @@ class SendOtpSmsTests(TestCase): with patch('accounts.services.requests.post', return_value=mock_response): with self.assertLogs('accounts.services', level='ERROR') as captured: with self.assertRaises(OTPDeliveryError): - send_otp_sms(phone='+989120000000', code='1234') + send_otp_sms(phone='09120000000', code='1234') message = captured.records[0].getMessage() self.assertNotIn('secret-ish detail', message) self.assertIn('phone_suffix=0000', message) @@ -176,22 +176,23 @@ class RequestOTPViewTests(TestCase): def test_dev_mode_returns_otp_in_response_without_provider_call(self): with patch('accounts.views.send_otp_sms') as mock_send: response = self.client.post( - '/api/auth/otp/request', {'phone': '+989120000001'}, format='json', + '/api/auth/otp/request', {'phone': '09120000001'}, format='json', ) self.assertEqual(response.status_code, 200) self.assertTrue(response.data['success']) self.assertTrue(response.data['devOtp']) mock_send.assert_called_once() - self.assertTrue(OTPCode.objects.filter(phone='+989120000001').exists()) + self.assertTrue(OTPCode.objects.filter(phone='09120000001').exists()) @override_settings(DEBUG=False, KAVENEGAR_API_KEY='') def test_production_delivery_failure_does_not_report_success(self): response = self.client.post( - '/api/auth/otp/request', {'phone': '+989120000002'}, format='json', + '/api/auth/otp/request', {'phone': '09120000002'}, format='json', ) self.assertEqual(response.status_code, 503) - # OTP row is created before delivery is attempted; only the SMS leg fails. - self.assertTrue(OTPCode.objects.filter(phone='+989120000002').exists()) + # ردیفِ کدی که هرگز نرسید حذف می‌شود تا در cooldown و سقفِ روزانه‌ی + # دیتابیسی شمرده نشود (۲۰۲۶-۰۹-۲۵). + self.assertFalse(OTPCode.objects.filter(phone='09120000002').exists()) @override_settings(DEBUG=True) def test_generated_otp_is_always_a_6_digit_string_with_leading_zeros(self): @@ -199,11 +200,11 @@ class RequestOTPViewTests(TestCase): with patch('accounts.views.secrets.randbelow', return_value=7): with patch('accounts.views.send_otp_sms'): response = self.client.post( - '/api/auth/otp/request', {'phone': '+989120000003'}, format='json', + '/api/auth/otp/request', {'phone': '09120000003'}, format='json', ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['devOtp'], '000007') - otp = OTPCode.objects.get(phone='+989120000003') + otp = OTPCode.objects.get(phone='09120000003') self.assertEqual(otp.code, '000007') self.assertEqual(len(otp.code), 6) self.assertTrue(otp.code.isdigit()) @@ -242,7 +243,7 @@ class RequestOTPStaleInvalidationTests(TestCase): # (RequestOTPPhoneCooldownTests). @override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) def test_second_request_invalidates_first_otp(self): - phone = '+989121110001' + phone = '09121110001' first_code = self._request_otp(phone, '111111') first_otp = OTPCode.objects.get(phone=phone, code=first_code) self.assertFalse(first_otp.is_used) @@ -254,7 +255,7 @@ class RequestOTPStaleInvalidationTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) def test_newest_otp_still_works_for_login(self): - phone = '+989121110002' + phone = '09121110002' self._request_otp(phone, '111111') second_code = self._request_otp(phone, '222222') @@ -265,7 +266,7 @@ class RequestOTPStaleInvalidationTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) def test_older_otp_is_rejected_after_new_request(self): - phone = '+989121110003' + phone = '09121110003' first_code = self._request_otp(phone, '111111') self._request_otp(phone, '222222') @@ -342,7 +343,7 @@ class LoginPhoneRateLimitTests(TestCase): @override_settings(DEBUG=True, OTP_LOGIN_PHONE_MAX_ATTEMPTS=3) def test_repeated_attempts_for_same_phone_are_blocked(self): - phone = '+989123334444' + phone = '09123334444' for _ in range(3): response = self.client.post( @@ -374,7 +375,7 @@ class RequestOTPPhoneRateLimitTests(TestCase): # (RequestOTPPhoneCooldownTests). @override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) def test_repeated_requests_for_same_phone_are_eventually_blocked(self): - phone = '+989127778888' + phone = '09127778888' # otp_request (IP-محور) پیش‌فرض 5/hour است؛ برای اینکه فقط # محدودیتِ شماره‌تلفن رو بسنجیم، throttle IP-محور رو موقتاً باز @@ -394,7 +395,7 @@ class RequestOTPPhoneRateLimitTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) def test_changing_client_ip_does_not_bypass_the_phone_limit(self): - phone = '+989127778889' + phone = '09127778889' with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}): with patch('accounts.views.send_otp_sms'): @@ -416,7 +417,7 @@ class RequestOTPPhoneRateLimitTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self): - phone = '+989127778890' + phone = '09127778890' with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}): with patch('accounts.views.send_otp_sms') as mock_send: @@ -458,7 +459,7 @@ class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase): def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self): import threading - phone = '+989127778891' + phone = '09127778891' barrier = threading.Barrier(2) results = [] results_lock = threading.Lock() @@ -502,7 +503,7 @@ class RequestOTPPhoneCooldownTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60) def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self): - phone = '+989127779001' + phone = '09127779001' with patch('accounts.views.send_otp_sms'): first_response = self.client.post( @@ -521,7 +522,7 @@ class RequestOTPPhoneCooldownTests(TestCase): # موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون # درخواست‌هایِ بلاک‌شده توسطِ cooldown هیچ‌وقت به شمارنده‌ی ساعتی # نمی‌رسن، سهمیه‌ی ساعتی مصرف نمی‌شه. - phone = '+989127779002' + phone = '09127779002' # throttle IP-محورِ otp_request (پیش‌فرض 5/hour) رو موقتاً باز # می‌کنیم تا اینجا فقط cooldown/سهمیه‌ی ساعتیِ per-phone سنجیده بشه، @@ -541,7 +542,11 @@ class RequestOTPPhoneCooldownTests(TestCase): # سهمیه‌ی ساعتی هنوز دست‌نخورده است: بعد از پایانِ cooldown، # همچنان می‌شه تا سقفِ مجاز درخواست داد. + # گذشتِ زمان: هم کلیدِ کش و هم cooldownِ دیتابیسی (از expires_at). cache.delete(f'otp_request_phone_cooldown:{phone}') + OTPCode.objects.filter(phone=phone).update( + expires_at=timezone.now() + timezone.timedelta(seconds=settings.OTP_TTL_SECONDS - 61), + ) with patch('accounts.views.send_otp_sms') as mock_send_again: allowed_again = self.client.post( '/api/auth/otp/request', {'phone': phone}, format='json', @@ -551,7 +556,7 @@ class RequestOTPPhoneCooldownTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60) def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self): - phone = '+989127779003' + phone = '09127779003' with patch('accounts.views.send_otp_sms') as mock_send: self.client.post('/api/auth/otp/request', {'phone': phone}, format='json') @@ -569,7 +574,7 @@ class RequestOTPPhoneCooldownTests(TestCase): def test_cooldown_expires_after_configured_window(self): import time - phone = '+989127779004' + phone = '09127779004' with patch('accounts.views.send_otp_sms'): first_response = self.client.post( @@ -588,10 +593,10 @@ class RequestOTPPhoneCooldownTests(TestCase): def test_cooldown_is_per_phone_not_global(self): with patch('accounts.views.send_otp_sms'): first_response = self.client.post( - '/api/auth/otp/request', {'phone': '+989127779005'}, format='json', + '/api/auth/otp/request', {'phone': '09127779005'}, format='json', ) second_response = self.client.post( - '/api/auth/otp/request', {'phone': '+989127779006'}, format='json', + '/api/auth/otp/request', {'phone': '09127779006'}, format='json', ) self.assertEqual(first_response.status_code, 200) self.assertEqual(second_response.status_code, 200) @@ -604,7 +609,7 @@ class RequestOTPPhoneCooldownTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120) def test_cooldown_response_reports_retry_after_header_and_body(self): - phone = '+989127779008' + phone = '09127779008' with patch('accounts.views.send_otp_sms'): first_response = self.client.post( @@ -633,7 +638,7 @@ class RequestOTPPhoneCooldownTests(TestCase): def test_retry_after_counts_down_as_time_passes(self): import time - phone = '+989127779009' + phone = '09127779009' with patch('accounts.views.send_otp_sms'): self.client.post('/api/auth/otp/request', {'phone': phone}, format='json') @@ -673,7 +678,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase): def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self): import threading - phone = '+989127779007' + phone = '09127779007' barrier = threading.Barrier(2) results = [] results_lock = threading.Lock() @@ -706,7 +711,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase): # هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک می‌موند. # این تست تضمین می‌کنه که با شکستِ ارسال، cooldown پاک می‌شه و # کاربر می‌تونه فوراً دوباره تلاش کنه. - phone = '+989127779010' + phone = '09127779010' with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')): first_response = self.client.post( @@ -727,7 +732,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase): def test_cooldown_still_active_after_successful_delivery(self): # اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ # رفتارِ قبلی فعال می‌مونه (فقط شکستِ تحویل باید cooldown رو پاک کنه). - phone = '+989127779011' + phone = '09127779011' with patch('accounts.views.send_otp_sms'): first_response = self.client.post( @@ -895,22 +900,22 @@ class UserProfileViewTests(TestCase): def test_returns_existing_profile_fields_for_registered_user(self): user = User.objects.create( - phone='+989120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2, + phone='09120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2, ) - Profile.objects.create(user=user, birth_year=2005, parent_phone='+989120000002', bio='سلام') + Profile.objects.create(user=user, birth_year=2005, parent_phone='09120000002', bio='سلام') token = issue_access_token(user.id) response = self._get_profile(token) self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data['phone'], '+989120000001') + self.assertEqual(data['phone'], '09120000001') self.assertEqual(data['name'], 'علی') self.assertEqual(data['avatar'], 'avatar.png') self.assertEqual(data['isGuest'], False) self.assertEqual(data['gradeId'], 2) self.assertEqual(data['birthYear'], 2005) - self.assertEqual(data['parentPhone'], '+989120000002') + self.assertEqual(data['parentPhone'], '09120000002') self.assertEqual(data['bio'], 'سلام') def test_does_not_leak_other_users_profile(self): @@ -1653,7 +1658,7 @@ class TestLoginPhoneParsingTests(TestCase): def test_valid_pairs_are_parsed(self): self.assertEqual( self._parse(' 09120000001:834512 , +989120000002:110947 '), - {'09120000001': '834512', '+989120000002': '110947'}, + {'09120000001': '834512', '09120000002': '110947'}, ) def test_empty_value_yields_no_test_phones(self): diff --git a/accounts/tests_audit_fixes.py b/accounts/tests_audit_fixes.py index 0f2804e..43b4b11 100644 --- a/accounts/tests_audit_fixes.py +++ b/accounts/tests_audit_fixes.py @@ -58,7 +58,7 @@ class OtpTests(TestCase): APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json') def test_a_code_dies_after_five_wrong_guesses(self): - phone = '+989121234001' + phone = '09121234001' self._request(phone) for guess in ('000001', '000002', '000003', '000004', '000005'): APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json') @@ -68,7 +68,7 @@ class OtpTests(TestCase): @override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, - TEST_LOGIN_PHONES={'+989120000101': '4321'}) + TEST_LOGIN_PHONES={'09120000101': '4321'}) class GuestSignupKeepsProgressTests(TestCase): def setUp(self): cache.clear() @@ -85,25 +85,25 @@ class GuestSignupKeepsProgressTests(TestCase): def test_the_guest_becomes_the_registered_user(self): with patch('accounts.views.secrets.randbelow', return_value=654321), \ patch('accounts.views.send_otp_sms'): - self.client.post('/api/auth/otp/request', {'phone': '+989121234002'}, format='json') - body = self._login('+989121234002', '654321').json() + self.client.post('/api/auth/otp/request', {'phone': '09121234002'}, format='json') + body = self._login('09121234002', '654321').json() self.assertEqual(int(body['userId']), self.guest_id) user = User.objects.get(pk=self.guest_id) - self.assertEqual((user.phone, user.is_guest), ('+989121234002', False)) + self.assertEqual((user.phone, user.is_guest), ('09121234002', False)) self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120) def test_an_existing_account_is_not_merged(self): - existing = User.objects.create(phone='+989121234003', is_guest=False) + existing = User.objects.create(phone='09121234003', is_guest=False) with patch('accounts.views.secrets.randbelow', return_value=111222), \ patch('accounts.views.send_otp_sms'): - self.client.post('/api/auth/otp/request', {'phone': '+989121234003'}, format='json') - body = self._login('+989121234003', '111222').json() + self.client.post('/api/auth/otp/request', {'phone': '09121234003'}, format='json') + body = self._login('09121234003', '111222').json() self.assertEqual(int(body['userId']), existing.id) self.assertTrue(User.objects.get(pk=self.guest_id).is_guest) def test_demo_test_phones_never_absorb_a_guest(self): - body = self._login('+989120000101', '4321').json() + body = self._login('09120000101', '4321').json() self.assertNotEqual(int(body['userId']), self.guest_id) self.assertTrue(User.objects.get(pk=self.guest_id).is_guest) diff --git a/accounts/tests_otp_limits.py b/accounts/tests_otp_limits.py new file mode 100644 index 0000000..5f2867a --- /dev/null +++ b/accounts/tests_otp_limits.py @@ -0,0 +1,130 @@ +""" +نرمال‌سازیِ شماره و سقف‌های دیتابیسیِ پیامکِ OTP (۲۰۲۶-۰۹-۲۵، همان روزی که +کلیدِ کاوه‌نگار وصل شد). security-before-production.md بندِ ۱۷. + +کش در هر تست پاک می‌شود تا رفتارِ «workerِ دیگر» (کشِ خالی، دیتابیسِ مشترک) +شبیه‌سازی شود. هم‌زمانیِ واقعیِ دو پروسه‌ی gunicorn تست نشده است. +""" +from unittest.mock import patch + +import requests +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 accounts.models import OTPCode, User +from accounts.services import send_sms + + +def _request(phone): + return APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json') + + +@override_settings(DEBUG=True, TEST_LOGIN_PHONES={}) +class PhoneNormalizationTests(TestCase): + def setUp(self): + cache.clear() + + def test_other_spellings_of_the_same_number_share_one_cooldown(self): + # همان سنجه‌ای که security-before-production.md برای بستنِ بندِ ۱۷ نوشته. + with patch('accounts.views.send_otp_sms') as send: + self.assertEqual(_request('09121112233').status_code, 200) + for spelling in ('+989121112233', '989121112233', '۰۹۱۲۱۱۱۲۲۳۳', '0912 111 2233'): + self.assertEqual(_request(spelling).status_code, 429, spelling) + send.assert_called_once() + self.assertEqual(set(OTPCode.objects.values_list('phone', flat=True)), {'09121112233'}) + + def test_login_with_another_spelling_reaches_the_same_account(self): + User.objects.create(phone='09121112244', is_guest=False) + with patch('accounts.views.secrets.randbelow', return_value=246810), \ + patch('accounts.views.send_otp_sms'): + _request('+989121112244') + body = APIClient().post('/api/auth/login', {'phone': '۰۹۱۲۱۱۱۲۲۴۴', 'otp': '246810'}, + format='json').json() + self.assertEqual(User.objects.filter(phone__endswith='9121112244').count(), 1) + self.assertEqual(body['userId'], str(User.objects.get(phone='09121112244').id)) + + def test_non_mobile_numbers_are_rejected_before_any_sms(self): + with patch('accounts.views.send_otp_sms') as send: + for bad in ('+14155552671', '02188888888', '0912123', 'abc'): + self.assertEqual(_request(bad).status_code, 400, bad) + send.assert_not_called() + self.assertFalse(OTPCode.objects.exists()) + + @override_settings(TEST_LOGIN_PHONES={'09120000101': '4321'}) + def test_demo_phone_matches_in_any_spelling(self): + response = APIClient().post('/api/auth/login', {'phone': '+989120000101', 'otp': '4321'}, + format='json') + self.assertEqual(response.status_code, 200) + + +@override_settings(DEBUG=True, TEST_LOGIN_PHONES={}, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120) +class DatabaseBackedLimitTests(TestCase): + """کشِ LocMem مالِ هر worker است؛ این سقف‌ها باید با کشِ خالی هم بمانند.""" + + def setUp(self): + cache.clear() + + def test_cooldown_holds_on_a_worker_with_an_empty_cache(self): + with patch('accounts.views.send_otp_sms') as send: + self.assertEqual(_request('09121112255').status_code, 200) + cache.clear() # درخواستِ بعدی به workerِ دیگری می‌رسد + response = _request('09121112255') + self.assertEqual(response.status_code, 429) + self.assertTrue(1 <= response.json()['retryAfter'] <= 120) + send.assert_called_once() + + @override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_REQUEST_PHONE_DAILY_MAX=3) + def test_daily_cap_per_phone(self): + with patch('accounts.views.send_otp_sms') as send: + statuses = [] + for _ in range(5): + cache.clear() # سقفِ ساعتیِ کش را کنار می‌گذارد؛ فقط سقفِ روزانه بماند + statuses.append(_request('09121112266').status_code) + self.assertEqual(statuses, [200, 200, 200, 429, 429]) + self.assertEqual(send.call_count, 3) + + @override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) + def test_codes_older_than_a_day_do_not_count(self): + OTPCode.objects.bulk_create([ + OTPCode(phone='09121112277', code='000000', + expires_at=timezone.now() - timezone.timedelta(days=2)) + for _ in range(20) + ]) + with patch('accounts.views.send_otp_sms'): + self.assertEqual(_request('09121112277').status_code, 200) + + @override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_SMS_GLOBAL_DAILY_MAX=2) + def test_global_daily_cap_stops_sms_pumping(self): + with patch('accounts.views.send_otp_sms') as send: + self.assertEqual(_request('09121110001').status_code, 200) + self.assertEqual(_request('09121110002').status_code, 200) + with self.assertLogs('accounts.views', level='ERROR'): + response = _request('09121110003') + self.assertEqual(response.status_code, 503) + self.assertEqual(send.call_count, 2) + self.assertFalse(OTPCode.objects.filter(phone='09121110003').exists()) + + @override_settings(DEBUG=False, KAVENEGAR_API_KEY='k') + def test_failed_delivery_frees_the_cooldown_and_quota(self): + with patch('accounts.services.requests.post', side_effect=requests.ConnectionError('down')): + self.assertEqual(_request('09121112288').status_code, 503) + cache.clear() + ok = type('R', (), {'status_code': 200, 'json': lambda self: { + 'return': {'status': 200}, 'entries': [{'messageid': 1, 'status': 1}]}})() + with patch('accounts.services.requests.post', return_value=ok): + self.assertEqual(_request('09121112288').status_code, 200) + + +class ProviderLoggingTests(TestCase): + @override_settings(KAVENEGAR_API_KEY='k') + def test_accepted_message_logs_its_id_not_its_text(self): + ok = type('R', (), {'status_code': 200, 'json': lambda self: { + 'return': {'status': 200}, 'entries': [{'messageid': 8792343, 'status': 1}]}})() + with patch('accounts.services.requests.post', return_value=ok), \ + self.assertLogs('accounts.services', level='INFO') as logs: + send_sms(phone='09121112299', message='کد ورود شما: 123456') + joined = '\n'.join(logs.output) + self.assertIn('messageid=8792343', joined) + self.assertNotIn('123456', joined) diff --git a/accounts/views.py b/accounts/views.py index ed52b94..43761d7 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -186,6 +186,8 @@ class RequestOTPView(APIView): # محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا # (وگرنه یه مهاجم می‌تونه بینِ چند IP پخش کنه). cache.add فقط وقتی # کلید نیست مقدار می‌ذاره، پس اتمیکه. + # شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس + # املای دیگرِ همان شماره سهمِ جدا نمی‌گیرد. phone_requests_key = f'otp_request_phone_attempts:{phone}' if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS): attempt_count = 1 @@ -207,25 +209,62 @@ class RequestOTPView(APIView): status=status.HTTP_429_TOO_MANY_REQUESTS, ) - # فقط آخرین کد صادرشده معتبر است. - OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True) - - # ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص - # روزی چند درصد شانس داشت. فرانت تا ۶ رقم را می‌پذیرد. - code = f'{secrets.randbelow(1_000_000):06d}' - OTPCode.objects.create( - phone=phone, - code=code, - expires_at=timezone.now() + timezone.timedelta(minutes=5), - ) - + # سقف‌های دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر + # worker‌اند (روی سرور ×۳). این‌ها از ردیف‌های OTPCode شمرده می‌شوند و بینِ + # workerها مشترک‌اند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل + # created_at ندارد). بررسی و ساختِ ردیف در یک تراکنش‌اند؛ با + # transaction_mode=IMMEDIATE دو درخواستِ هم‌زمان پشتِ هم اجرا می‌شوند + # (هم‌زمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده). + # فراخوانیِ کاوه‌نگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند. + now = timezone.now() + ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS) + day_window_expiry = now - timezone.timedelta(days=1) + ttl + with transaction.atomic(): + phone_codes = OTPCode.objects.filter(phone=phone) + last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first() + if cooldown_seconds and last_expiry is not None: + elapsed = (now - (last_expiry - ttl)).total_seconds() + if elapsed < cooldown_seconds: + retry_after = max(1, math.ceil(cooldown_seconds - elapsed)) + return Response( + { + 'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.', + 'retryAfter': retry_after, + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + headers={'Retry-After': str(retry_after)}, + ) + if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX: + logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:]) + return Response( + {'detail': 'سقفِ روزانه‌ی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX: + # یا حمله‌ی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند. + logger.error( + 'OTP global daily SMS cap reached: cap=%s phone_suffix=%s', + settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:], + ) + return Response( + {'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + # فقط آخرین کد صادرشده معتبر است. + OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True) + # ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص + # روزی چند درصد شانس داشت. فرانت تا ۶ رقم را می‌پذیرد. + code = f'{secrets.randbelow(1_000_000):06d}' + otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl) try: send_otp_sms(phone=phone, code=code) except OTPDeliveryError: # ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک می‌کنیم - # تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/ - # throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم، نه این cooldown). + # تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم + # حذف می‌کنیم تا در cooldown و سقفِ روزانه‌ی دیتابیسی شمرده نشه. + # سقفِ ساعتیِ کش و throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم). cache.delete(phone_cooldown_key) + otp_row.delete() raise # devOtp فقط در محیط dev برمی‌گردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده می‌شود. @@ -380,9 +419,13 @@ class LogoutView(APIView): """ POST /api/auth/logout — خروج از حساب. - refreshTokenِ این دستگاه (اگر فرستاده شود) باطل می‌شود و همه‌ی access - tokenهای صادرشده تا این لحظه رد می‌شوند. دستگاه‌های دیگر با refreshِ + refreshTokenِ این دستگاه (اگر فرستاده شود) باطل می‌شود و access tokenهای + صادرشده *پیش از ثانیه‌ی خروج* رد می‌شوند. دستگاه‌های دیگر با refreshِ خودشان بی‌صدا توکنِ تازه می‌گیرند؛ فقط همین دستگاه بیرون می‌ماند. + + ⚠️ مقایسه ثانیه‌ای است: توکنی که در همان ثانیه‌ی خروج صادر شده معتبر + می‌ماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ + کاربر فقط در همین worker پاک می‌شود؛ پیش‌فرض ۰ است و آن حالت تست نشده. """ permission_classes = [IsAuthenticated] diff --git a/affiliate/services.py b/affiliate/services.py index ada5fe0..0270341 100644 --- a/affiliate/services.py +++ b/affiliate/services.py @@ -3,8 +3,12 @@ تا ۲۰۲۶-۰۹-۲۵ هیچ کدی Conversion را از «pending» جلو نمی‌برد و پورسانتی حساب نمی‌شد؛ برنامه‌ی معرفی فقط یک جدولِ ثبت بود. حالا اولین سفارشِ -پرداخت‌شده‌ی کاربرِ معرفی‌شده Conversion را `paid` می‌کند و +پرداخت‌شده‌ی کاربرِ معرفی‌شده *بعد از ثبتِ کد* Conversion را `paid` می‌کند و `commission_amount = مبلغِ پرداختی × commission_rate` را ثبت می‌کند. + +⚠️ «کاربرِ تازه» بررسی نمی‌شود: مشتریِ قدیمی هم هر وقت بخواهد کد ثبت می‌کند +و تمدیدِ بعدی‌اش پورسانت می‌سازد (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). جلوی +معرفیِ خود با شماره‌ی دوم هم چیزی نیست. پرداختِ خودِ پورسانت به معرف (تسویه) هنوز دستی و از پنلِ ادمین است. ثبت‌نام یا ورودِ مهمان هیچ پورسانتی نمی‌سازد؛ فقط پول جابه‌جا شدنِ واقعی. diff --git a/ai_tutor/views.py b/ai_tutor/views.py index ec9510b..ee923b5 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -98,12 +98,21 @@ def _is_db_ref_id(value) -> bool: این تابع فقط تشخیص می‌ده که آیا مقدار برای ذخیره‌سازی در همچین فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره (اونجا همیشه مقدارِ خامِ اصلی پاس داده می‌شه). + + پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد می‌شود؛ پیش از + ۲۰۲۶-۰۹-۲۵ نمی‌شد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ می‌داد و + سهمِ روزانه مصرف‌شده می‌ماند. """ if value in (None, ''): return False return str(value).strip().lstrip('-').isdigit() +def _db_ref_or_none(value): + """مقدارِ عددی برای ستونِ IntegerField، وگرنه None (نه خطای ORM).""" + return value if _is_db_ref_id(value) else None + + def _conversation_history(conversation, before_message) -> list[dict]: """ چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان. @@ -419,7 +428,7 @@ class AskAIView(APIView): conversation=conversation, role='assistant', text=answer_text, - question_id=v.get('questionId') or None, + question_id=_db_ref_or_none(v.get('questionId')), **_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache), ) @@ -558,7 +567,7 @@ class AskAIView(APIView): with transaction.atomic(): assistant_message = Message.objects.create( conversation=conversation, role='assistant', text=answer_text, - question_id=v.get('questionId') or None, + question_id=_db_ref_or_none(v.get('questionId')), **_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache), ) if idempotency_key: diff --git a/config/settings.py b/config/settings.py index 44dfbd9..08c884a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -422,9 +422,13 @@ REST_FRAMEWORK = { # `/api/health` در `clientIp` نشان می‌دهد سرور چه IPای می‌بیند. 'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')), 'DEFAULT_THROTTLE_RATES': { - 'otp_request': '5/hour', - 'otp_login': '10/hour', - 'guest_session': '20/hour', + # سقف‌های per-IP عمداً گشاده‌اند: پشتِ CGNATِ اپراتورهای موبایل، وای‌فایِ + # مدرسه یا شبکه‌ی یک دفتر، ده‌ها نفر یک IP دارند و با ۵ در ساعت فقط ۵ نفر + # می‌توانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایین‌تر و + # در دیتابیس)؛ این‌ها فقط جلوی یک ماشینِ تنها را می‌گیرند. + 'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'), + 'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'), + 'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'), 'bazaar_verify': '10/hour', 'ai_ask': '20/hour', 'rag_ingest': '30/hour', @@ -600,6 +604,20 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600 # (که جلویِ burst رو نمی‌گیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار # باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی. OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120 + +# عمرِ هر کدِ ورود. سقف‌های دیتابیسیِ پایین زمانِ صدور را از expires_at منهای +# همین مقدار می‌خوانند، پس عوض کردنش آن سقف‌ها را هم (برای کدهای قدیمی) جابه‌جا می‌کند. +OTP_TTL_SECONDS = 300 + +# سقف‌هایی که در دیتابیس شمرده می‌شوند، نه در کش. کشِ پیش‌فرض LocMem و مالِ +# هر worker است، پس cooldown و سقفِ ساعتیِ بالا روی سرور (۳ worker) عملاً سه +# برابرند؛ این دو بینِ همه‌ی workerها مشترک‌اند و بدونِ Redis هم درست کار می‌کنند. +# - روزانه‌ی هر شماره: جلوی بمبارانِ پیامکیِ یک گوشی در طولِ روز. +# - روزانه‌ی کلِ سامانه: سقفِ خرجِ اعتبارِ کاوه‌نگار اگر کسی با شماره‌های تصادفی +# و IPهای زیاد پیامک بکشد (SMS pumping). رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد؛ +# با رشدِ کاربرِ واقعی از .env بالا ببرید. +OTP_REQUEST_PHONE_DAILY_MAX = int(os.environ.get('OTP_REQUEST_PHONE_DAILY_MAX', '10')) +OTP_SMS_GLOBAL_DAILY_MAX = int(os.environ.get('OTP_SMS_GLOBAL_DAILY_MAX', '300')) # هر کدِ ورود بعد از این تعداد حدسِ غلط باطل می‌شود (شمارش در دیتابیس). # با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون. OTP_MAX_FAILED_ATTEMPTS = 5 @@ -617,6 +635,8 @@ DEV_MASTER_OTP = '0000' if DEBUG else None # را normalize نمی‌کند (accounts/serializers.py فقط CharField است)، پس # «09121111111» و «+989121111111» دو کاربرِ جدا هستند. def _parse_test_login_phones(raw: str) -> dict[str, str]: + from accounts.phone import normalize_phone + pairs = {} for item in raw.split(','): item = item.strip() @@ -633,7 +653,14 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]: 'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) ' 'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512' ) - pairs[phone] = code + # همان نرمال‌سازیِ ورودیِ API (accounts/phone.py)، وگرنه شماره‌ای که + # اینجا ‎+98…‎ نوشته شده با ورودیِ نرمال‌شده‌ی ‎09…‎ هرگز جور نمی‌شد. + normalized = normalize_phone(phone) + if normalized is None: + raise ImproperlyConfigured( + f'TEST_LOGIN_PHONES شماره‌ی موبایلِ ایران نیست: «{item}». مثال: 09120000001:834512' + ) + pairs[normalized] = code return pairs @@ -646,7 +673,11 @@ 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', '') -OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}') +# جمله‌ی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامک‌های بانکی)؛ کلِ متن زیرِ +# ۷۰ نویسه می‌ماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه. +OTP_SMS_TEMPLATE = os.environ.get( + 'OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}\nاین کد را به هیچ‌کس ندهید.', +) # ----------------------------------------------------------------------- # Content progress (NodeProgress status) diff --git a/config/tests_audit_round2.py b/config/tests_audit_round2.py new file mode 100644 index 0000000..24778e8 --- /dev/null +++ b/config/tests_audit_round2.py @@ -0,0 +1,140 @@ +""" +بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵: ایرادهایی که بازتولید و بسته شدند. + +هر تست همان سناریویی را اجرا می‌کند که پیش‌تر خراب بود. ایرادهایی که عمداً +باز ماندند (تصمیمِ محصولی یا خطرِ شکستنِ فرانت) در docs/security-before-production.md +و کامنتِ همان کدها ثبت‌اند. +""" +from unittest.mock import MagicMock, patch + +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 accounts.authentication import issue_access_token +from accounts.models import User +from content.models import Choice, Grade, Lesson, Node, Question, Unit +from discount.models import Offer +from family.models import FamilyLink +from payment.models import Order +from payment.services import apply_order_payment_result +from subscription.models import Plan, Subscription + + +def _client(user): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + return client + + +class _Base(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create(phone='09120008001', is_guest=False) + self.client = _client(self.user) + self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365) + + def tearDown(self): + cache.clear() + + def buy(self, client=None): + response = (client or self.client).post( + '/api/subscription/purchase', {'planId': str(self.year.id)}, format='json') + self.assertEqual(response.status_code, 200, response.content) + return Order.objects.get(pk=response.json()['orderId']) + + def pay(self, order, ref): + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref, + new_status='paid', event='test') + + +class OfferNeverShortensSubscription(_Base): + def test_trial_code_after_a_paid_year_keeps_the_year(self): + self.pay(self.buy(), ref='R1') + before = Subscription.objects.get(user_id=self.user.id).expires_at + Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK') + response = self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json') + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(Subscription.objects.get(user_id=self.user.id).expires_at, before) + + @override_settings(DEV_MASTER_OTP='000000') + def test_guest_who_paid_keeps_the_year_after_registering(self): + Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, + auto_assign_on_signup=True) + guest = User.objects.create(device_id='audit-2-device', is_guest=True) + guest_client = _client(guest) + self.pay(self.buy(guest_client), ref='R2') + response = guest_client.post('/api/auth/login', {'phone': '09125550001', 'otp': '000000'}, + format='json') + self.assertEqual(response.status_code, 200, response.content) + days = (Subscription.objects.get(user_id=guest.id).expires_at - timezone.now()).days + self.assertGreater(days, 360) + + def test_trial_still_works_for_a_user_without_subscription(self): + Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK') + self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json') + sub = Subscription.objects.get(user_id=self.user.id) + self.assertEqual(sub.status, 'active') + self.assertIn((sub.expires_at - timezone.now()).days, (6, 7)) + + def test_free_full_still_upgrades_a_paid_year_to_unlimited(self): + self.pay(self.buy(), ref='R3') + Offer.objects.create(name='رایگانِ کامل', kind='free_full', code='FULL') + self.client.post('/api/discount/offers/redeem', {'code': 'FULL'}, format='json') + self.assertIsNone(Subscription.objects.get(user_id=self.user.id).expires_at) + + +@override_settings(ZARINPAL_MERCHANT_ID='x' * 36, ZARINPAL_REDIRECT_URL='https://app.example/') +class ZarinpalErrorBodyReturnsToApp(_Base): + def test_documented_error_body_redirects_instead_of_500(self): + order = self.buy() + Order.objects.filter(pk=order.pk).update(gateway_authority='A' * 36) + fake = MagicMock(status_code=200) + fake.json.return_value = {'data': [], 'errors': {'code': -51, 'message': 'failed'}} + with patch('payment.zarinpal.requests.post', return_value=fake): + response = APIClient().get('/api/payment/zarinpal/callback', + {'Authority': 'A' * 36, 'Status': 'OK'}) + self.assertEqual(response.status_code, 302) + self.assertIn('status=failed', response['Location']) + + +class WeeklyReportCountsIslands(TestCase): + def test_island_quiz_xp_reaches_the_parent(self): + cache.clear() + grade = Grade.objects.create(title='پایه نهم', icon='', color='') + node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1), + title='جزیره', order=1) + child = User.objects.create(phone='09120008002', grade_id=grade.id) + child_client = _client(child) + for i in range(4): + question = Question.objects.create( + lesson=Lesson.objects.create(node=node, title=f'درس {i}'), + type='multiple_choice', prompt=f'سوال {i}') + right = Choice.objects.create(question=question, value='الف', is_correct=True) + Choice.objects.create(question=question, value='ب', is_correct=False) + child_client.post(f'/api/questions/{question.id}/answer', {'selectedOptionId': right.id}, + format='json') + child_client.post(f'/api/nodes/{node.id}/quiz/complete', + {'correctCount': 4, 'totalQuestions': 4}, format='json') + + parent = User.objects.create(phone='09120008003') + FamilyLink.objects.create(parent_user_id=parent.id, child_user_id=child.id, + child_phone=child.phone, invite_code='654321', + status='accepted', accepted_at=timezone.now()) + report = _client(parent).get('/api/family/weekly-report').json() + self.assertEqual(report['xpEarned'], 40) + self.assertEqual(report['islandsCompleted'], 1) + self.assertEqual(report['lessonsCompleted'], 0) + + +class AskWithNonNumericQuestionId(TestCase): + def test_answer_is_saved_not_500(self): + cache.clear() + user = User.objects.create(phone='09120008004') + response = _client(user).post('/api/ai/ask', + {'userQuestion': 'کسر چیست؟', 'questionId': 'q-12'}, + format='json') + self.assertEqual(response.status_code, 200, response.content) + from ai_tutor.models import Message + self.assertIsNone(Message.objects.get(pk=response.json()['messageId']).question_id) diff --git a/content/services.py b/content/services.py index 56eb070..4c0c9c5 100644 --- a/content/services.py +++ b/content/services.py @@ -76,7 +76,13 @@ def node_playable_lesson_ids(node) -> list[int]: def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int: - """تعدادِ سوال‌های متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده.""" + """ + تعدادِ سوال‌های متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده. + + «درست» یعنی *هر* جوابِ درست، حتی بعد از جوابِ غلط روی همان سوال. چون + جوابِ غلط `correctOptionId` را برمی‌گرداند و تکرارِ جواب آزاد است، این عدد + جلوی «جواب نداده امتیاز گرفتن» را می‌گیرد، نه «غلط زدن و بعد درست زدن». + """ from .models import UserAnswer qs = UserAnswer.objects.filter(user_id=user_id, correct=True) diff --git a/content/views.py b/content/views.py index b51f157..d9f8f00 100644 --- a/content/views.py +++ b/content/views.py @@ -514,8 +514,9 @@ class LessonCompleteView(APIView): # می‌تونست به‌جای هر کاربرِ دیگری xp/coins ثبت کنه. permission_classes = [IsAuthenticated] # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره؛ برای همینه که - # GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour - # (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار می‌سازه. + # GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ در + # settings (`lesson_complete`، الان ۱۲۰/hour) کمتر از question_answer است + # چون این endpoint رکوردِ ماندگار می‌سازه. throttle_scope = 'lesson_complete' throttle_classes = [GuestAwareContentThrottle] @@ -554,6 +555,9 @@ class LessonCompleteView(APIView): # امتیاز فقط برای جواب‌هایی که واقعاً ثبت شده‌اند. پیش‌تر عددِ کلاینت # (فقط با سقفِ تعدادِ سوال‌ها) پذیرفته می‌شد، پس صدا زدنِ مستقیمِ همین # endpoint برای هر درس، بی‌جواب دادن، امتیاز و جدولِ رتبه‌بندی را پر می‌کرد. + # ⚠️ جلوی «غلط بزن، کلید را ببین، درست بزن» را نمی‌گیرد + # (verified_correct_count را ببینید)، و همین جواب‌ها در پایانِ آزمونِ + # جزیره دوباره امتیاز می‌گیرند. هر دو در بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید شد. correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id)) # ratio همیشه بر اساس actual_question_count محاسبه می‌شود، نه totalQuestions ارسالی کلاینت. @@ -883,9 +887,12 @@ class NodeQuizCompleteView(APIView): correct_count = serializer.validated_data['correctCount'] total_questions = serializer.validated_data['totalQuestions'] - # سقف‌ها از روی خودِ مبحث، نه از روی حرفِ کلاینت، تا امتیازِ متورم - # ثبت نشود: آزمون بیشتر از استخرِ سوال‌های مبحث (و سقفِ NodeQuizView) - # سوال ندارد، و پاسخِ درست بیشتر از سوال‌ها نمی‌شود. + # سقف‌ها از روی خودِ مبحث، نه از روی حرفِ کلاینت: آزمون بیشتر از + # استخرِ سوال‌های مبحث (و سقفِ NodeQuizView) سوال ندارد، و پاسخِ درست + # بیشتر از سوال‌ها نمی‌شود. + # ⚠️ سقف «کلِ استخر» است (تا ۲۰)، نه آزمونِ چهارسوالی‌ای که واقعاً داده + # شد؛ جواب‌های درستِ مسیرِ درس‌ها هم اینجا دوباره شمرده می‌شوند. قفل بودنِ + # جزیره هم بررسی نمی‌شود: هر جزیره‌ای با correctCount=0 «تمام» می‌شود. pool = min( Question.objects.filter(lesson__node_id=node_id).count(), NodeQuizView.MAX_SIZE, diff --git a/discount/views.py b/discount/views.py index a10bfad..06e327b 100644 --- a/discount/views.py +++ b/discount/views.py @@ -56,6 +56,9 @@ class GuestAwareDiscountThrottle(ScopedRateThrottle): def _is_usable(discount: DiscountCode) -> bool: + # ⚠️ هر Redemption شمرده می‌شود، حتی روی سفارشی که هرگز پرداخت نشد؛ پس + # سبدِ رهاشده سهمِ max_uses را برای همیشه می‌سوزاند و خودِ همان کاربر هم + # روی سفارشِ تازه‌اش دیگر نمی‌تواند کد بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). return bool( discount.redemptions.count() < discount.max_uses and (discount.expires_at is None or discount.expires_at >= timezone.now()) diff --git a/docs/architecture.md b/docs/architecture.md index 8516ac0..65627ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,9 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م کاربرِ واردشده با شماره کدِ معرف می‌گیرد (`GET /api/affiliate/me`) و کاربرِ تازه کدِ دیگری را ثبت می‌کند (`POST /api/affiliate/track`). **پورسانت فقط روی -اولین پرداختِ واقعیِ کاربرِ معرفی‌شده** حساب می‌شود +اولین پرداختِ واقعیِ کاربرِ معرفی‌شده پس از ثبتِ کد** حساب می‌شود. تازه بودنِ +کاربر سنجیده نمی‌شود: مشتریِ قدیمی هم می‌تواند بعداً کد ثبت کند و تمدیدش پورسانت +بسازد (تصمیمِ محصولیِ باز، بازبینیِ ۲۰۲۶-۰۹-۲۵) (`affiliate.services.record_paid_order`، از داخلِ تأییدِ پرداخت): `commission_amount = مبلغِ پرداختی × commission_rate` و Conversion `paid` می‌شود. مهمان نه کد می‌گیرد نه معرفی‌شده حساب می‌شود (۴۰۳). تسویه‌ی پورسانت با معرف هنوز diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 0ac88b4..fdf24b1 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -36,6 +36,8 @@ | ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | +| ۱۷ | ~~شماره‌ی موبایل نرمال نمی‌شود؛ سقف‌های per-phone با املای دیگر دور زده می‌شوند~~ **بسته شد ۲۰۲۶-۰۹-۲۵** | متوسط | کد | +| ۱۸ | `/api/payment/orders/{id}/pay` بدونِ ورود و بدونِ سنجشِ مالکیت | کم | کد | --- @@ -206,6 +208,43 @@ - **خطر:** هر XSS در سایت هر دو را می‌خواند. - **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`. +## ۱۷. شماره‌ی موبایل نرمال نمی‌شود (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵) + +- **چیست:** `phone` در ورود، درخواستِ OTP و دعوتِ خانواده رشته‌ی خام است + (`accounts/serializers.py`، `max_length=15` بدونِ اعتبارسنجی). `09121234567`، + `+989121234567`، `989121234567` و همان شماره با ارقامِ فارسی چهار «شماره»ی جدا‌اند. +- **خطر:** cooldownِ ۲ دقیقه‌ای و سقفِ ۵ در ساعتِ هر شماره به‌ازای هر املا جداست، پس با + چند IP می‌شود یک گوشی را با پیامک بمباران کرد (وقتی کاوه‌نگار وصل شود، به هزینه‌ی ما)؛ + یک آدم با دو املا دو حسابِ جدا می‌سازد؛ دعوتِ والد با املای دیگر ۴۰۳ می‌گیرد. + (بازتولید شد با تستِ موقت؛ پیامکِ واقعی فرستاده نشد.) +- **رفع:** یک تابعِ `normalize_phone` (ارقامِ فارسی/عربی → لاتین، `+98`/`0098`/`98` → `0`، + فقط `09\d{9}`) در هر سه serializer، و migrationِ داده برای ردیف‌های موجود. +- **چطور بسنجید:** دو درخواستِ OTP پشتِ‌سرِهم با `0912…` و `+98912…` باید دومی ۴۲۹ بگیرد. +- **✅ بسته شد ۲۰۲۶-۰۹-۲۵** (همان روزی که کلیدِ کاوه‌نگار روی سرور رفت؛ کامیت: _پس از کامیت + اینجا بنویسید_). `accounts/phone.py::normalize_phone` در ورود، درخواستِ OTP، دعوتِ + خانواده و کلیدهای `TEST_LOGIN_PHONES`؛ migrationِ `accounts.0005` شماره‌های موجود را + نرمال می‌کند (ردیفی که شکلِ نرمالش مالِ کاربرِ دیگری است دست نمی‌خورد و فقط شمرده می‌شود). + `parentPhone` در پروفایل هنوز نرمال **نمی‌شود**. همراهش، چون از این تاریخ پیامکِ واقعی + خرجِ اعتبار است: + - cooldownِ ۲ دقیقه‌ای و سقفِ روزانه‌ی هر شماره (`OTP_REQUEST_PHONE_DAILY_MAX=10`) + حالا در **دیتابیس** هم شمرده می‌شوند، پس روی ۳ workerِ سرور سه‌برابر نمی‌شوند (بندِ ۹ + برای این دو دیگر صدق نمی‌کند؛ برای throttleهای IP هنوز می‌کند). + - سقفِ روزانه‌ی کلِ پیامکِ OTP (`OTP_SMS_GLOBAL_DAILY_MAX=300`) جلوی SMS pumping؛ + رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد. با رشدِ کاربرِ واقعی از `.env` بالا ببرید. + - سقف‌های per-IP گشاده‌تر شدند (کد ۲۰، ورود ۳۰، مهمان ۶۰ در ساعت، همه از `.env` قابلِ + تغییر) چون پشتِ CGNAT و وای‌فایِ مدرسه ده‌ها نفر یک IP دارند. + - تست‌ها: `accounts/tests_otp_limits.py`. هم‌زمانیِ واقعیِ دو پروسه‌ی gunicorn تست نشده. + +## ۱۸. صفحه‌ی پرداخت مالکیت را نمی‌سنجد (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵) + +- **چیست:** `PaymentRedirectView` (`AllowAny`) برای هر شناسه‌ی سفارشِ ترتیبی از زرین‌پال + authority می‌گیرد. +- **خطر:** غریبه با پیمایشِ `/orders/1/pay`، `/orders/2/pay`… روی سفارش‌های باز authority + می‌نشاند؛ از آن به بعد صاحبِ سفارش «کدِ تخفیف را پیش از درگاه وارد کنید» می‌گیرد + (بازتولید شد، با زرین‌پالِ mock). وضعیتِ paid/failedِ سفارشِ دیگران هم از ریدایرکت پیداست. +- **رفع:** در `paymentUrl` یک توکنِ امضاشده‌ی کوتاه‌عمر (`signing`) با `order_id` بگذارید و + همان‌جا بسنجید؛ یا شناسه‌ی غیرترتیبی. + --- ## چیزهایی که بسته شده‌اند (برای اینکه دوباره باز نشوند) diff --git a/family/serializers.py b/family/serializers.py index 9c8e8d9..87ff7d2 100644 --- a/family/serializers.py +++ b/family/serializers.py @@ -1,8 +1,10 @@ from rest_framework import serializers +from accounts.serializers import IranMobileField + class FamilyLinkInviteRequestSerializer(serializers.Serializer): - childPhone = serializers.CharField(max_length=15) + childPhone = IranMobileField() class FamilyLinkInviteResponseSerializer(serializers.Serializer): @@ -47,5 +49,8 @@ class FamilyWeeklyReportResponseSerializer(serializers.Serializer): weekStart = serializers.DateField() xpEarned = serializers.IntegerField() lessonsCompleted = serializers.IntegerField() + # افزوده ۲۰۲۶-۰۹-۲۵: جزیره‌هایی که آزمونشان در این هفته تمام شده. xpEarned + # از همین تاریخ امتیازِ آزمونِ جزیره را هم دارد. + islandsCompleted = serializers.IntegerField() currentStreak = serializers.IntegerField() openedAt = serializers.DateTimeField(allow_null=True) diff --git a/family/tests.py b/family/tests.py index 28d5d36..2c1890e 100644 --- a/family/tests.py +++ b/family/tests.py @@ -24,10 +24,10 @@ class FamilyLinkAcceptThrottleTests(TestCase): self.client = APIClient() self.parent = User.objects.create(device_id='device-family-parent-1', is_guest=True, name='') self.child = User.objects.create( - device_id='device-family-child-1', is_guest=True, name='', phone='+989120000001', + device_id='device-family-child-1', is_guest=True, name='', phone='09120000001', ) self.child_token = issue_access_token(self.child.id) - self.registered_user = User.objects.create(is_guest=False, name='', phone='+989120000098') + self.registered_user = User.objects.create(is_guest=False, name='', phone='09120000098') self.registered_token = issue_access_token(self.registered_user.id) self.link = FamilyLink.objects.create( parent_user_id=self.parent.id, child_phone=self.child.phone, invite_code='123456', @@ -60,7 +60,7 @@ class FamilyLinkAcceptThrottleTests(TestCase): def test_throttle_is_scoped_per_user_not_shared_globally(self): # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). other_user = User.objects.create( - is_guest=False, name='', phone='+989120000099', + is_guest=False, name='', phone='09120000099', ) other_token = issue_access_token(other_user.id) @@ -153,11 +153,11 @@ class FamilyLinkAcceptGuestThrottleBypassTests(TestCase): # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle # برای اونا هنوز بر اساسِ user.pk است، نه IP. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000201') + user_a = User.objects.create(is_guest=False, name='', phone='09120000201') response_a = self._accept('000001', issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 404) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000202') + user_b = User.objects.create(is_guest=False, name='', phone='09120000202') response_b = self._accept('000002', issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 404) @@ -168,7 +168,7 @@ class FamilyLinkAcceptGuestThrottleBypassTests(TestCase): # برسه — throttle باید *قبل* از منطقِ accept رد کنه. parent = User.objects.create(device_id='device-family-guest-rot-parent', is_guest=True, name='') child = User.objects.create( - device_id='device-family-guest-rot-child', is_guest=True, name='', phone='+989120000203', + device_id='device-family-guest-rot-child', is_guest=True, name='', phone='09120000203', ) link = FamilyLink.objects.create( parent_user_id=parent.id, child_phone=child.phone, invite_code='654321', @@ -217,55 +217,55 @@ class FamilyLinkInviteThrottleTests(TestCase): ) def test_unauthenticated_request_is_rejected(self): - response = self._invite('+989120001001') + response = self._invite('09120001001') self.assertEqual(response.status_code, 401) def test_requests_within_the_limit_still_succeed(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}): for i in range(3): - response = self._invite(f'+98912000110{i}', token=self.token) + response = self._invite(f'0912000110{i}', token=self.token) self.assertEqual(response.status_code, 200) self.assertIn('inviteCode', response.data) def test_excessive_invite_requests_from_same_user_are_blocked(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}): for i in range(3): - response = self._invite(f'+98912000120{i}', token=self.token) + response = self._invite(f'0912000120{i}', token=self.token) self.assertEqual(response.status_code, 200) - blocked_response = self._invite('+989120001299', token=self.token) + blocked_response = self._invite('09120001299', token=self.token) self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): # کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. - registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300') + registered_parent = User.objects.create(is_guest=False, name='', phone='09120001300') registered_token = issue_access_token(registered_parent.id) with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}): for i in range(3): response = self._invite( - f'+98912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}', + f'0912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}', ) self.assertEqual(response.status_code, 200) - blocked_response = self._invite('+989120001399', token=registered_token, remote_addr='10.5.0.99') + blocked_response = self._invite('09120001399', token=registered_token, remote_addr='10.5.0.99') self.assertEqual(blocked_response.status_code, 429) def test_throttled_request_does_not_create_family_link(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}): - first_response = self._invite('+989120001401', token=self.token) + first_response = self._invite('09120001401', token=self.token) self.assertEqual(first_response.status_code, 200) link_count_before = FamilyLink.objects.filter(parent_user_id=self.parent.id).count() - blocked_response = self._invite('+989120001402', token=self.token) + blocked_response = self._invite('09120001402', token=self.token) self.assertEqual(blocked_response.status_code, 429) self.assertEqual( FamilyLink.objects.filter(parent_user_id=self.parent.id).count(), link_count_before, ) self.assertFalse( - FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='+989120001402').exists(), + FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='09120001402').exists(), ) @@ -295,39 +295,39 @@ class FamilyLinkInviteGuestThrottleBypassTests(TestCase): def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '2/hour'}): guest1 = User.objects.create(device_id='device-family-invite-guest-rot-1', is_guest=True, name='') - response1 = self._invite('+989120001501', issue_access_token(guest1.id)) + response1 = self._invite('09120001501', issue_access_token(guest1.id)) self.assertEqual(response1.status_code, 200) guest2 = User.objects.create(device_id='device-family-invite-guest-rot-2', is_guest=True, name='') - response2 = self._invite('+989120001502', issue_access_token(guest2.id)) + response2 = self._invite('09120001502', issue_access_token(guest2.id)) self.assertEqual(response2.status_code, 200) # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. guest3 = User.objects.create(device_id='device-family-invite-guest-rot-3', is_guest=True, name='') - response3 = self._invite('+989120001503', issue_access_token(guest3.id)) + response3 = self._invite('09120001503', issue_access_token(guest3.id)) self.assertEqual(response3.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120001601') - response_a = self._invite('+989120001701', issue_access_token(user_a.id)) + user_a = User.objects.create(is_guest=False, name='', phone='09120001601') + response_a = self._invite('09120001701', issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120001602') - response_b = self._invite('+989120001702', issue_access_token(user_b.id)) + user_b = User.objects.create(is_guest=False, name='', phone='09120001602') + response_b = self._invite('09120001702', issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 200) def test_blocked_guest_request_does_not_create_family_link(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}): guest1 = User.objects.create(device_id='device-family-invite-guest-rot-4', is_guest=True, name='') - first_response = self._invite('+989120001801', issue_access_token(guest1.id)) + first_response = self._invite('09120001801', issue_access_token(guest1.id)) self.assertEqual(first_response.status_code, 200) guest2 = User.objects.create(device_id='device-family-invite-guest-rot-5', is_guest=True, name='') - blocked_response = self._invite('+989120001802', issue_access_token(guest2.id)) + blocked_response = self._invite('09120001802', issue_access_token(guest2.id)) self.assertEqual(blocked_response.status_code, 429) self.assertFalse(FamilyLink.objects.filter(parent_user_id=guest2.id).exists()) diff --git a/family/views.py b/family/views.py index fc871ca..8cbd760 100644 --- a/family/views.py +++ b/family/views.py @@ -151,6 +151,8 @@ class FamilyLinkAcceptView(APIView): ) # اگر دعوت برای شماره‌ی مشخصی صادر شده باشد، فقط همان شماره می‌تواند accept کند. + # ⚠️ مقایسه‌ی رشته‌ی خام است: دعوت با +98912… را صاحبِ 0912… نمی‌تواند + # بپذیرد (۴۰۳، بازتولید شد). مهمان (phone=None) هم هیچ دعوتی را نمی‌پذیرد. if link.child_phone and link.child_phone != request.user.phone: raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.') @@ -284,6 +286,11 @@ class FamilyWeeklyReportView(APIView): جنگویی‌ه، همون «خوندن» با import مستقیم مدل‌های آن دو اپ پیاده‌سازی شده — دقیقاً همون الگویی که progress/views.py (LeaderboardView) برای خوندن نام کاربر از accounts.User استفاده می‌کنه. + + امتیاز هم از درس‌ها و هم از آزمونِ جزیره (UserNodeCompletion) جمع می‌شود. + پیش از ۲۰۲۶-۰۹-۲۵ فقط درس‌ها شمرده می‌شدند و فرزندی که با آزمونِ جزیره (مسیرِ + اصلیِ بازیِ فرانت) پیش می‌رفت برای والد «۰ امتیاز» دیده می‌شد. + ⚠️ ParentalSettings فقط ذخیره می‌شود؛ هیچ‌جای کد اعمالش نمی‌کند. """ permission_classes = [IsAuthenticated] @@ -308,14 +315,21 @@ class FamilyWeeklyReportView(APIView): related_id=log.id, ) - from content.models import UserLessonCompletion + from content.models import UserLessonCompletion, UserNodeCompletion from progress.models import StreakRecord completions = UserLessonCompletion.objects.filter( user_id=link.child_user_id, created_at__date__gte=week_start ) - xp_earned = completions.aggregate(total=Sum('xp_earned'))['total'] or 0 + islands = UserNodeCompletion.objects.filter( + user_id=link.child_user_id, created_at__date__gte=week_start + ) + xp_earned = ( + (completions.aggregate(total=Sum('xp_earned'))['total'] or 0) + + (islands.aggregate(total=Sum('xp_earned'))['total'] or 0) + ) lessons_completed = completions.count() + islands_completed = islands.values('node_id').distinct().count() streak = StreakRecord.objects.filter(user_id=link.child_user_id).first() current_streak = streak.current_streak if streak else 0 @@ -328,6 +342,7 @@ class FamilyWeeklyReportView(APIView): 'weekStart': week_start, 'xpEarned': xp_earned, 'lessonsCompleted': lessons_completed, + 'islandsCompleted': islands_completed, 'currentStreak': current_streak, 'openedAt': log.opened_at, } diff --git a/payment/views.py b/payment/views.py index 4f9dcbd..e4c055e 100644 --- a/payment/views.py +++ b/payment/views.py @@ -125,6 +125,11 @@ class PaymentRedirectView(APIView): URL باز شود authority ساخته و روی سفارش ذخیره می‌شود؛ بازکردنِ دوباره (مثلاً دکمه‌ی back مرورگر) همان authority قبلی را دوباره استفاده می‌کند، نه یک PaymentRequest تازه. + + ⚠️ AllowAny است و مالکیتِ سفارش را نمی‌سنجد (مرورگر هدرِ Bearer نمی‌فرستد)؛ + شناسه‌ی سفارش ترتیبی است. هر کسی با باز کردنِ `/orders//pay` برای سفارشِ + دیگران authority می‌سازد و از آن به بعد صاحبِ سفارش دیگر نمی‌تواند کدِ + تخفیف بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). """ permission_classes = [AllowAny] diff --git a/payment/zarinpal.py b/payment/zarinpal.py index b406b92..540743c 100644 --- a/payment/zarinpal.py +++ b/payment/zarinpal.py @@ -120,12 +120,23 @@ def verify_payment(*, authority: str, amount: int, currency: str) -> ZarinpalVer raise ZarinpalAPIError() try: - result_data = resp.json()['data'] - code = result_data.get('code') + body = resp.json() + result_data = body['data'] except (ValueError, KeyError, TypeError): logger.error('Zarinpal payment-verify response malformed: status=%s', resp.status_code) raise ZarinpalAPIError() + # زرین‌پال روی خطا (مثلاً -51 پرداختِ ناموفق) `"data": []` و کدِ خطا را در + # `errors` برمی‌گرداند. این یک «رد»ِ صریح است، نه خرابیِ شبکه، پس مثلِ هر کدِ + # غیرِ ۱۰۰/۱۰۱ نامعتبر حساب می‌شود. پیش از ۲۰۲۶-۰۹-۲۵ اینجا `[].get` کرش + # می‌کرد و کاربر به‌جای برگشت به اپ صفحه‌ی ۵۰۰ می‌دید. با بدنه‌ی مستندِ + # زرین‌پال تست شده، نه با درگاهِ واقعی. + if not isinstance(result_data, dict): + errors = body.get('errors') if isinstance(body, dict) else None + logger.warning('Zarinpal payment-verify rejected: errors=%s', errors) + return ZarinpalVerifyResult(is_valid=False, raw=body if isinstance(body, dict) else None) + code = result_data.get('code') + # ۱۰۰ = تاییدِ موفقِ تازه، ۱۰۱ = قبلاً تایید شده (idempotent از سمتِ زرین‌پال) — هر دو معتبرن. if code in (100, 101): return ZarinpalVerifyResult(is_valid=True, ref_id=str(result_data.get('ref_id', '')), raw=result_data) diff --git a/subscription/services.py b/subscription/services.py index e1defb7..917b3d2 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -65,16 +65,29 @@ def grant_free_subscription(user_id: int, *, duration_days: int | None = None, e مستقل از caller اتمیک است. ردیف Subscription قبل از خواندن/تغییر با select_for_update قفل می‌شود تا فراخوانی‌های هم‌زمان lost update نسازند. + + اشتراکِ فعالی که دیرتر تمام می‌شود (یا نامحدود است) کوتاه نمی‌شود؛ آفر فقط + وقتی اثر دارد که بیشتر از چیزی که کاربر دارد بدهد. پیش از ۲۰۲۶-۰۹-۲۵ کاربری + که سالانه خریده بود و بعد کدِ آفرِ ۷ روزه می‌زد (یا مهمانی که خریده بود و بعد + ثبت‌نام می‌کرد و آفرِ خودکارِ ثبت‌نام می‌گرفت) با ۷ روز می‌ماند. """ with transaction.atomic(): Subscription.objects.get_or_create(user_id=user_id) subscription = Subscription.objects.select_for_update().get(user_id=user_id) old_status = subscription.status - subscription.status = 'active' - subscription.expires_at = ( + offered_until = ( timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None ) + still_active = old_status == 'active' and ( + subscription.expires_at is None or subscription.expires_at > timezone.now() + ) + keeps_longer = still_active and offered_until is not None and ( + subscription.expires_at is None or subscription.expires_at >= offered_until + ) + subscription.status = 'active' + if not keeps_longer: + subscription.expires_at = offered_until subscription.save(update_fields=['status', 'expires_at']) status_changed.send( diff --git a/subscription/views.py b/subscription/views.py index 60160ad..d05b1de 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -112,8 +112,9 @@ class PurchaseView(APIView): plan = get_object_or_404(Plan, pk=v['planId']) idempotency_key = request.headers.get('Idempotency-Key') - # Three-phase flow so create_order (which may call an external payment - # gateway) never runs inside a DB transaction/lock: phase 1 creates/updates + # Three-phase flow so create_order never runs inside a DB transaction/lock. + # (create_order itself no longer calls Zarinpal — PaymentRedirectView + # does, on first visit — so this split is now defensive.) Phase 1 creates/updates # the pending Subscription, phase 2 calls create_order and restores the # prior state on failure, phase 3 attaches order_id atomically. existing_order = None