I proceeded with Phase 1 step 1 (phone-first auth + OTP hardening) and updated the plan context. Here’s the current state and the next steps.
What I implemented Phone-first auth endpoints with OTP → JWT issuance. views.py urls.py Phone normalization (KSA-focused, E.164 or Saudi mobile) and validation. phone.py serializers.py OTP protections: rate limit, resend cooldown, attempt counting. otp.py models.py Email is now optional to allow phone-only users. models.py 0002_phone_auth_fields.py Admin OTP visibility improved. admin.py Risks updated. risks.md
This commit is contained in:
@@ -26,6 +26,8 @@ After migrations, you can seed demo data:
|
|||||||
- `GET/PATCH /api/auth/me/`
|
- `GET/PATCH /api/auth/me/`
|
||||||
- `POST /api/auth/otp/request/`
|
- `POST /api/auth/otp/request/`
|
||||||
- `POST /api/auth/otp/verify/`
|
- `POST /api/auth/otp/verify/`
|
||||||
|
- `POST /api/auth/phone/request/`
|
||||||
|
- `POST /api/auth/phone/verify/`
|
||||||
- `POST /api/auth/social/<provider>/` (placeholder)
|
- `POST /api/auth/social/<provider>/` (placeholder)
|
||||||
- `GET /api/salons/`
|
- `GET /api/salons/`
|
||||||
- `GET /api/salons/<id>/`
|
- `GET /api/salons/<id>/`
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/salon
|
|||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
OTP_PROVIDER=console
|
OTP_PROVIDER=console
|
||||||
OTP_EXPIRY_MINUTES=5
|
OTP_EXPIRY_MINUTES=5
|
||||||
|
OTP_MAX_PER_WINDOW=5
|
||||||
|
OTP_WINDOW_MINUTES=15
|
||||||
|
OTP_RESEND_COOLDOWN_SECONDS=60
|
||||||
DEFAULT_CURRENCY=SAR
|
DEFAULT_CURRENCY=SAR
|
||||||
TWILIO_ACCOUNT_SID=
|
TWILIO_ACCOUNT_SID=
|
||||||
TWILIO_AUTH_TOKEN=
|
TWILIO_AUTH_TOKEN=
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ class UserAdmin(DjangoUserAdmin):
|
|||||||
|
|
||||||
@admin.register(PhoneOTP)
|
@admin.register(PhoneOTP)
|
||||||
class PhoneOTPAdmin(admin.ModelAdmin):
|
class PhoneOTPAdmin(admin.ModelAdmin):
|
||||||
list_display = ("phone_number", "channel", "provider", "created_at", "expires_at", "verified_at")
|
list_display = (
|
||||||
list_filter = ("channel", "provider")
|
"phone_number",
|
||||||
|
"channel",
|
||||||
|
"purpose",
|
||||||
|
"provider",
|
||||||
|
"created_at",
|
||||||
|
"expires_at",
|
||||||
|
"verified_at",
|
||||||
|
"attempt_count",
|
||||||
|
)
|
||||||
|
list_filter = ("channel", "purpose", "provider")
|
||||||
search_fields = ("phone_number",)
|
search_fields = ("phone_number",)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="email",
|
||||||
|
field=models.EmailField(blank=True, max_length=254, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="phoneotp",
|
||||||
|
name="attempt_count",
|
||||||
|
field=models.PositiveSmallIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="phoneotp",
|
||||||
|
name="max_attempts",
|
||||||
|
field=models.PositiveSmallIntegerField(default=5),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="phoneotp",
|
||||||
|
name="purpose",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("auth", "Authentication"), ("verify", "Phone Verification")],
|
||||||
|
default="auth",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,12 +15,17 @@ class UserRole(models.TextChoices):
|
|||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
def create_user(self, email, password=None, **extra_fields):
|
def create_user(self, email=None, password=None, **extra_fields):
|
||||||
if not email:
|
phone_number = extra_fields.get("phone_number")
|
||||||
raise ValueError("Email is required")
|
if not email and not phone_number:
|
||||||
|
raise ValueError("Email or phone number is required")
|
||||||
|
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)
|
||||||
|
if password:
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
|
else:
|
||||||
|
user.set_unusable_password()
|
||||||
user.save(using=self._db)
|
user.save(using=self._db)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -36,7 +41,7 @@ class UserManager(BaseUserManager):
|
|||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
email = models.EmailField(unique=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, null=True, blank=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)
|
||||||
@@ -52,7 +57,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "email"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email or self.phone_number or str(self.id)
|
||||||
|
|
||||||
|
|
||||||
class OtpChannel(models.TextChoices):
|
class OtpChannel(models.TextChoices):
|
||||||
@@ -60,15 +65,23 @@ class OtpChannel(models.TextChoices):
|
|||||||
WHATSAPP = "whatsapp", "WhatsApp"
|
WHATSAPP = "whatsapp", "WhatsApp"
|
||||||
|
|
||||||
|
|
||||||
|
class OtpPurpose(models.TextChoices):
|
||||||
|
AUTH = "auth", "Authentication"
|
||||||
|
VERIFY = "verify", "Phone Verification"
|
||||||
|
|
||||||
|
|
||||||
class PhoneOTP(models.Model):
|
class PhoneOTP(models.Model):
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
phone_number = models.CharField(max_length=20)
|
phone_number = models.CharField(max_length=20)
|
||||||
channel = models.CharField(max_length=20, choices=OtpChannel.choices)
|
channel = models.CharField(max_length=20, choices=OtpChannel.choices)
|
||||||
|
purpose = models.CharField(max_length=20, choices=OtpPurpose.choices, default=OtpPurpose.AUTH)
|
||||||
provider = models.CharField(max_length=50)
|
provider = models.CharField(max_length=50)
|
||||||
code_hash = models.CharField(max_length=128)
|
code_hash = models.CharField(max_length=128)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
expires_at = models.DateTimeField()
|
expires_at = models.DateTimeField()
|
||||||
verified_at = models.DateTimeField(null=True, blank=True)
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
attempt_count = models.PositiveSmallIntegerField(default=0)
|
||||||
|
max_attempts = models.PositiveSmallIntegerField(default=5)
|
||||||
|
|
||||||
def is_expired(self):
|
def is_expired(self):
|
||||||
return timezone.now() >= self.expires_at
|
return timezone.now() >= self.expires_at
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel, PhoneOTP
|
from apps.accounts.models import OtpChannel, PhoneOTP
|
||||||
|
from apps.accounts.services.phone import normalize_phone_number
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -20,6 +21,14 @@ class RegisterSerializer(serializers.ModelSerializer):
|
|||||||
model = User
|
model = User
|
||||||
fields = ["email", "password", "phone_number", "first_name", "last_name"]
|
fields = ["email", "password", "phone_number", "first_name", "last_name"]
|
||||||
|
|
||||||
|
def validate_phone_number(self, value):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return normalize_phone_number(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise serializers.ValidationError(str(exc)) from exc
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
return User.objects.create_user(**validated_data)
|
return User.objects.create_user(**validated_data)
|
||||||
|
|
||||||
@@ -28,12 +37,37 @@ class OTPRequestSerializer(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)
|
||||||
|
|
||||||
|
def validate_phone_number(self, value):
|
||||||
|
try:
|
||||||
|
return normalize_phone_number(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise serializers.ValidationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
class OTPVerifySerializer(serializers.Serializer):
|
class OTPVerifySerializer(serializers.Serializer):
|
||||||
request_id = serializers.UUIDField()
|
request_id = serializers.UUIDField()
|
||||||
code = serializers.CharField(max_length=6)
|
code = serializers.CharField(max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneAuthRequestSerializer(serializers.Serializer):
|
||||||
|
phone_number = serializers.CharField(max_length=20)
|
||||||
|
channel = serializers.ChoiceField(choices=OtpChannel.choices)
|
||||||
|
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)
|
||||||
|
|
||||||
|
def validate_phone_number(self, value):
|
||||||
|
try:
|
||||||
|
return normalize_phone_number(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise serializers.ValidationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneAuthVerifySerializer(serializers.Serializer):
|
||||||
|
request_id = serializers.UUIDField()
|
||||||
|
code = serializers.CharField(max_length=6)
|
||||||
|
|
||||||
|
|
||||||
class OTPStatusSerializer(serializers.ModelSerializer):
|
class OTPStatusSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PhoneOTP
|
model = PhoneOTP
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password, make_password
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel, PhoneOTP
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,6 +19,18 @@ class OtpSendResult:
|
|||||||
expires_at: str
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class OtpRateLimitError(RuntimeError):
|
||||||
|
def __init__(self, retry_after_seconds: int):
|
||||||
|
super().__init__("Too many OTP requests. Try again later.")
|
||||||
|
self.retry_after_seconds = retry_after_seconds
|
||||||
|
|
||||||
|
|
||||||
|
class OtpCooldownError(RuntimeError):
|
||||||
|
def __init__(self, retry_after_seconds: int):
|
||||||
|
super().__init__("Please wait before requesting another code.")
|
||||||
|
self.retry_after_seconds = retry_after_seconds
|
||||||
|
|
||||||
|
|
||||||
class BaseOtpProvider:
|
class BaseOtpProvider:
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@@ -97,12 +110,38 @@ 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) -> OtpSendResult:
|
def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpose.AUTH) -> OtpSendResult:
|
||||||
provider = get_provider()
|
provider = get_provider()
|
||||||
|
now = timezone.now()
|
||||||
|
window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15)
|
||||||
|
max_per_window = getattr(settings, "OTP_MAX_PER_WINDOW", 5)
|
||||||
|
cooldown_seconds = getattr(settings, "OTP_RESEND_COOLDOWN_SECONDS", 60)
|
||||||
|
|
||||||
|
window_start = now - timedelta(minutes=window_minutes)
|
||||||
|
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))
|
||||||
|
|
||||||
|
latest = (
|
||||||
|
PhoneOTP.objects.filter(phone_number=phone_number, channel=channel)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest and latest.verified_at is None:
|
||||||
|
elapsed = (now - latest.created_at).total_seconds()
|
||||||
|
if elapsed < cooldown_seconds:
|
||||||
|
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
||||||
|
|
||||||
code = generate_code()
|
code = generate_code()
|
||||||
otp = PhoneOTP.objects.create(
|
otp = PhoneOTP.objects.create(
|
||||||
phone_number=phone_number,
|
phone_number=phone_number,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
purpose=purpose,
|
||||||
provider=settings.OTP_PROVIDER,
|
provider=settings.OTP_PROVIDER,
|
||||||
code_hash=make_password(code),
|
code_hash=make_password(code),
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
@@ -122,8 +161,13 @@ def create_and_send_otp(phone_number: str, channel: str) -> OtpSendResult:
|
|||||||
def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
||||||
if otp.verified_at or otp.is_expired():
|
if otp.verified_at or otp.is_expired():
|
||||||
return False
|
return False
|
||||||
|
otp.attempt_count += 1
|
||||||
|
if otp.attempt_count > otp.max_attempts:
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
return False
|
||||||
if not check_password(code, otp.code_hash):
|
if not check_password(code, otp.code_hash):
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
return False
|
return False
|
||||||
otp.verified_at = timezone.now()
|
otp.verified_at = timezone.now()
|
||||||
otp.save(update_fields=["verified_at"])
|
otp.save(update_fields=["verified_at", "attempt_count"])
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone_number(raw_phone: str) -> str:
|
||||||
|
if not raw_phone:
|
||||||
|
raise ValueError("Phone number is required")
|
||||||
|
|
||||||
|
phone = re.sub(r"[\s\-\(\)]", "", raw_phone)
|
||||||
|
if phone.startswith("00"):
|
||||||
|
phone = "+" + phone[2:]
|
||||||
|
|
||||||
|
if phone.startswith("+"):
|
||||||
|
digits = phone[1:]
|
||||||
|
if not digits.isdigit() or not (8 <= len(digits) <= 15):
|
||||||
|
raise ValueError("Invalid phone number format")
|
||||||
|
return "+" + digits
|
||||||
|
|
||||||
|
digits = re.sub(r"\D", "", phone)
|
||||||
|
|
||||||
|
if digits.startswith("0") and len(digits) == 10 and digits[1] == "5":
|
||||||
|
return "+966" + digits[1:]
|
||||||
|
|
||||||
|
if digits.startswith("5") and len(digits) == 9:
|
||||||
|
return "+966" + digits
|
||||||
|
|
||||||
|
raise ValueError("Phone number must be in E.164 format or a valid Saudi mobile")
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
|
||||||
from apps.accounts.views import MeView, OTPRequestView, OTPVerifyView, RegisterView, SocialLoginPlaceholderView
|
from apps.accounts.views import (
|
||||||
|
MeView,
|
||||||
|
OTPRequestView,
|
||||||
|
OTPVerifyView,
|
||||||
|
PhoneAuthRequestView,
|
||||||
|
PhoneAuthVerifyView,
|
||||||
|
RegisterView,
|
||||||
|
SocialLoginPlaceholderView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("register/", RegisterView.as_view(), name="register"),
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
@@ -10,5 +18,7 @@ urlpatterns = [
|
|||||||
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"),
|
||||||
|
path("phone/request/", PhoneAuthRequestView.as_view(), name="phone_auth_request"),
|
||||||
|
path("phone/verify/", PhoneAuthVerifyView.as_view(), name="phone_auth_verify"),
|
||||||
path("social/<str:provider>/", SocialLoginPlaceholderView.as_view(), name="social_login"),
|
path("social/<str:provider>/", SocialLoginPlaceholderView.as_view(), name="social_login"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,15 +3,23 @@ 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 rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
from apps.accounts.models import PhoneOTP
|
from apps.accounts.models import OtpPurpose, PhoneOTP
|
||||||
from apps.accounts.serializers import (
|
from apps.accounts.serializers import (
|
||||||
OTPRequestSerializer,
|
OTPRequestSerializer,
|
||||||
OTPVerifySerializer,
|
OTPVerifySerializer,
|
||||||
|
PhoneAuthRequestSerializer,
|
||||||
|
PhoneAuthVerifySerializer,
|
||||||
RegisterSerializer,
|
RegisterSerializer,
|
||||||
UserSerializer,
|
UserSerializer,
|
||||||
)
|
)
|
||||||
from apps.accounts.services.otp import create_and_send_otp, verify_otp
|
from apps.accounts.services.otp import (
|
||||||
|
OtpCooldownError,
|
||||||
|
OtpRateLimitError,
|
||||||
|
create_and_send_otp,
|
||||||
|
verify_otp,
|
||||||
|
)
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -36,7 +44,18 @@ class OTPRequestView(APIView):
|
|||||||
serializer = OTPRequestSerializer(data=request.data)
|
serializer = OTPRequestSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
result = create_and_send_otp(data["phone_number"], data["channel"])
|
try:
|
||||||
|
result = create_and_send_otp(data["phone_number"], data["channel"], purpose=OtpPurpose.VERIFY)
|
||||||
|
except OtpCooldownError as exc:
|
||||||
|
return Response(
|
||||||
|
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
except OtpRateLimitError as exc:
|
||||||
|
return Response(
|
||||||
|
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{"request_id": result.request_id, "expires_at": result.expires_at},
|
{"request_id": result.request_id, "expires_at": result.expires_at},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
@@ -62,6 +81,80 @@ class OTPVerifyView(APIView):
|
|||||||
return Response({"detail": "Phone verified"}, status=status.HTTP_200_OK)
|
return Response({"detail": "Phone verified"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneAuthRequestView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = PhoneAuthRequestSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
data = serializer.validated_data
|
||||||
|
phone_number = data["phone_number"]
|
||||||
|
email = data.get("email") or None
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number=phone_number).first()
|
||||||
|
if not user:
|
||||||
|
if email and User.objects.filter(email=email).exists():
|
||||||
|
return Response(
|
||||||
|
{"detail": "Email already in use."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email=email,
|
||||||
|
phone_number=phone_number,
|
||||||
|
first_name=data.get("first_name", ""),
|
||||||
|
last_name=data.get("last_name", ""),
|
||||||
|
role="customer",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = create_and_send_otp(phone_number, data["channel"], purpose=OtpPurpose.AUTH)
|
||||||
|
except OtpCooldownError as exc:
|
||||||
|
return Response(
|
||||||
|
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
except OtpRateLimitError as exc:
|
||||||
|
return Response(
|
||||||
|
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"request_id": result.request_id, "expires_at": result.expires_at},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneAuthVerifyView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = PhoneAuthVerifySerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
data = serializer.validated_data
|
||||||
|
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
||||||
|
if not verify_otp(otp, data["code"]):
|
||||||
|
return Response({"detail": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number=otp.phone_number).first()
|
||||||
|
if not user:
|
||||||
|
return Response({"detail": "User not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if not user.is_phone_verified:
|
||||||
|
user.is_phone_verified = True
|
||||||
|
user.save(update_fields=["is_phone_verified"])
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"refresh": str(refresh),
|
||||||
|
"access": str(refresh.access_token),
|
||||||
|
"user": UserSerializer(user).data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SocialLoginPlaceholderView(APIView):
|
class SocialLoginPlaceholderView(APIView):
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|||||||
@@ -129,4 +129,7 @@ CORS_ALLOWED_ORIGINS = [
|
|||||||
|
|
||||||
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||||
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
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"))
|
||||||
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
||||||
|
|||||||
+5
-4
@@ -3,10 +3,11 @@
|
|||||||
This file tracks known gaps and risks to address in future iterations.
|
This file tracks known gaps and risks to address in future iterations.
|
||||||
|
|
||||||
## Security And Auth
|
## Security And Auth
|
||||||
- Phone auth only verifies existing users. Add phone-first sign-up flow.
|
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
||||||
- OTP rate limiting, resend cooldown, and abuse protections are missing.
|
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
||||||
- Phone normalization/validation (E.164) not implemented.
|
- OTP provider adapters (Twilio/Unifonic) are scaffolds only; no real SMS/WhatsApp delivery yet.
|
||||||
- Social login is a placeholder.
|
- Social login is a placeholder.
|
||||||
|
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
||||||
|
|
||||||
## Booking Integrity
|
## Booking Integrity
|
||||||
- No availability checks or overlap prevention for staff/salon schedules.
|
- No availability checks or overlap prevention for staff/salon schedules.
|
||||||
@@ -14,7 +15,7 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
- No cancellation rules or refund logic.
|
- No cancellation rules or refund logic.
|
||||||
|
|
||||||
## Payments
|
## Payments
|
||||||
- Payment integration is not implemented. Current API only stores records.
|
- Payment integration is not implemented. Current API only stores records and a Moyasar scaffold exists.
|
||||||
- Webhook handling and payment status reconciliation missing.
|
- Webhook handling and payment status reconciliation missing.
|
||||||
- Idempotency handling for payment creation missing.
|
- Idempotency handling for payment creation missing.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user