Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

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

View File

@@ -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=''),

View File

@@ -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
View 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 با خطا مواجه شد.')

View File

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

View File

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