46af911a06
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
166 lines
5.8 KiB
Python
166 lines
5.8 KiB
Python
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.response import Response
|
|
from rest_framework.views import APIView
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
|
from apps.accounts.models import OtpPurpose, PhoneOTP
|
|
from apps.accounts.serializers import (
|
|
OTPRequestSerializer,
|
|
OTPVerifySerializer,
|
|
PhoneAuthRequestSerializer,
|
|
PhoneAuthVerifySerializer,
|
|
RegisterSerializer,
|
|
UserSerializer,
|
|
)
|
|
from apps.accounts.services.otp import (
|
|
OtpCooldownError,
|
|
OtpRateLimitError,
|
|
create_and_send_otp,
|
|
verify_otp,
|
|
)
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class RegisterView(generics.CreateAPIView):
|
|
serializer_class = RegisterSerializer
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
|
|
class MeView(generics.RetrieveUpdateAPIView):
|
|
serializer_class = UserSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_object(self):
|
|
return self.request.user
|
|
|
|
|
|
class OTPRequestView(APIView):
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
def post(self, request):
|
|
serializer = OTPRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
data = serializer.validated_data
|
|
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,
|
|
)
|
|
|
|
|
|
class OTPVerifyView(APIView):
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
def post(self, request):
|
|
serializer = OTPVerifySerializer(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 user and not user.is_phone_verified:
|
|
user.is_phone_verified = True
|
|
user.save(update_fields=["is_phone_verified"])
|
|
|
|
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]
|
|
|
|
def post(self, request, provider):
|
|
return Response(
|
|
{"detail": "Social login not configured yet. Add OAuth provider config."},
|
|
status=status.HTTP_501_NOT_IMPLEMENTED,
|
|
)
|