2026-02-19 01:19:22 +03:30
|
|
|
import secrets
|
|
|
|
|
|
2026-03-23 22:24:30 +03:30
|
|
|
from django.contrib.auth import authenticate
|
2026-02-19 01:19:22 +03:30
|
|
|
from django.conf import settings
|
|
|
|
|
from django.core.cache import cache
|
2026-03-20 23:16:53 +03:30
|
|
|
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
|
2026-03-23 22:24:30 +03:30
|
|
|
from django.db import IntegrityError
|
2026-03-24 20:10:48 +03:30
|
|
|
from rest_framework import serializers
|
2026-02-19 01:19:22 +03:30
|
|
|
from rest_framework import status
|
|
|
|
|
from rest_framework.response import Response
|
|
|
|
|
from rest_framework.views import APIView
|
2026-03-24 20:10:48 +03:30
|
|
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
2026-03-20 23:16:53 +03:30
|
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
2026-02-19 01:19:22 +03:30
|
|
|
|
2026-03-20 23:16:53 +03:30
|
|
|
from account.models import User
|
2026-03-24 20:10:48 +03:30
|
|
|
from config.swagger import code_response
|
2026-03-23 22:24:30 +03:30
|
|
|
from .serializers import (
|
2026-03-24 20:10:48 +03:30
|
|
|
AuthUserSerializer,
|
2026-03-23 22:24:30 +03:30
|
|
|
LoginSerializer,
|
|
|
|
|
RegisterSerializer,
|
|
|
|
|
RequestOTPSerializer,
|
|
|
|
|
VerifyOTPSerializer,
|
|
|
|
|
)
|
2026-03-20 23:16:53 +03:30
|
|
|
from .sms_service import send_otp_sms
|
2026-02-19 01:19:22 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
OTP_TTL_SECONDS = 300
|
|
|
|
|
OTP_SIGNER = TimestampSigner(salt="auth.otp")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _auth_user_to_data(user):
|
2026-03-20 23:16:53 +03:30
|
|
|
"""Build AuthUser-shaped dict from Django User."""
|
|
|
|
|
if user is None or not getattr(user, "pk", None):
|
|
|
|
|
return None
|
2026-02-19 01:19:22 +03:30
|
|
|
return {
|
2026-03-20 23:16:53 +03:30
|
|
|
"id": user.id,
|
|
|
|
|
"username": getattr(user, "username", "") or "",
|
|
|
|
|
"email": getattr(user, "email", "") or "",
|
|
|
|
|
"first_name": getattr(user, "first_name", "") or "",
|
|
|
|
|
"last_name": getattr(user, "last_name", "") or "",
|
|
|
|
|
"phone_number": getattr(user, "phone_number", "") or "",
|
2026-02-19 01:19:22 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 20:10:48 +03:30
|
|
|
@extend_schema_view(
|
|
|
|
|
post=extend_schema(
|
|
|
|
|
tags=["Authentication"],
|
|
|
|
|
request=RegisterSerializer,
|
|
|
|
|
responses={
|
|
|
|
|
201: code_response("RegisterResponse", data=AuthUserSerializer(), token=True),
|
|
|
|
|
400: code_response("RegisterErrorResponse"),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-03-23 22:24:30 +03:30
|
|
|
class RegisterView(APIView):
|
|
|
|
|
"""
|
|
|
|
|
POST /api/auth/register/
|
|
|
|
|
Creates a new user with username, email, phone_number, and password.
|
|
|
|
|
All fields are required (first_name, last_name optional).
|
|
|
|
|
Returns JWT tokens and user data on success.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def post(self, request):
|
|
|
|
|
serializer = RegisterSerializer(data=request.data)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
data = serializer.validated_data
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
user = User.objects.create_user(
|
|
|
|
|
username=data["username"],
|
|
|
|
|
email=data["email"],
|
|
|
|
|
phone_number=data["phone_number"],
|
|
|
|
|
password=data["password"],
|
|
|
|
|
first_name=data.get("first_name", ""),
|
|
|
|
|
last_name=data.get("last_name", ""),
|
|
|
|
|
)
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
msg = str(exc).lower()
|
|
|
|
|
if "username" in msg:
|
|
|
|
|
detail = "A user with this username already exists."
|
|
|
|
|
elif "email" in msg:
|
|
|
|
|
detail = "A user with this email already exists."
|
|
|
|
|
elif "phone_number" in msg:
|
|
|
|
|
detail = "A user with this phone number already exists."
|
|
|
|
|
else:
|
|
|
|
|
detail = "A user with these credentials already exists."
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 400, "msg": detail},
|
|
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
refresh = RefreshToken.for_user(user)
|
|
|
|
|
user_data = _auth_user_to_data(user)
|
|
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"code": 201,
|
|
|
|
|
"msg": "success",
|
|
|
|
|
"data": user_data,
|
|
|
|
|
"token": {
|
|
|
|
|
"access": str(refresh.access_token),
|
|
|
|
|
"refresh": str(refresh),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
status=status.HTTP_201_CREATED,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 20:10:48 +03:30
|
|
|
@extend_schema_view(
|
|
|
|
|
post=extend_schema(
|
|
|
|
|
tags=["Authentication"],
|
|
|
|
|
request=LoginSerializer,
|
|
|
|
|
responses={
|
|
|
|
|
200: code_response("LoginResponse", data=AuthUserSerializer(), token=True),
|
|
|
|
|
401: code_response("LoginErrorResponse"),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-03-23 22:24:30 +03:30
|
|
|
class LoginView(APIView):
|
|
|
|
|
"""
|
|
|
|
|
POST /api/auth/login/
|
|
|
|
|
Accepts identifier (username, email, or phone_number) + password.
|
|
|
|
|
Returns JWT tokens and user data on success.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def post(self, request):
|
|
|
|
|
serializer = LoginSerializer(data=request.data)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
|
|
|
|
|
identifier = serializer.validated_data["identifier"]
|
|
|
|
|
password = serializer.validated_data["password"]
|
|
|
|
|
|
|
|
|
|
user = authenticate(request, username=identifier, password=password)
|
|
|
|
|
|
|
|
|
|
if user is None:
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 401, "msg": "Invalid credentials."},
|
|
|
|
|
status=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
refresh = RefreshToken.for_user(user)
|
|
|
|
|
user_data = _auth_user_to_data(user)
|
|
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"code": 200,
|
|
|
|
|
"msg": "success",
|
|
|
|
|
"data": user_data,
|
|
|
|
|
"token": {
|
|
|
|
|
"access": str(refresh.access_token),
|
|
|
|
|
"refresh": str(refresh),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
status=status.HTTP_200_OK,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 20:10:48 +03:30
|
|
|
@extend_schema_view(
|
|
|
|
|
post=extend_schema(
|
|
|
|
|
tags=["Authentication"],
|
|
|
|
|
request=RequestOTPSerializer,
|
|
|
|
|
responses={
|
|
|
|
|
200: code_response(
|
|
|
|
|
"RequestOtpResponse",
|
|
|
|
|
extra_fields={
|
|
|
|
|
"token": serializers.CharField(),
|
|
|
|
|
"sms_warning": serializers.CharField(required=False),
|
|
|
|
|
"debug_otp": serializers.CharField(required=False),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
400: code_response("RequestOtpErrorResponse"),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-02-19 01:19:22 +03:30
|
|
|
class AuthenticationView(APIView):
|
|
|
|
|
"""
|
|
|
|
|
Single view for auth flows: request-otp and verify-otp.
|
|
|
|
|
Dispatches by path: .../request-otp/ -> request_otp, .../verify-otp/ -> verify_otp.
|
|
|
|
|
Response format: RequestOTPResponse / VerifyOTPResponse (code, msg, token, data when applicable).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def post(self, request):
|
|
|
|
|
if "verify-otp" in request.path:
|
|
|
|
|
return self._verify_otp(request)
|
|
|
|
|
return self._request_otp(request)
|
|
|
|
|
|
|
|
|
|
def _request_otp(self, request):
|
|
|
|
|
serializer = RequestOTPSerializer(data=request.data)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
|
|
|
|
|
phone_number = serializer.validated_data["phone_number"].strip()
|
|
|
|
|
otp_code = f"{secrets.randbelow(1_000_000):06d}"
|
|
|
|
|
|
|
|
|
|
cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS)
|
|
|
|
|
otp_token = OTP_SIGNER.sign(phone_number)
|
|
|
|
|
|
2026-03-20 23:16:53 +03:30
|
|
|
sms_sent = send_otp_sms(phone_number, otp_code)
|
|
|
|
|
|
2026-02-19 01:19:22 +03:30
|
|
|
payload = {"code": 200, "msg": "success", "token": otp_token}
|
2026-03-20 23:16:53 +03:30
|
|
|
if not sms_sent:
|
|
|
|
|
payload["sms_warning"] = "SMS delivery failed; OTP stored server-side."
|
2026-02-19 01:19:22 +03:30
|
|
|
if settings.DEBUG:
|
2026-03-20 23:16:53 +03:30
|
|
|
payload["debug_otp"] = otp_code
|
2026-02-19 01:19:22 +03:30
|
|
|
|
|
|
|
|
return Response(payload, status=status.HTTP_200_OK)
|
|
|
|
|
|
|
|
|
|
def _verify_otp(self, request):
|
|
|
|
|
serializer = VerifyOTPSerializer(data=request.data)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
|
2026-03-20 23:16:53 +03:30
|
|
|
token = serializer.validated_data["token"]
|
|
|
|
|
otp_code = serializer.validated_data["otp_code"].strip()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
phone_number = OTP_SIGNER.unsign(
|
|
|
|
|
token, max_age=OTP_TTL_SECONDS
|
|
|
|
|
)
|
|
|
|
|
except (BadSignature, SignatureExpired):
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 400, "msg": "Token is invalid or expired."},
|
|
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
cached_otp = cache.get(f"otp_code:{phone_number}")
|
|
|
|
|
if cached_otp is None or cached_otp != otp_code:
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 400, "msg": "OTP code is invalid or expired."},
|
|
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
cache.delete(f"otp_code:{phone_number}")
|
|
|
|
|
|
|
|
|
|
user, created = User.objects.get_or_create(
|
|
|
|
|
phone_number=phone_number,
|
2026-03-23 22:24:30 +03:30
|
|
|
defaults={
|
|
|
|
|
"username": phone_number,
|
|
|
|
|
"email": f"{phone_number}@otp.local",
|
|
|
|
|
},
|
2026-03-20 23:16:53 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
refresh = RefreshToken.for_user(user)
|
|
|
|
|
|
|
|
|
|
user_data = _auth_user_to_data(user)
|
2026-02-19 01:19:22 +03:30
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"code": 200,
|
|
|
|
|
"msg": "success",
|
|
|
|
|
"data": user_data,
|
2026-03-20 23:16:53 +03:30
|
|
|
"token": {
|
|
|
|
|
"access": str(refresh.access_token),
|
|
|
|
|
"refresh": str(refresh),
|
|
|
|
|
},
|
2026-02-19 01:19:22 +03:30
|
|
|
},
|
|
|
|
|
status=status.HTTP_200_OK,
|
|
|
|
|
)
|