Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
@@ -6,5 +6,5 @@ class AccountsConfig(AppConfig):
|
||||
name = 'accounts'
|
||||
|
||||
def ready(self):
|
||||
# ثبت OpenApiAuthenticationExtension برای دکمهی Authorize توی Swagger UI
|
||||
# Registers OpenApiAuthenticationExtension for the Swagger UI Authorize button.
|
||||
from . import schema # noqa: F401
|
||||
|
||||
@@ -21,18 +21,7 @@ class User(models.Model):
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
# 🔒 اصلاح race condition: قبلاً device_id هیچ قید یکتاییِ سطح
|
||||
# DB نداشت، پس User.objects.get_or_create(device_id=...) در
|
||||
# GuestSessionView در برابر دو درخواستِ همزمان با همون
|
||||
# device_id، میتونست دو کاربرِ مهمانِ جدا بسازه (هر دو create
|
||||
# میشدن چون هیچکدوم قبل از دیگری commit نشده بود که get بعدی
|
||||
# پیداش کنه). این UniqueConstraint (فقط برای کاربرانِ مهمان و
|
||||
# device_id غیرخالی — کاربرانِ ثبتنامشده با phone میتونن
|
||||
# device_id مشترک/خالی داشته باشن) کافیه؛ چون Django's
|
||||
# get_or_create از قبل خودش IntegrityError روی همین constraint
|
||||
# رو میگیره و رکورد موجود رو دوباره get میکنه (نگاه کنید به
|
||||
# پیادهسازی _create_object_from_params در Django)، پس نیازی به
|
||||
# تغییر GuestSessionView نبود.
|
||||
# جلوگیری از ساخت دو کاربر مهمان همزمان با یک device_id یکسان.
|
||||
models.UniqueConstraint(
|
||||
fields=['device_id'],
|
||||
condition=models.Q(is_guest=True) & ~models.Q(device_id=None) & ~models.Q(device_id=''),
|
||||
|
||||
@@ -43,6 +43,7 @@ class LoginResponseSerializer(serializers.Serializer):
|
||||
|
||||
class RefreshResponseSerializer(serializers.Serializer):
|
||||
token = serializers.CharField()
|
||||
refreshToken = serializers.CharField()
|
||||
expiresIn = serializers.IntegerField()
|
||||
|
||||
|
||||
|
||||
99
accounts/services.py
Normal file
99
accounts/services.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
منطق کمکیِ اپ Accounts که از views.py همین اپ صدا زده میشه.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OTPDeliveryError(APIException):
|
||||
"""
|
||||
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ میکرد و همیشه
|
||||
(بیقیدوشرط) به RequestOTPView «موفقیت» برمیگردوند؛ یعنی در پروداکشن
|
||||
(DEBUG=False، جایی که devOtp دیگه توی پاسخ برنمیگرده) هیچ پیامکِ
|
||||
واقعیای ارسال نمیشد ولی API همچنان success=True میداد — کاربرِ واقعی
|
||||
هیچوقت کدش رو نمیگرفت و حتی نمیفهمید که مشکلی پیش اومده. حالا اگه
|
||||
پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائهدهنده شکست
|
||||
بخوره، همین استثنا raise میشه و RequestOTPView یک خطای صریح (نه یک
|
||||
پاسخِ ساختگیِ موفق) برمیگردونه.
|
||||
"""
|
||||
|
||||
status_code = 503
|
||||
default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||||
default_code = 'otp_sms_delivery_failed'
|
||||
|
||||
|
||||
def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
"""
|
||||
ارسالِ واقعیِ پیامکِ OTP از طریق کاوهنگار (kavenegar.com) — دقیقاً همون
|
||||
ارائهدهندهای که در نسخهی قبلی (استاب) بهعنوان نمونه اشاره شده بود.
|
||||
کلید API هیچوقت داخل کد نیست؛ همیشه از env خونده میشه
|
||||
(settings.KAVENEGAR_API_KEY). دو حالت:
|
||||
|
||||
- DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ میکنه و هیچ پیامکِ
|
||||
واقعیای نمیفرسته — چون در این محیط، RequestOTPView خودِ کد رو در
|
||||
فیلدِ devOtp پاسخ برمیگردونه و SMS واقعی لازم نیست (رفتار قبلی dev
|
||||
حفظ شده).
|
||||
- DEBUG=False (production): اگه KAVENEGAR_API_KEY تنظیم نشده باشه، یا
|
||||
درخواست به کاوهنگار fail بشه/کدِ غیر ۲xx برگردونه، بهجای گزارشِ
|
||||
خاموشِ «موفقیت»، OTPDeliveryError raise میشه (کاربر پاسخ ۵۰۳ صریح
|
||||
میگیره، نه یک success دروغین).
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
logger.info('OTP sms dispatch (dev, no real SMS sent): phone=%s code_len=%d', phone, len(code))
|
||||
return
|
||||
|
||||
if not settings.KAVENEGAR_API_KEY:
|
||||
logger.error('OTP SMS misconfigured: KAVENEGAR_API_KEY is not set (phone=%s)', phone)
|
||||
raise OTPDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
|
||||
|
||||
message = settings.OTP_SMS_TEMPLATE.format(code=code)
|
||||
payload = {'receptor': phone, 'message': message}
|
||||
if settings.KAVENEGAR_SENDER:
|
||||
payload['sender'] = settings.KAVENEGAR_SENDER
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/sms/send.json',
|
||||
data=payload,
|
||||
timeout=5,
|
||||
)
|
||||
except requests.RequestException:
|
||||
logger.exception('OTP SMS request to provider failed: phone=%s', phone)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
'OTP SMS provider returned non-200: phone=%s status=%s body=%s',
|
||||
phone, response.status_code, response.text[:500],
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
|
||||
# ⚠️ اصلاح: کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر،
|
||||
# اعتبار ناکافی، گیرندهی نامعتبر و…) HTTP 200 برمیگردونه؛ نتیجهی
|
||||
# واقعی توی بدنهی JSON، زیرِ return.status هست (200 یعنی موفق). قبلاً
|
||||
# همینِ status_code == 200 بهتنهایی «موفقیت» در نظر گرفته میشد، یعنی
|
||||
# یک رد شدنِ واقعی از طرفِ کاوهنگار هم success=True دروغین برمیگردوند.
|
||||
# حالا بدنه parse میشه و فقط return.status == 200 موفقیت محسوب میشه؛
|
||||
# هر پاسخِ ناقص/غیرمنتظره هم شکستِ تحویل در نظر گرفته میشه (نه موفقیت).
|
||||
try:
|
||||
body = response.json()
|
||||
provider_status = body['return']['status']
|
||||
except (ValueError, KeyError, TypeError):
|
||||
logger.error(
|
||||
'OTP SMS provider returned malformed response body: phone=%s body=%s',
|
||||
phone, response.text[:500],
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
|
||||
if provider_status != 200:
|
||||
logger.error(
|
||||
'OTP SMS provider rejected message at application level: phone=%s '
|
||||
'provider_status=%s body=%s',
|
||||
phone, provider_status, response.text[:500],
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
@@ -1,3 +1,341 @@
|
||||
from django.test import TestCase
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Create your tests here.
|
||||
from django.conf import settings
|
||||
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 rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from .models import OTPCode, Session, User
|
||||
from .services import OTPDeliveryError, send_otp_sms
|
||||
|
||||
|
||||
class SendOtpSmsTests(TestCase):
|
||||
"""
|
||||
تستهای واحد برای accounts.services.send_otp_sms — تمرکز روی همون رفتاری
|
||||
که این fix اضافه میکنه: در dev فقط لاگ، در پروداکشن یا واقعاً ارسال یا
|
||||
خطای صریح (هیچوقت موفقیتِ خاموش/ساختگی).
|
||||
"""
|
||||
|
||||
@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')
|
||||
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')
|
||||
mock_post.assert_not_called()
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key', KAVENEGAR_SENDER='')
|
||||
def test_production_success_calls_provider(self):
|
||||
# پاسخِ موفقِ کاوهنگار: HTTP 200 + return.status == 200 در بدنه.
|
||||
mock_response = Mock(
|
||||
status_code=200,
|
||||
text='{"return": {"status": 200, "message": "OK"}, "entries": []}',
|
||||
)
|
||||
mock_response.json.return_value = {
|
||||
'return': {'status': 200, 'message': 'OK'},
|
||||
'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')
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
self.assertIn('test-key', args[0])
|
||||
self.assertEqual(kwargs['data']['receptor'], '+989120000000')
|
||||
self.assertIn('1234', kwargs['data']['message'])
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_provider_error_status_raises(self):
|
||||
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')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_http_200_with_application_level_rejection_raises(self):
|
||||
# کاوهنگار حتی رد کردنِ اپلیکیشنی (مثلاً کلیدِ نامعتبر) رو با
|
||||
# HTTP 200 برمیگردونه؛ return.status غیر از 200 باید شکست باشه.
|
||||
mock_response = Mock(
|
||||
status_code=200,
|
||||
text='{"return": {"status": 401, "message": "Invalid API key"}, "entries": []}',
|
||||
)
|
||||
mock_response.json.return_value = {
|
||||
'return': {'status': 401, 'message': 'Invalid API key'},
|
||||
'entries': [],
|
||||
}
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_malformed_provider_body_raises(self):
|
||||
# بدنهی نامعتبر/غیرمنتظره (نه JSON، یا بدونِ ساختارِ return.status)
|
||||
# باید شکستِ تحویل در نظر گرفته بشه، نه موفقیتِ خاموش.
|
||||
mock_response = Mock(status_code=200, text='not json')
|
||||
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')
|
||||
|
||||
@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')
|
||||
|
||||
|
||||
class RequestOTPViewTests(TestCase):
|
||||
"""
|
||||
تستهای سطح API برای RequestOTPView: مطمئن میشیم رفتارِ dev قبلی حفظ
|
||||
شده و در پروداکشن، شکستِ تحویلِ پیامک باعثِ یک خطای صریح میشه (نه
|
||||
success=True دروغین) در حالی که ردیفِ OTPCode همچنان ساخته شده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
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',
|
||||
)
|
||||
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())
|
||||
|
||||
@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',
|
||||
)
|
||||
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())
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_generated_otp_is_always_a_4_digit_string_with_leading_zeros(self):
|
||||
# ⚠️ فیکسِ این تست: تولیدِ OTP از random.randint به secrets.randbelow
|
||||
# تغییر کرده؛ این تست مطمئن میشه فرمتِ ۴ رقمی (با صفرهای ابتدایی
|
||||
# حفظشده، نه یک عددِ کوتاهتر) دستنخورده مونده.
|
||||
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',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['devOtp'], '0007')
|
||||
otp = OTPCode.objects.get(phone='+989120000003')
|
||||
self.assertEqual(otp.code, '0007')
|
||||
self.assertEqual(len(otp.code), 4)
|
||||
self.assertTrue(otp.code.isdigit())
|
||||
|
||||
|
||||
class RequestOTPStaleInvalidationTests(TestCase):
|
||||
"""
|
||||
تستهای متمرکز روی فیکسِ stale OTP: قبل از ساختِ OTPCode جدید، تمامِ
|
||||
OTPCode های is_used=False قبلیِ همون شماره باید invalid (is_used=True)
|
||||
بشن — طوری که فقط آخرین کدِ صادرشده برای همون شماره برای ورود کار کنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
# throttle (ScopedRateThrottle) از همون cache پیشفرضِ جنگو استفاده
|
||||
# میکنه؛ بین تستها پاکش میکنیم تا شمارندهی یک تست روی تستِ بعدی
|
||||
# اثر نذاره.
|
||||
cache.clear()
|
||||
|
||||
def _request_otp(self, phone, code):
|
||||
# با patch کردنِ secrets.randbelow، کدِ هر درخواست معلوم و
|
||||
# قابلپیشبینیه (بهجای اتکا به شانسِ برخورد نکردنِ دو کدِ تصادفی).
|
||||
with patch('accounts.views.secrets.randbelow', return_value=int(code)):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['devOtp'], code)
|
||||
return code
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_second_request_invalidates_first_otp(self):
|
||||
phone = '+989121110001'
|
||||
first_code = self._request_otp(phone, '1111')
|
||||
first_otp = OTPCode.objects.get(phone=phone, code=first_code)
|
||||
self.assertFalse(first_otp.is_used)
|
||||
|
||||
self._request_otp(phone, '2222')
|
||||
|
||||
first_otp.refresh_from_db()
|
||||
self.assertTrue(first_otp.is_used)
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_newest_otp_still_works_for_login(self):
|
||||
phone = '+989121110002'
|
||||
self._request_otp(phone, '1111')
|
||||
second_code = self._request_otp(phone, '2222')
|
||||
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': second_code}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_older_otp_is_rejected_after_new_request(self):
|
||||
phone = '+989121110003'
|
||||
first_code = self._request_otp(phone, '1111')
|
||||
self._request_otp(phone, '2222')
|
||||
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': first_code}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class RefreshTokenReplayTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ replay: یک refresh token معتبر باید فقط یکبار قابل
|
||||
استفاده باشه. بعد از یک رفرشِ موفق، همون توکن دیگه نباید کار کنه —
|
||||
حتی بدون rotation، مصرفِ همون توکن دوباره باید 401 بگیره.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(is_guest=True, device_id='device-refresh-test')
|
||||
self.session = Session.objects.create(
|
||||
user=self.user,
|
||||
refresh_token='test-refresh-token-abc123',
|
||||
device_id='device-refresh-test',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=30),
|
||||
revoked=False,
|
||||
)
|
||||
|
||||
def test_valid_refresh_token_succeeds(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['token'])
|
||||
|
||||
def test_same_refresh_token_rejected_on_second_use(self):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
second_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(second_response.status_code, 401)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertTrue(self.session.revoked)
|
||||
|
||||
def test_successful_refresh_returns_new_refresh_token(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
new_refresh_token = response.data['refreshToken']
|
||||
self.assertTrue(new_refresh_token)
|
||||
self.assertNotEqual(new_refresh_token, self.session.refresh_token)
|
||||
self.assertTrue(
|
||||
Session.objects.filter(
|
||||
refresh_token=new_refresh_token, user=self.user, revoked=False,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class LoginPhoneRateLimitTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ بر اساسِ شمارهتلفن روی LoginView: تلاشهای
|
||||
تکراریِ ورود برای همون شماره باید بعد از حدِ مجاز بلاک بشن، حتی اگه
|
||||
throttle_scope='otp_login' (IP-محور) رو دور بزنیم.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
@override_settings(DEBUG=True, OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
|
||||
def test_repeated_attempts_for_same_phone_are_blocked(self):
|
||||
phone = '+989123334444'
|
||||
|
||||
for _ in range(3):
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
|
||||
class GuestSessionThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی
|
||||
GuestSessionView: درخواستهای تکراری از همون کلاینت (IP) باید بعد از
|
||||
حدِ مجاز با ۴۲۹ رد بشن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
**settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'],
|
||||
'guest_session': '3/hour',
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_repeated_guest_session_requests_are_eventually_throttled(self):
|
||||
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست دو مشکلِ جدا
|
||||
# داشت:
|
||||
# ۱) `type` رو نمیفرستاد (پایینتر فیکس شده) — قراردادِ واقعیِ
|
||||
# API طبقِ README نیازش داره، پس هر درخواست قبل از throttle
|
||||
# با 400 رد میشد.
|
||||
# ۲) override_settings(REST_FRAMEWORK=...) بهتنهایی رفتارِ
|
||||
# ScopedRateThrottle رو تغییر نمیده: DRF مقدارِ
|
||||
# THROTTLE_RATES رو یکبار موقعِ import ماژولِ throttling
|
||||
# بهعنوانِ یک class attribute از api_settings.
|
||||
# DEFAULT_THROTTLE_RATES میگیره (همون دیکشنریِ اصلیِ
|
||||
# settings.py، با reference). override_settings یک دیکشنریِ
|
||||
# *جدید* برای settings.REST_FRAMEWORK میسازه، اما این
|
||||
# class attribute هنوز به همون دیکشنریِ قدیمی اشاره میکنه —
|
||||
# سیگنالِ setting_changed فقط api_settings رو reload میکنه،
|
||||
# نه این class attribute رو. نتیجه: نرخِ واقعیِ اعمالشده
|
||||
# همیشه همون دیفالتِ 20/hour در settings.py میموند، نه
|
||||
# 3/hour، پس ۵ درخواست هیچوقت به سقف نمیرسیدن. برای اینکه
|
||||
# این تست واقعاً نرخِ محدودشده رو بسنجه، مستقیماً همون
|
||||
# دیکشنریِ زندهی ScopedRateThrottle.THROTTLE_RATES رو
|
||||
# patch.dict میکنیم (که override_settings بالا هم برای
|
||||
# سازگاری/مستندسازیِ نیت نگه داشته میشه).
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self.client.post(
|
||||
'/api/auth/session',
|
||||
{'type': 'guest', 'deviceId': f'device-{i}'},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/session',
|
||||
{'type': 'guest', 'deviceId': 'device-blocked'},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import random
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .models import OTPCode, Session, User
|
||||
from .services import send_otp_sms
|
||||
from .serializers import (
|
||||
GuestSessionRequestSerializer,
|
||||
GuestSessionResponseSerializer,
|
||||
@@ -29,6 +32,9 @@ from .serializers import (
|
||||
class GuestSessionView(APIView):
|
||||
"""POST /api/auth/session — ایجاد سشن مهمان"""
|
||||
|
||||
throttle_scope = 'guest_session'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=GuestSessionRequestSerializer,
|
||||
responses=GuestSessionResponseSerializer,
|
||||
@@ -62,6 +68,9 @@ class RequestOTPView(APIView):
|
||||
در محیط توسعه، کد ساختهشده در پاسخ برگردونده میشه تا نیازی به SMS واقعی نباشه.
|
||||
"""
|
||||
|
||||
throttle_scope = 'otp_request'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=RequestOTPRequestSerializer,
|
||||
responses=RequestOTPResponseSerializer,
|
||||
@@ -72,16 +81,26 @@ class RequestOTPView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
phone = serializer.validated_data['phone']
|
||||
|
||||
code = f'{random.randint(0, 9999):04d}'
|
||||
# فقط آخرین کد صادرشده معتبر است.
|
||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||
|
||||
code = f'{secrets.randbelow(10000):04d}'
|
||||
OTPCode.objects.create(
|
||||
phone=phone,
|
||||
code=code,
|
||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
||||
)
|
||||
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
|
||||
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
||||
note = 'در پروداکشن این کد پیامک میشه.'
|
||||
if settings.DEV_MASTER_OTP is not None:
|
||||
note = f'در پروداکشن این کد پیامک میشه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول میشه.'
|
||||
data = {
|
||||
'success': True,
|
||||
'devOtp': code,
|
||||
'note': f'در پروداکشن این کد پیامک میشه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول میشه.',
|
||||
'devOtp': code if settings.DEBUG else '',
|
||||
'note': note,
|
||||
}
|
||||
return Response(RequestOTPResponseSerializer(data).data)
|
||||
|
||||
@@ -89,6 +108,9 @@ class RequestOTPView(APIView):
|
||||
class LoginView(APIView):
|
||||
"""POST /api/auth/login — ورود با OTP"""
|
||||
|
||||
throttle_scope = 'otp_login'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=LoginRequestSerializer,
|
||||
responses=LoginResponseSerializer,
|
||||
@@ -100,17 +122,28 @@ class LoginView(APIView):
|
||||
phone = serializer.validated_data['phone']
|
||||
otp = serializer.validated_data['otp']
|
||||
|
||||
# ⚛️🔒 race condition: قبلاً چکِ «is_used=False» و ستِ «is_used=True»
|
||||
# دو عملیاتِ جدا و بدون قفل بودن. دو درخواستِ همزمان با دقیقاً همون
|
||||
# کدِ OTP میتونستن هر دو is_used=False رو ببینن و هر دو valid=True
|
||||
# بگیرن — یعنی یک کدِ یکبارمصرف، دوبار مصرف بشه (و در پیاش، دوبار
|
||||
# وارد شاخهی «ثبتنامِ واقعی» بشیم). حالا ردیفِ OTP قفل میشه
|
||||
# (select_for_update، دقیقاً همون الگویی که در payment/discount
|
||||
# استفاده شده) و چکوست داخل همون قفل انجام میشه، تا کل ورود
|
||||
# (چک OTP + get_or_create کاربر + سشن) یک واحدِ اتمیکِ قفلشده باشه.
|
||||
# محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا).
|
||||
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
|
||||
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
else:
|
||||
try:
|
||||
attempt_count = cache.incr(phone_attempts_key)
|
||||
except ValueError:
|
||||
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||||
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
|
||||
attempt_count = 1
|
||||
|
||||
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
|
||||
return Response(
|
||||
{'detail': 'تعداد تلاشهای ورود برای این شماره بیش از حد مجاز است.'},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
# قفل ردیف OTP جلوی مصرف دوبارهی یک کد یکبارمصرف را در درخواستهای همزمان میگیرد.
|
||||
with transaction.atomic():
|
||||
valid = False
|
||||
if otp == settings.DEV_MASTER_OTP:
|
||||
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
|
||||
valid = True
|
||||
else:
|
||||
otp_obj = OTPCode.objects.select_for_update().filter(
|
||||
@@ -124,12 +157,6 @@ class LoginView(APIView):
|
||||
if not valid:
|
||||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# ⚛️ atomic: get_or_create/save روی User، سیگنال user_registered
|
||||
# (که آفرهای auto_assign_on_signup رو از طریق discount اعمال میکنه
|
||||
# و خودش UserOffer/Subscription میسازه) و ساخت Session، همه یک
|
||||
# واحدند. بدون این، مثلاً اگه ساخت Session fail کنه، کاربر واقعاً
|
||||
# «ثبتنامشده» و صاحب آفر رایگان میمونه ولی هیچ سشنی نداره که
|
||||
# باهاش وارد بشه (لاگین باید کامل موفق یا کامل ناموفق باشه).
|
||||
user, created = User.objects.get_or_create(
|
||||
phone=phone,
|
||||
defaults={'is_guest': False, 'name': ''},
|
||||
@@ -139,9 +166,7 @@ class LoginView(APIView):
|
||||
user.save(update_fields=['is_guest'])
|
||||
|
||||
if created:
|
||||
# ثبتنام واقعی (نه guest session): آفرهای auto_assign_on_signup
|
||||
# (مثل یک هفته رایگان) از طریق discount/receivers.py روی همین
|
||||
# سیگنال اعمال میشن؛ این ویو از وجود آفر/discount چیزی نمیدونه.
|
||||
# آفرهای ثبتنام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال میشوند.
|
||||
from .signals import user_registered
|
||||
user_registered.send(sender=User, user_id=user.id)
|
||||
|
||||
@@ -175,14 +200,31 @@ class RefreshTokenView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
refresh_token = serializer.validated_data['refreshToken']
|
||||
|
||||
session = Session.objects.filter(
|
||||
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
|
||||
).first()
|
||||
if not session:
|
||||
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
# UPDATE اتمیک جلوی استفادهی همزمان از یک refresh token را میگیرد (replay protection).
|
||||
with transaction.atomic():
|
||||
updated_count = Session.objects.filter(
|
||||
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
|
||||
).update(revoked=True)
|
||||
|
||||
access_token = issue_access_token(session.user_id)
|
||||
data = {'token': access_token, 'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS}
|
||||
if not updated_count:
|
||||
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
old_session = Session.objects.get(refresh_token=refresh_token)
|
||||
|
||||
new_refresh_token = secrets.token_hex(32)
|
||||
new_session = Session.objects.create(
|
||||
user_id=old_session.user_id,
|
||||
refresh_token=new_refresh_token,
|
||||
device_id=old_session.device_id,
|
||||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
)
|
||||
|
||||
access_token = issue_access_token(new_session.user_id)
|
||||
data = {
|
||||
'token': access_token,
|
||||
'refreshToken': new_refresh_token,
|
||||
'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS,
|
||||
}
|
||||
return Response(RefreshResponseSerializer(data).data)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user