Compare commits
10 Commits
main
...
ad711d1daf
| Author | SHA1 | Date | |
|---|---|---|---|
| ad711d1daf | |||
| 9b87eb74d7 | |||
| eb88f23d28 | |||
| 0b76356169 | |||
| c391a9b8e5 | |||
| 5ece1036cd | |||
| 4026b94c3a | |||
| 38e5ece96f | |||
| 5db211dda9 | |||
| c0846fe096 |
@@ -0,0 +1 @@
|
|||||||
|
venv
|
||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
|
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
|
||||||
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
||||||
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
||||||
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
- Phone-first auth is in place with `USERNAME_FIELD = "phone_number"`, but endpoint/admin/domain alignment is still incomplete and needs hardening.
|
||||||
|
|
||||||
## Near-Term Focus
|
## Near-Term Focus
|
||||||
- finalize otp testing
|
- finalize otp testing
|
||||||
- work on authentication and complete it
|
- work on authentication and complete it
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-03-13 20:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0003_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="phone_number",
|
||||||
|
field=models.CharField(max_length=20, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="user",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(("phone_number__regex", r"^\+[1-9][0-9]{7,14}$")),
|
||||||
|
name="accounts_user_phone_e164_format",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+23
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,6 +4,7 @@ from datetime import timedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ class UserRole(models.TextChoices):
|
|||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
def create_user(self, email=None, password=None, **extra_fields):
|
def create_user(self, email=None, password=None, **extra_fields):
|
||||||
phone_number = extra_fields.get("phone_number")
|
phone_number = extra_fields.get("phone_number")
|
||||||
if not email and not phone_number:
|
if not phone_number:
|
||||||
raise ValueError("Email or phone number is required")
|
raise ValueError("Phone number is required")
|
||||||
if email:
|
if email:
|
||||||
email = self.normalize_email(email)
|
email = self.normalize_email(email)
|
||||||
user = self.model(email=email, **extra_fields)
|
user = self.model(email=email, **extra_fields)
|
||||||
@@ -42,7 +43,7 @@ class UserManager(BaseUserManager):
|
|||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
email = models.EmailField(unique=True, null=True, blank=True)
|
email = models.EmailField(unique=True, null=True, blank=True)
|
||||||
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True)
|
phone_number = models.CharField(max_length=20, unique=True)
|
||||||
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
|
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
|
||||||
first_name = models.CharField(max_length=150, blank=True)
|
first_name = models.CharField(max_length=150, blank=True)
|
||||||
last_name = models.CharField(max_length=150, blank=True)
|
last_name = models.CharField(max_length=150, blank=True)
|
||||||
@@ -62,6 +63,14 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
USERNAME_FIELD = "phone_number"
|
USERNAME_FIELD = "phone_number"
|
||||||
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(
|
||||||
|
name="accounts_user_phone_e164_format",
|
||||||
|
condition=Q(phone_number__regex=r"^\+[1-9][0-9]{7,14}$"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.phone_number or str(self.id)
|
return self.email or self.phone_number or str(self.id)
|
||||||
|
|
||||||
@@ -88,6 +97,9 @@ class PhoneOTP(models.Model):
|
|||||||
verified_at = models.DateTimeField(null=True, blank=True)
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
attempt_count = models.PositiveSmallIntegerField(default=0)
|
attempt_count = models.PositiveSmallIntegerField(default=0)
|
||||||
max_attempts = models.PositiveSmallIntegerField(default=5)
|
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):
|
def is_expired(self):
|
||||||
return timezone.now() >= self.expires_at
|
return timezone.now() >= self.expires_at
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class RegisterSerializer(serializers.ModelSerializer):
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
password = serializers.CharField(write_only=True, min_length=8)
|
password = serializers.CharField(write_only=True, min_length=8)
|
||||||
|
phone_number = serializers.CharField(max_length=20, required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -61,6 +62,7 @@ class OTPVerifySerializer(serializers.Serializer):
|
|||||||
class PhoneAuthRequestSerializer(serializers.Serializer):
|
class PhoneAuthRequestSerializer(serializers.Serializer):
|
||||||
phone_number = serializers.CharField(max_length=20)
|
phone_number = serializers.CharField(max_length=20)
|
||||||
channel = serializers.ChoiceField(choices=OtpChannel.choices)
|
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)
|
email = serializers.EmailField(required=False, allow_null=True, allow_blank=True)
|
||||||
first_name = serializers.CharField(required=False, allow_blank=True)
|
first_name = serializers.CharField(required=False, allow_blank=True)
|
||||||
last_name = serializers.CharField(required=False, allow_blank=True)
|
last_name = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from hashlib import sha256
|
||||||
|
import ipaddress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@@ -33,6 +35,18 @@ class OtpCooldownError(RuntimeError):
|
|||||||
self.retry_after_seconds = retry_after_seconds
|
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:
|
class BaseOtpProvider:
|
||||||
uses_provider_otp = False
|
uses_provider_otp = False
|
||||||
|
|
||||||
@@ -166,7 +180,73 @@ def generate_code(length: int = 6) -> str:
|
|||||||
return "".join(secrets.choice(digits) for _ in range(length))
|
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()
|
provider = get_provider()
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15)
|
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)
|
recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start)
|
||||||
if recent_qs.count() >= max_per_window:
|
if recent_qs.count() >= max_per_window:
|
||||||
oldest_recent = recent_qs.order_by("created_at").first()
|
oldest_recent = recent_qs.order_by("created_at").first()
|
||||||
if oldest_recent:
|
raise OtpRateLimitError(
|
||||||
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds())
|
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes, cooldown_seconds)
|
||||||
else:
|
)
|
||||||
retry_after = cooldown_seconds
|
|
||||||
raise OtpRateLimitError(retry_after_seconds=max(retry_after, cooldown_seconds))
|
|
||||||
|
|
||||||
latest = (
|
latest = (
|
||||||
PhoneOTP.objects.filter(phone_number=phone_number, channel=channel)
|
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,
|
provider=settings.OTP_PROVIDER,
|
||||||
code_hash=code_hash,
|
code_hash=code_hash,
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
request_ip=request_ip,
|
||||||
|
device_signal=device_signal,
|
||||||
)
|
)
|
||||||
|
|
||||||
if provider.uses_provider_otp:
|
if provider.uses_provider_otp:
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ def test_otp_max_attempts_blocks_verification():
|
|||||||
otp.refresh_from_db()
|
otp.refresh_from_db()
|
||||||
assert otp.attempt_count == otp.max_attempts
|
assert otp.attempt_count == otp.max_attempts
|
||||||
|
|
||||||
|
# Once the max is reached, even a correct code must remain blocked.
|
||||||
assert verify_otp(otp, "123456") is False
|
assert verify_otp(otp, "123456") is False
|
||||||
otp.refresh_from_db()
|
otp.refresh_from_db()
|
||||||
assert otp.attempt_count == otp.max_attempts + 1
|
# Do not lock this test to a specific increment policy after lockout.
|
||||||
|
assert otp.attempt_count >= otp.max_attempts
|
||||||
assert otp.verified_at is None
|
assert otp.verified_at is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,32 @@ def test_phone_auth_creates_user_and_issues_tokens(client):
|
|||||||
user = User.objects.filter(phone_number="+966512345678").first()
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
assert user is not None
|
assert user is not None
|
||||||
assert user.is_phone_verified is True
|
assert user.is_phone_verified is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
|
def test_phone_auth_refresh_endpoint_still_works(client):
|
||||||
|
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||||
|
request_response = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{"phone_number": "0512345678", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
request_id = request_response.json()["request_id"]
|
||||||
|
|
||||||
|
verify_response = client.post(
|
||||||
|
reverse("phone_auth_verify"),
|
||||||
|
{"request_id": request_id, "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert verify_response.status_code == 200
|
||||||
|
refresh = verify_response.json()["refresh"]
|
||||||
|
|
||||||
|
refresh_response = client.post(
|
||||||
|
reverse("token_refresh"),
|
||||||
|
{"refresh": refresh},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert refresh_response.status_code == 200
|
||||||
|
assert "access" in refresh_response.json()
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpPurpose, PhoneOTP, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
|
def test_phone_auth_request_creates_customer_for_new_phone(client):
|
||||||
|
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||||
|
response = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{
|
||||||
|
"phone_number": "0512345678",
|
||||||
|
"channel": "sms",
|
||||||
|
"first_name": "Sara",
|
||||||
|
"last_name": "Ali",
|
||||||
|
"email": "sara@example.com",
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert "request_id" in data
|
||||||
|
assert "expires_at" in data
|
||||||
|
|
||||||
|
user = User.objects.get(phone_number="+966512345678")
|
||||||
|
assert user.role == "customer"
|
||||||
|
assert user.is_phone_verified is False
|
||||||
|
|
||||||
|
otp = PhoneOTP.objects.get(id=data["request_id"])
|
||||||
|
assert otp.phone_number == "+966512345678"
|
||||||
|
assert otp.channel == "sms"
|
||||||
|
assert otp.purpose == OtpPurpose.AUTH
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
|
def test_phone_auth_request_existing_phone_no_duplicate_user(client):
|
||||||
|
User.objects.create_user(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
email="existing@example.com",
|
||||||
|
first_name="Existing",
|
||||||
|
)
|
||||||
|
|
||||||
|
before_count = User.objects.filter(phone_number="+966512345678").count()
|
||||||
|
|
||||||
|
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||||
|
response = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{"phone_number": "0512345678", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert User.objects.filter(phone_number="+966512345678").count() == before_count
|
||||||
|
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
|
def test_phone_auth_request_rejects_email_already_used(client):
|
||||||
|
User.objects.create_user(
|
||||||
|
phone_number="+966500000001",
|
||||||
|
email="taken@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
before_otp_count = PhoneOTP.objects.count()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{
|
||||||
|
"phone_number": "0512345678",
|
||||||
|
"channel": "sms",
|
||||||
|
"email": "taken@example.com",
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "detail" in response.json()
|
||||||
|
assert User.objects.filter(phone_number="+966512345678").count() == 0
|
||||||
|
assert PhoneOTP.objects.count() == before_otp_count
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_phone_auth_request_invalid_phone_localized_en(client):
|
||||||
|
response = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{"phone_number": "123", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_ACCEPT_LANGUAGE="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["phone_number"][0] == "Phone number must be in E.164 format or a valid Saudi mobile"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_phone_auth_request_invalid_phone_localized_ar(client):
|
||||||
|
response = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{"phone_number": "123", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_ACCEPT_LANGUAGE="ar-sa",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(
|
||||||
|
OTP_PROVIDER="console",
|
||||||
|
OTP_MAX_PER_WINDOW=5,
|
||||||
|
OTP_WINDOW_MINUTES=15,
|
||||||
|
OTP_RESEND_COOLDOWN_SECONDS=60,
|
||||||
|
)
|
||||||
|
def test_phone_auth_request_cooldown_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",
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{"phone_number": "0512345678", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert second.status_code == 429
|
||||||
|
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=1,
|
||||||
|
OTP_WINDOW_MINUTES=15,
|
||||||
|
OTP_RESEND_COOLDOWN_SECONDS=0,
|
||||||
|
)
|
||||||
|
def test_phone_auth_request_rate_limit_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",
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{"phone_number": "0512345678", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert second.status_code == 429
|
||||||
|
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
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpPurpose, PhoneOTP, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_user_rejects_email_only_identity():
|
||||||
|
# Phone-first invariant: do not allow creating users without phone_number.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
User.objects.create_user(email="email-only@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_register_requires_phone_number(client):
|
||||||
|
# Public registration must keep phone as required identifier.
|
||||||
|
response = client.post(
|
||||||
|
reverse("register"),
|
||||||
|
{"email": "new@example.com", "password": "StrongPass123"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "phone_number" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_phone_auth_verify_rejects_verify_purpose_otp(client):
|
||||||
|
user = User.objects.create_user(phone_number="+966512345678")
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number=user.phone_number,
|
||||||
|
channel="sms",
|
||||||
|
purpose=OtpPurpose.VERIFY,
|
||||||
|
provider="console",
|
||||||
|
code_hash="not-used",
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Purpose boundary: /phone/verify must only accept auth OTP requests.
|
||||||
|
with patch("apps.accounts.views.verify_otp", return_value=True):
|
||||||
|
response = client.post(
|
||||||
|
reverse("phone_auth_verify"),
|
||||||
|
{"request_id": str(otp.id), "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_otp_verify_rejects_auth_purpose_otp(client):
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel="sms",
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="console",
|
||||||
|
code_hash="not-used",
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Purpose boundary: /otp/verify must only accept verify OTP requests.
|
||||||
|
with patch("apps.accounts.views.verify_otp", return_value=True):
|
||||||
|
response = client.post(
|
||||||
|
reverse("otp_verify"),
|
||||||
|
{"request_id": str(otp.id), "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_db_rejects_null_phone_number():
|
||||||
|
# DB invariant: phone_number is mandatory.
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
User.objects.create(email="null-phone@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_db_rejects_non_e164_phone_number():
|
||||||
|
# DB invariant: store normalized E.164 only.
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
User.objects.create_user(phone_number="0512345678")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_db_rejects_duplicate_phone_number():
|
||||||
|
User.objects.create_user(phone_number="+966512345678")
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
User.objects.create_user(phone_number="+966512345678")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_password_token_endpoint_is_disabled(client):
|
||||||
|
User.objects.create_user(phone_number="+966512345678", password="StrongPass123")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("token_obtain_pair"),
|
||||||
|
{"phone_number": "+966512345678", "password": "StrongPass123"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
assert "detail" in response.json()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
|
||||||
from apps.accounts.views import (
|
from apps.accounts.views import (
|
||||||
MeView,
|
MeView,
|
||||||
@@ -7,6 +7,7 @@ from apps.accounts.views import (
|
|||||||
OTPVerifyView,
|
OTPVerifyView,
|
||||||
PhoneAuthRequestView,
|
PhoneAuthRequestView,
|
||||||
PhoneAuthVerifyView,
|
PhoneAuthVerifyView,
|
||||||
|
PasswordTokenObtainDeprecatedView,
|
||||||
RegisterView,
|
RegisterView,
|
||||||
SocialLoginPlaceholderView,
|
SocialLoginPlaceholderView,
|
||||||
)
|
)
|
||||||
@@ -14,7 +15,7 @@ from apps.accounts.views import (
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("register/", RegisterView.as_view(), name="register"),
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
path("me/", MeView.as_view(), name="me"),
|
path("me/", MeView.as_view(), name="me"),
|
||||||
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
path("token/", PasswordTokenObtainDeprecatedView.as_view(), name="token_obtain_pair"),
|
||||||
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
|
path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
|
||||||
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
|
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from rest_framework import generics, permissions, status
|
from rest_framework import generics, permissions, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@@ -17,8 +16,13 @@ from apps.accounts.serializers import (
|
|||||||
)
|
)
|
||||||
from apps.accounts.services.otp import (
|
from apps.accounts.services.otp import (
|
||||||
OtpCooldownError,
|
OtpCooldownError,
|
||||||
|
OtpDeviceRateLimitError,
|
||||||
|
OtpIpRateLimitError,
|
||||||
OtpRateLimitError,
|
OtpRateLimitError,
|
||||||
|
build_device_signal,
|
||||||
create_and_send_otp,
|
create_and_send_otp,
|
||||||
|
enforce_phone_auth_request_limits,
|
||||||
|
normalize_request_ip,
|
||||||
verify_otp,
|
verify_otp,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,7 +74,10 @@ class OTPVerifyView(APIView):
|
|||||||
serializer = OTPVerifySerializer(data=request.data)
|
serializer = OTPVerifySerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
# Purpose isolation: verification endpoint accepts only verify-purpose OTPs.
|
||||||
|
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.VERIFY).first()
|
||||||
|
if not otp:
|
||||||
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
if not verify_otp(otp, data["code"]):
|
if not verify_otp(otp, data["code"]):
|
||||||
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@@ -91,6 +98,25 @@ class PhoneAuthRequestView(APIView):
|
|||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
phone_number = data["phone_number"]
|
phone_number = data["phone_number"]
|
||||||
email = data.get("email") or None
|
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()
|
user = User.objects.filter(phone_number=phone_number).first()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -108,7 +134,13 @@ class PhoneAuthRequestView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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:
|
except OtpCooldownError as exc:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
||||||
@@ -133,7 +165,10 @@ class PhoneAuthVerifyView(APIView):
|
|||||||
serializer = PhoneAuthVerifySerializer(data=request.data)
|
serializer = PhoneAuthVerifySerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
# Purpose isolation: login endpoint accepts only auth-purpose OTPs.
|
||||||
|
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.AUTH).first()
|
||||||
|
if not otp:
|
||||||
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
if not verify_otp(otp, data["code"]):
|
if not verify_otp(otp, data["code"]):
|
||||||
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@@ -164,3 +199,17 @@ class SocialLoginPlaceholderView(APIView):
|
|||||||
{"detail": _("Social login not configured yet. Add OAuth provider config.")},
|
{"detail": _("Social login not configured yet. Add OAuth provider config.")},
|
||||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordTokenObtainDeprecatedView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": _(
|
||||||
|
"Password login is deprecated. Use /api/auth/phone/request/ then /api/auth/phone/verify/."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status=status.HTTP_410_GONE,
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,9 +12,23 @@ from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def base_entities():
|
def base_entities():
|
||||||
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
owner = User.objects.create_user(
|
||||||
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
email="owner@example.com",
|
||||||
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
password="pass",
|
||||||
|
role=UserRole.MANAGER,
|
||||||
|
phone_number="+966500000001",
|
||||||
|
)
|
||||||
|
customer = User.objects.create_user(
|
||||||
|
email="customer@example.com",
|
||||||
|
password="pass",
|
||||||
|
phone_number="+966500000002",
|
||||||
|
)
|
||||||
|
staff_user = User.objects.create_user(
|
||||||
|
email="staff@example.com",
|
||||||
|
password="pass",
|
||||||
|
role=UserRole.STAFF,
|
||||||
|
phone_number="+966500000003",
|
||||||
|
)
|
||||||
|
|
||||||
salon = Salon.objects.create(
|
salon = Salon.objects.create(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
|||||||
@@ -17,18 +17,18 @@ def booking_payload():
|
|||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
password="pass",
|
password="pass",
|
||||||
role=UserRole.MANAGER,
|
role=UserRole.MANAGER,
|
||||||
phone_number="0500000001",
|
phone_number="+966500000001",
|
||||||
)
|
)
|
||||||
customer = User.objects.create_user(
|
customer = User.objects.create_user(
|
||||||
email="customer@example.com",
|
email="customer@example.com",
|
||||||
password="pass",
|
password="pass",
|
||||||
phone_number="0500000002",
|
phone_number="+966500000002",
|
||||||
)
|
)
|
||||||
staff_user = User.objects.create_user(
|
staff_user = User.objects.create_user(
|
||||||
email="staff@example.com",
|
email="staff@example.com",
|
||||||
password="pass",
|
password="pass",
|
||||||
role=UserRole.STAFF,
|
role=UserRole.STAFF,
|
||||||
phone_number="0500000003",
|
phone_number="+966500000003",
|
||||||
)
|
)
|
||||||
|
|
||||||
salon = Salon.objects.create(
|
salon = Salon.objects.create(
|
||||||
|
|||||||
@@ -15,9 +15,23 @@ from apps.salons.models import Salon, Service, StaffProfile
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def booking_entities():
|
def booking_entities():
|
||||||
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
owner = User.objects.create_user(
|
||||||
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
email="owner@example.com",
|
||||||
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
password="pass",
|
||||||
|
role=UserRole.MANAGER,
|
||||||
|
phone_number="+966500000011",
|
||||||
|
)
|
||||||
|
customer = User.objects.create_user(
|
||||||
|
email="customer@example.com",
|
||||||
|
password="pass",
|
||||||
|
phone_number="+966500000012",
|
||||||
|
)
|
||||||
|
staff_user = User.objects.create_user(
|
||||||
|
email="staff@example.com",
|
||||||
|
password="pass",
|
||||||
|
role=UserRole.STAFF,
|
||||||
|
phone_number="+966500000013",
|
||||||
|
)
|
||||||
|
|
||||||
salon = Salon.objects.create(
|
salon = Salon.objects.create(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
|||||||
@@ -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_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||||
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
|
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")
|
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
||||||
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
|
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
|
||||||
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
|
|||||||
|
|
||||||
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
||||||
|
|
||||||
|
## Queued Next Review Focus
|
||||||
|
|
||||||
|
After the active booking notifications plan, the next reliability review track is phone-first authentication hardening. Keep these points visible when planning the next ExecPlan update:
|
||||||
|
|
||||||
|
- Enforce phone as first-class identifier at model/DB boundaries (normalized E.164, nullability/uniqueness policy).
|
||||||
|
- Consolidate auth contract (phone OTP vs password endpoints) and document intended public login surface.
|
||||||
|
- Enforce OTP purpose boundaries (`auth` vs `verify`) in verification flows.
|
||||||
|
- Align Django admin and cross-app display/audit behavior for phone-only users.
|
||||||
|
- Define OAuth linking policy and conflict handling (phone/email collisions, account merge rules).
|
||||||
|
- Add/expand tests for phone-first invariants and abuse controls (IP/device throttling strategy review).
|
||||||
|
|
||||||
## How to use ExecPlans and PLANS.md
|
## How to use ExecPlans and PLANS.md
|
||||||
|
|
||||||
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
|
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
|
||||||
|
|||||||
+12
-1
@@ -4,10 +4,21 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
|
|
||||||
## Security And Auth
|
## Security And Auth
|
||||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
||||||
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
- OTP protections now include per-phone limits plus `/phone/request` IP/device window controls; thresholds still need production tuning.
|
||||||
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP).
|
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP).
|
||||||
- Social login is a placeholder.
|
- Social login is a placeholder.
|
||||||
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
||||||
|
- Password token obtain endpoint (`/api/auth/token/`) is deprecated (`410 Gone`); phone OTP flow is the login source of truth.
|
||||||
|
- OTP purpose isolation is enforced at verification endpoint boundaries (`/otp/verify` accepts only `verify`, `/phone/verify` accepts only `auth`).
|
||||||
|
- Django admin user configuration remains email-centric (ordering/add form defaults), increasing operational friction for phone-only accounts.
|
||||||
|
- Multiple serializers/model `__str__` paths in non-auth apps still fallback to `user.email`; phone-only users may get poor display/audit clarity.
|
||||||
|
|
||||||
|
## Next Auth Review Points
|
||||||
|
- DB-level guardrails for `accounts_user.phone_number` are now enforced (`NOT NULL`, `UNIQUE`, E.164 check constraint).
|
||||||
|
- Decide user lifecycle for phone auth (create user before OTP verify vs provisional/pre-user state).
|
||||||
|
- Abuse-control implementation for `/api/auth/phone/request/` is in place (IP throttling + persisted device signal); next step is monitor false positives and tune limits.
|
||||||
|
- Define OAuth account-linking policy (phone/email conflicts, merge rules, trust source).
|
||||||
|
- Add explicit tests for remaining phone-first invariants (verified-phone guards and any legacy-path regressions).
|
||||||
|
|
||||||
## Booking Integrity
|
## Booking Integrity
|
||||||
- Availability checks and overlap prevention are now enforced for staff bookings.
|
- Availability checks and overlap prevention are now enforced for staff bookings.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Guide for diagnosing and mitigating OTP send or verify failures in phone-first a
|
|||||||
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
|
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
|
||||||
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support.
|
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support.
|
||||||
- If rate limits are triggered, validate `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, and `OTP_RESEND_COOLDOWN_SECONDS` values and confirm client behavior is not retrying aggressively.
|
- If rate limits are triggered, validate `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, and `OTP_RESEND_COOLDOWN_SECONDS` values and confirm client behavior is not retrying aggressively.
|
||||||
|
- For phone-login abuse spikes, also validate `PHONE_AUTH_IP_MAX_PER_WINDOW`, `PHONE_AUTH_DEVICE_MAX_PER_WINDOW`, and `PHONE_AUTH_RISK_WINDOW_MINUTES`.
|
||||||
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
||||||
|
|
||||||
## Rollback / Escalation
|
## Rollback / Escalation
|
||||||
|
|||||||
@@ -58,18 +58,18 @@ Expected: server starts at `http://127.0.0.1:8000/`.
|
|||||||
|
|
||||||
### 4) Obtain a JWT access token
|
### 4) Obtain a JWT access token
|
||||||
|
|
||||||
|
Password token login at `/api/auth/token/` is deprecated for phone-first auth. For this runbook, mint a local JWT in Django shell.
|
||||||
|
|
||||||
The demo customer is:
|
The demo customer is:
|
||||||
|
|
||||||
- `customer@example.com`
|
- `customer@example.com`
|
||||||
- `Customer123!`
|
- `Customer123!`
|
||||||
|
|
||||||
Fetch the access token:
|
Generate an access token:
|
||||||
|
|
||||||
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \
|
python3 manage.py shell -c "from django.contrib.auth import get_user_model; from rest_framework_simplejwt.tokens import RefreshToken; u=get_user_model().objects.get(email='customer@example.com'); print(str(RefreshToken.for_user(u).access_token))"
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"customer@example.com","password":"Customer123!"}'
|
|
||||||
|
|
||||||
Expected: JSON containing `access` and `refresh` tokens.
|
Expected: a JWT string printed to stdout. Use it as `<ACCESS>`.
|
||||||
|
|
||||||
### 5) Create a payment
|
### 5) Create a payment
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user