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:
2026-02-27 15:57:09 +03:00
parent dc68ecfe4c
commit 46af911a06
12 changed files with 292 additions and 20 deletions
+96 -3
View File
@@ -3,15 +3,23 @@ from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions, status
from rest_framework.response import Response
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 (
OTPRequestSerializer,
OTPVerifySerializer,
PhoneAuthRequestSerializer,
PhoneAuthVerifySerializer,
RegisterSerializer,
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()
@@ -36,7 +44,18 @@ class OTPRequestView(APIView):
serializer = OTPRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
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(
{"request_id": result.request_id, "expires_at": result.expires_at},
status=status.HTTP_201_CREATED,
@@ -62,6 +81,80 @@ class OTPVerifyView(APIView):
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):
permission_classes = [permissions.AllowAny]