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, LoginRequestSerializer, LoginResponseSerializer, RefreshRequestSerializer, RefreshResponseSerializer, RequestOTPRequestSerializer, RequestOTPResponseSerializer, SetGradeRequestSerializer, SetGradeResponseSerializer, ) class GuestSessionView(APIView): """POST /api/auth/session — ایجاد سشن مهمان""" throttle_scope = 'guest_session' throttle_classes = [ScopedRateThrottle] @extend_schema( request=GuestSessionRequestSerializer, responses=GuestSessionResponseSerializer, tags=['Identity & Profile (accounts)'], ) def post(self, request): serializer = GuestSessionRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) device_id = serializer.validated_data['deviceId'] user, is_new_user = User.objects.get_or_create( device_id=device_id, is_guest=True, defaults={'name': ''}, ) token = issue_access_token(user.id) data = { 'userId': str(user.id), 'token': token, 'isNewUser': is_new_user, 'isGuest': True, } return Response(GuestSessionResponseSerializer(data).data) class RequestOTPView(APIView): """ POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger) در محیط توسعه، کد ساخته‌شده در پاسخ برگردونده می‌شه تا نیازی به SMS واقعی نباشه. """ throttle_scope = 'otp_request' throttle_classes = [ScopedRateThrottle] @extend_schema( request=RequestOTPRequestSerializer, responses=RequestOTPResponseSerializer, tags=['Identity & Profile (accounts)'], ) def post(self, request): serializer = RequestOTPRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) phone = serializer.validated_data['phone'] # فقط آخرین کد صادرشده معتبر است. 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 if settings.DEBUG else '', 'note': note, } return Response(RequestOTPResponseSerializer(data).data) class LoginView(APIView): """POST /api/auth/login — ورود با OTP""" throttle_scope = 'otp_login' throttle_classes = [ScopedRateThrottle] @extend_schema( request=LoginRequestSerializer, responses=LoginResponseSerializer, tags=['Identity & Profile (accounts)'], ) def post(self, request): serializer = LoginRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) phone = serializer.validated_data['phone'] otp = serializer.validated_data['otp'] # محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از 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 settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP: valid = True else: otp_obj = OTPCode.objects.select_for_update().filter( phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() ).order_by('-id').first() if otp_obj: otp_obj.is_used = True otp_obj.save(update_fields=['is_used']) valid = True if not valid: return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) user, created = User.objects.get_or_create( phone=phone, defaults={'is_guest': False, 'name': ''}, ) if user.is_guest: user.is_guest = False user.save(update_fields=['is_guest']) if created: # آفرهای ثبت‌نام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال می‌شوند. from .signals import user_registered user_registered.send(sender=User, user_id=user.id) refresh_token = uuid.uuid4().hex Session.objects.create( user=user, refresh_token=refresh_token, device_id=request.data.get('deviceId', ''), expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), ) access_token = issue_access_token(user.id) data = { 'userId': str(user.id), 'token': access_token, 'refreshToken': refresh_token, } return Response(LoginResponseSerializer(data).data) class RefreshTokenView(APIView): """POST /api/auth/refresh — تمدید توکن""" @extend_schema( request=RefreshRequestSerializer, responses=RefreshResponseSerializer, tags=['Identity & Profile (accounts)'], ) def post(self, request): serializer = RefreshRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) refresh_token = serializer.validated_data['refreshToken'] # 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) 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) class SetUserGradeView(APIView): """POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)""" permission_classes = [IsAuthenticated] @extend_schema( request=SetGradeRequestSerializer, responses=SetGradeResponseSerializer, tags=['Identity & Profile (accounts)'], ) def post(self, request): serializer = SetGradeRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) grade_id = serializer.validated_data['gradeId'] user = request.user user.grade_id = grade_id user.save(update_fields=['grade_id']) data = {'success': True, 'gradeId': grade_id} return Response(SetGradeResponseSerializer(data).data)