harden OTP for real SMS and close the second audit round

Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 12:28:06 +03:30
parent 74aa9aa8ca
commit 0580aec7b0
24 changed files with 685 additions and 119 deletions

View File

@@ -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),
]

31
accounts/phone.py Normal file
View File

@@ -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

View File

@@ -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 ----

View File

@@ -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:
"""

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]