diff --git a/backend/apps/accounts/migrations/0005_phoneotp_device_signal_phoneotp_request_ip_and_more.py b/backend/apps/accounts/migrations/0005_phoneotp_device_signal_phoneotp_request_ip_and_more.py new file mode 100644 index 0000000..8ebbbcb --- /dev/null +++ b/backend/apps/accounts/migrations/0005_phoneotp_device_signal_phoneotp_request_ip_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-03-13 21:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_alter_user_groups_alter_user_phone_number_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='phoneotp', + name='device_signal', + field=models.CharField(blank=True, db_index=True, default='', max_length=64), + ), + migrations.AddField( + model_name='phoneotp', + name='request_ip', + field=models.GenericIPAddressField(blank=True, db_index=True, null=True), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index a659b7a..ca7b9b6 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -97,6 +97,9 @@ class PhoneOTP(models.Model): verified_at = models.DateTimeField(null=True, blank=True) attempt_count = models.PositiveSmallIntegerField(default=0) max_attempts = models.PositiveSmallIntegerField(default=5) + # Request metadata for abuse controls and support investigations. + request_ip = models.GenericIPAddressField(null=True, blank=True, db_index=True) + device_signal = models.CharField(max_length=64, blank=True, default="", db_index=True) def is_expired(self): return timezone.now() >= self.expires_at diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 0b00b27..d080709 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -62,6 +62,7 @@ class OTPVerifySerializer(serializers.Serializer): class PhoneAuthRequestSerializer(serializers.Serializer): phone_number = serializers.CharField(max_length=20) channel = serializers.ChoiceField(choices=OtpChannel.choices) + device_id = serializers.CharField(required=False, allow_blank=True, max_length=128, write_only=True) email = serializers.EmailField(required=False, allow_null=True, allow_blank=True) first_name = serializers.CharField(required=False, allow_blank=True) last_name = serializers.CharField(required=False, allow_blank=True) diff --git a/backend/apps/accounts/services/otp.py b/backend/apps/accounts/services/otp.py index fee893f..17b95ec 100644 --- a/backend/apps/accounts/services/otp.py +++ b/backend/apps/accounts/services/otp.py @@ -1,6 +1,8 @@ import logging import os import secrets +from hashlib import sha256 +import ipaddress from dataclasses import dataclass from datetime import timedelta @@ -33,6 +35,18 @@ class OtpCooldownError(RuntimeError): self.retry_after_seconds = retry_after_seconds +class OtpIpRateLimitError(RuntimeError): + def __init__(self, retry_after_seconds: int): + super().__init__(_("Too many OTP requests from this IP. Try again later.")) + self.retry_after_seconds = retry_after_seconds + + +class OtpDeviceRateLimitError(RuntimeError): + def __init__(self, retry_after_seconds: int): + super().__init__(_("Too many OTP requests from this device. Try again later.")) + self.retry_after_seconds = retry_after_seconds + + class BaseOtpProvider: uses_provider_otp = False @@ -166,7 +180,73 @@ def generate_code(length: int = 6) -> str: return "".join(secrets.choice(digits) for _ in range(length)) -def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpose.AUTH) -> OtpSendResult: +def _compute_retry_after_seconds(oldest_recent, now, window_minutes: int, floor_seconds: int = 1) -> int: + if oldest_recent: + retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds()) + else: + retry_after = floor_seconds + return max(retry_after, floor_seconds) + + +def normalize_request_ip(raw_ip: str | None) -> str | None: + if not raw_ip: + return None + candidate = raw_ip.split(",")[0].strip() + try: + return str(ipaddress.ip_address(candidate)) + except ValueError: + return None + + +def build_device_signal(device_id: str | None, user_agent: str | None, accept_language: str | None) -> str: + # Prefer explicit device id; fallback to passive headers for coarse signal. + source = (device_id or "").strip() + if not source: + source = f"{(user_agent or '').strip()}|{(accept_language or '').strip()}" + if not source.strip("|"): + return "" + return f"sha256:{sha256(source.encode('utf-8')).hexdigest()[:24]}" + + +def enforce_phone_auth_request_limits(request_ip: str | None, device_signal: str | None) -> None: + now = timezone.now() + window_minutes = getattr(settings, "PHONE_AUTH_RISK_WINDOW_MINUTES", 15) + window_start = now - timedelta(minutes=window_minutes) + ip_limit = getattr(settings, "PHONE_AUTH_IP_MAX_PER_WINDOW", 20) + device_limit = getattr(settings, "PHONE_AUTH_DEVICE_MAX_PER_WINDOW", 20) + + if request_ip and ip_limit > 0: + ip_recent = PhoneOTP.objects.filter( + purpose=OtpPurpose.AUTH, + request_ip=request_ip, + created_at__gte=window_start, + ) + if ip_recent.count() >= ip_limit: + oldest_recent = ip_recent.order_by("created_at").first() + raise OtpIpRateLimitError( + retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes) + ) + + if device_signal and device_limit > 0: + device_recent = PhoneOTP.objects.filter( + purpose=OtpPurpose.AUTH, + device_signal=device_signal, + created_at__gte=window_start, + ) + if device_recent.count() >= device_limit: + oldest_recent = device_recent.order_by("created_at").first() + raise OtpDeviceRateLimitError( + retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes) + ) + + +def create_and_send_otp( + phone_number: str, + channel: str, + purpose: str = OtpPurpose.AUTH, + request_ip: str | None = None, + device_signal: str = "", +) -> OtpSendResult: provider = get_provider() now = timezone.now() window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15) @@ -177,11 +257,9 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start) if recent_qs.count() >= max_per_window: oldest_recent = recent_qs.order_by("created_at").first() - if oldest_recent: - retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds()) - else: - retry_after = cooldown_seconds - raise OtpRateLimitError(retry_after_seconds=max(retry_after, cooldown_seconds)) + raise OtpRateLimitError( + retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes, cooldown_seconds) + ) latest = ( PhoneOTP.objects.filter(phone_number=phone_number, channel=channel) @@ -210,6 +288,8 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo provider=settings.OTP_PROVIDER, code_hash=code_hash, expires_at=PhoneOTP.expiry_at(), + request_ip=request_ip, + device_signal=device_signal, ) if provider.uses_provider_otp: diff --git a/backend/apps/accounts/tests/test_phone_auth_request_contract.py b/backend/apps/accounts/tests/test_phone_auth_request_contract.py index 3b2e134..4d61cfa 100644 --- a/backend/apps/accounts/tests/test_phone_auth_request_contract.py +++ b/backend/apps/accounts/tests/test_phone_auth_request_contract.py @@ -167,3 +167,68 @@ def test_phone_auth_request_rate_limit_returns_retry_after(client): data = second.json() assert "detail" in data assert data["retry_after_seconds"] > 0 + + +@pytest.mark.django_db +@override_settings( + OTP_PROVIDER="console", + OTP_MAX_PER_WINDOW=20, + OTP_WINDOW_MINUTES=15, + OTP_RESEND_COOLDOWN_SECONDS=0, + PHONE_AUTH_RISK_WINDOW_MINUTES=15, + PHONE_AUTH_IP_MAX_PER_WINDOW=2, + PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20, +) +def test_phone_auth_request_ip_throttle_returns_retry_after(client): + with patch("apps.accounts.services.otp.generate_code", return_value="123456"): + first = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345678", "channel": "sms"}, + content_type="application/json", + REMOTE_ADDR="203.0.113.10", + ) + assert first.status_code == 201 + + second = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345679", "channel": "sms"}, + content_type="application/json", + REMOTE_ADDR="203.0.113.10", + ) + assert second.status_code == 201 + + third = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345680", "channel": "sms"}, + content_type="application/json", + REMOTE_ADDR="203.0.113.10", + ) + + assert third.status_code == 429 + data = third.json() + assert "detail" in data + assert data["retry_after_seconds"] > 0 + + +@pytest.mark.django_db +@override_settings( + OTP_PROVIDER="console", + OTP_RESEND_COOLDOWN_SECONDS=0, + OTP_MAX_PER_WINDOW=20, + PHONE_AUTH_IP_MAX_PER_WINDOW=20, + PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20, +) +def test_phone_auth_request_persists_request_ip_and_device_signal(client): + with patch("apps.accounts.services.otp.generate_code", return_value="123456"): + response = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345678", "channel": "sms", "device_id": "device-abc"}, + content_type="application/json", + REMOTE_ADDR="198.51.100.40", + HTTP_USER_AGENT="pytest-agent", + ) + + assert response.status_code == 201 + otp = PhoneOTP.objects.get(id=response.json()["request_id"]) + assert otp.request_ip == "198.51.100.40" + assert otp.device_signal diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 58ec29a..a33eae8 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -16,8 +16,13 @@ from apps.accounts.serializers import ( ) from apps.accounts.services.otp import ( OtpCooldownError, + OtpDeviceRateLimitError, + OtpIpRateLimitError, OtpRateLimitError, + build_device_signal, create_and_send_otp, + enforce_phone_auth_request_limits, + normalize_request_ip, verify_otp, ) @@ -93,6 +98,25 @@ class PhoneAuthRequestView(APIView): data = serializer.validated_data phone_number = data["phone_number"] email = data.get("email") or None + request_ip = normalize_request_ip(request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR")) + device_signal = build_device_signal( + data.get("device_id"), + request.META.get("HTTP_USER_AGENT"), + request.META.get("HTTP_ACCEPT_LANGUAGE"), + ) + + try: + enforce_phone_auth_request_limits(request_ip=request_ip, device_signal=device_signal) + except OtpIpRateLimitError as exc: + return Response( + {"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + except OtpDeviceRateLimitError as exc: + return Response( + {"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) user = User.objects.filter(phone_number=phone_number).first() if not user: @@ -110,7 +134,13 @@ class PhoneAuthRequestView(APIView): ) try: - result = create_and_send_otp(phone_number, data["channel"], purpose=OtpPurpose.AUTH) + result = create_and_send_otp( + phone_number, + data["channel"], + purpose=OtpPurpose.AUTH, + request_ip=request_ip, + device_signal=device_signal, + ) except OtpCooldownError as exc: return Response( {"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds}, diff --git a/backend/salon_api/settings.py b/backend/salon_api/settings.py index 0a484a1..7558367 100644 --- a/backend/salon_api/settings.py +++ b/backend/salon_api/settings.py @@ -146,6 +146,9 @@ OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5")) OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5")) OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15")) OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60")) +PHONE_AUTH_RISK_WINDOW_MINUTES = int(os.getenv("PHONE_AUTH_RISK_WINDOW_MINUTES", "15")) +PHONE_AUTH_IP_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_IP_MAX_PER_WINDOW", "20")) +PHONE_AUTH_DEVICE_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_DEVICE_MAX_PER_WINDOW", "20")) DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR") NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER) NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")