throttling, abuse-protection & logging/monitoring pass
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
import math
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
@@ -14,7 +16,7 @@ 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 .services import OTPDeliveryError, send_otp_sms
|
||||
from .serializers import (
|
||||
GuestSessionRequestSerializer,
|
||||
GuestSessionResponseSerializer,
|
||||
@@ -28,6 +30,32 @@ from .serializers import (
|
||||
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 — ایجاد سشن مهمان"""
|
||||
@@ -81,6 +109,79 @@ class RequestOTPView(APIView):
|
||||
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)
|
||||
|
||||
@@ -91,7 +192,18 @@ class RequestOTPView(APIView):
|
||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
||||
)
|
||||
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
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 = 'در پروداکشن این کد پیامک میشه.'
|
||||
@@ -135,6 +247,14 @@ class LoginView(APIView):
|
||||
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,
|
||||
@@ -190,6 +310,15 @@ class LoginView(APIView):
|
||||
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,
|
||||
@@ -232,6 +361,12 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user