Files
MathApp/accounts/views.py

387 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Session, User
from .services import OTPDeliveryError, send_otp_sms
from .serializers import (
GuestSessionRequestSerializer,
GuestSessionResponseSerializer,
LoginRequestSerializer,
LoginResponseSerializer,
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': ''},
)
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'])
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)
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)