432 lines
21 KiB
Python
432 lines
21 KiB
Python
import logging
|
||
import math
|
||
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, Profile, Session, User
|
||
from .services import OTPDeliveryError, send_otp_sms
|
||
from .serializers import (
|
||
GuestSessionRequestSerializer,
|
||
GuestSessionResponseSerializer,
|
||
LoginRequestSerializer,
|
||
LoginResponseSerializer,
|
||
ProfileResponseSerializer,
|
||
RefreshRequestSerializer,
|
||
RefreshResponseSerializer,
|
||
RequestOTPRequestSerializer,
|
||
RequestOTPResponseSerializer,
|
||
SetGradeRequestSerializer,
|
||
SetGradeResponseSerializer,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class GuestAwareAccountThrottle(ScopedRateThrottle):
|
||
"""
|
||
⚠️ اضافه شده: همون الگویِ GuestAwareContentThrottle (content/views.py)،
|
||
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView
|
||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
||
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
|
||
بود. اینجا برای SetUserGradeView (که مهمانها هم میتونن صداش بزنن)
|
||
از همین کلید بر اساسِ IP برای مهمانها استفاده میشه؛ رفتارِ
|
||
throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||
میمونه.
|
||
"""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
|
||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||
|
||
|
||
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': ''},
|
||
)
|
||
|
||
# ثبتِ فعالیتِ کاربر: ایجاد/استفادهی سشنِ مهمان یک سشنِ واقعیِ
|
||
# کاربره، پس last_activity_at بهروزرسانی میشه (بدونِ تأثیر روی
|
||
# سایرِ رفتارِ endpoint).
|
||
user.last_activity_at = timezone.now()
|
||
user.save(update_fields=['last_activity_at'])
|
||
|
||
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']
|
||
|
||
# حداقل فاصلهی زمانی بین دو درخواست برای همون شماره (burst
|
||
# protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو میگیره، اما بهتنهایی
|
||
# جلویِ این رو نمیگیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر
|
||
# از یک ثانیه مصرف بشن (که هم بهمعنایِ flood پیامکی روی یک شمارهی
|
||
# مشخصه، هم میتونه با invalidate کردنِ پیدرپیِ OTPهای قبلی
|
||
# (رفتارِ «فقط آخرین کد معتبره» که پایینتره) عملاً یک DoS روی
|
||
# کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارندهی
|
||
# ساعتی انجام میشه تا یک درخواستِ بلاکشده توسطِ cooldown سهمیهی
|
||
# ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده
|
||
# (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView).
|
||
#
|
||
# کلید cooldown بهجای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره
|
||
# میکنه؛ اینطوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ
|
||
# هیچ خواندنِ ناامنی میشه دقیقاً محاسبه کرد چند ثانیه تا پایانِ
|
||
# cooldown مونده و اون رو هم در بدنهی پاسخ (retryAfter) و هم در
|
||
# هدرِ استانداردِ Retry-After برگردوند — اینطوری frontend
|
||
# countdown رو دقیقاً از رویِ همین پاسخِ backend شروع میکنه، نه
|
||
# از رویِ یک عددِ hardcodeشدهی سمتِ کلاینت که مرجعِ امنیتی نیست.
|
||
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
|
||
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
|
||
now_epoch = timezone.now().timestamp()
|
||
cooldown_expires_at_epoch = now_epoch + cooldown_seconds
|
||
if not cache.add(phone_cooldown_key, cooldown_expires_at_epoch, timeout=cooldown_seconds):
|
||
stored_expiry_epoch = cache.get(phone_cooldown_key)
|
||
if stored_expiry_epoch is None:
|
||
# بینِ شکستِ add و این get دقیقاً منقضی شده؛ عملاً دیگه
|
||
# cooldownی در کار نیست، اما برای رفتارِ یکدست یک
|
||
# retry_after حداقلی برمیگردونیم (کلاینت با تلاشِ بعدی
|
||
# مسیرِ عادی رو طی میکنه).
|
||
retry_after = 1
|
||
else:
|
||
retry_after = max(1, math.ceil(stored_expiry_epoch - now_epoch))
|
||
logger.warning(
|
||
'OTP request phone cooldown active: phone_suffix=%s retry_after_seconds=%s',
|
||
phone[-4:], retry_after,
|
||
)
|
||
return Response(
|
||
{
|
||
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||
'retryAfter': retry_after,
|
||
},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
headers={'Retry-After': str(retry_after)},
|
||
)
|
||
|
||
# محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ
|
||
# otp_request بالا. throttle IP-محور بهتنهایی کافی نیست چون یک
|
||
# مهاجم میتونه درخواستهای تکراری برای یک شماره رو بین چند IP
|
||
# مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr
|
||
# همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید
|
||
# وجود نداره مقدار میذاره (اتمیک روی سطح backend cache)، پس دو
|
||
# درخواستِ همزمان نمیتونن هر دو از یک non-atomic check رد بشن.
|
||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||
attempt_count = 1
|
||
else:
|
||
try:
|
||
attempt_count = cache.incr(phone_requests_key)
|
||
except ValueError:
|
||
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||
cache.set(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS)
|
||
attempt_count = 1
|
||
|
||
if attempt_count > settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS:
|
||
logger.warning(
|
||
'OTP request phone limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||
phone[-4:], attempt_count, settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS,
|
||
)
|
||
return Response(
|
||
{'detail': 'تعداد درخواستهای کد OTP برای این شماره بیش از حد مجاز است.'},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
)
|
||
|
||
# فقط آخرین کد صادرشده معتبر است.
|
||
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),
|
||
)
|
||
|
||
try:
|
||
send_otp_sms(phone=phone, code=code)
|
||
except OTPDeliveryError:
|
||
# ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست
|
||
# خورده)، پس cooldownِ ۱۲۰ثانیهای که بالاتر برای همین درخواست
|
||
# ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ
|
||
# اینصورت کاربر ۲ دقیقه معطل میمونه بدونِ اینکه اصلاً کدی
|
||
# گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle
|
||
# IP-محور دستنخورده میمونن — اینها محدودیتِ حجمِ کلیان، نه
|
||
# cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن.
|
||
cache.delete(phone_cooldown_key)
|
||
raise
|
||
|
||
# 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:
|
||
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
|
||
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمیشه؛
|
||
# شمارهی تلفن هم کامل نوشته نمیشه، فقط ۴ رقم آخرش (برای
|
||
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
|
||
logger.warning(
|
||
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||
phone[-4:], 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'])
|
||
|
||
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
|
||
user.last_activity_at = timezone.now()
|
||
user.save(update_fields=['last_activity_at'])
|
||
|
||
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 — تمدید توکن"""
|
||
|
||
# این endpoint احرازهویت نمیخواد (خودِ refresh token شناسه است)، پس
|
||
# ScopedRateThrottle طبیعتاً روی IP کلید میخوره. یک توکنِ رفرش عادتاً
|
||
# فقط گاهبهگاه (نه با فرکانسِ بالا) باید استفاده بشه؛ این throttle
|
||
# جلوی سوءاستفاده از این endpoint بهعنوان یک عملیاتِ پرتکرارِ
|
||
# دیتابیس/تولیدِ توکن رو میگیره، بدون تغییری در rotation یا محافظتِ
|
||
# اتمیکِ replay که پایینتر پیادهسازی شده.
|
||
throttle_scope = 'token_refresh'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@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)
|
||
|
||
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
|
||
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
|
||
|
||
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]
|
||
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت؛ یک کاربرِ
|
||
# (از جمله مهمان) احرازهویتشده میتونست تعدادِ نامحدودی درخواستِ
|
||
# نوشتنِ دیتابیس بفرسته. طبقِ همون الگویِ سایرِ اپها، یک
|
||
# throttle_scope مجزا با GuestAwareAccountThrottle اضافه شد.
|
||
throttle_scope = 'user_profile_write'
|
||
throttle_classes = [GuestAwareAccountThrottle]
|
||
|
||
@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)
|
||
|
||
|
||
class UserProfileView(APIView):
|
||
"""GET /api/user/profile — اطلاعات پروفایل کاربر جاری (نیاز به احراز هویت)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(
|
||
responses=ProfileResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def get(self, request):
|
||
user = request.user
|
||
# مدل Profile بهصورت lazy ساخته میشه چون هیچجای دیگهای (نه
|
||
# GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمیسازه؛
|
||
# get_or_create اینجا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
|
||
# زده میشه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
|
||
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||
|
||
data = {
|
||
'userId': str(user.id),
|
||
'phone': user.phone,
|
||
'name': user.name,
|
||
'avatar': user.avatar,
|
||
'isGuest': user.is_guest,
|
||
'gradeId': user.grade_id,
|
||
'birthYear': profile.birth_year,
|
||
'parentPhone': profile.parent_phone,
|
||
'bio': profile.bio,
|
||
}
|
||
return Response(ProfileResponseSerializer(data).data)
|