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:
40
accounts/migrations/0005_normalize_user_phones.py
Normal file
40
accounts/migrations/0005_normalize_user_phones.py
Normal 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
31
accounts/phone.py
Normal 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
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
130
accounts/tests_otp_limits.py
Normal file
130
accounts/tests_otp_limits.py
Normal 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)
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user