Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
@@ -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