252 lines
9.6 KiB
Python
252 lines
9.6 KiB
Python
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)
|