Files
sarlinkportal/backend/users/views.py
T
2026-09-22 00:50:30 +05:00

263 lines
8.5 KiB
Python

"""Authentication and registration.
1. POST /api/auth/start/ {mobile}
-> {"next": "password"} the account signs in with a password
-> {"next": "otp", ...} a code was sent by SMS
2. POST /api/auth/login/password/ {mobile, password}
-> {"next": "dashboard", "token", "expiry", "user"}
POST /api/auth/verify/ {mobile, code}
-> {"next": "dashboard", "token", "expiry", "user"}
-> {"next": "register", "registration_token", "mobile", "expires_at"}
3. POST /api/auth/register/ {registration_token, ...form}
-> 201 {"status": "pending", ...}
Whether a number has an account is only answered once its owner has confirmed
a code, so `start` looks the same for every valid number.
"""
import logging
from django.conf import settings
from django.contrib.auth import authenticate
from django.utils import timezone
from knox.views import LoginView as KnoxLoginView
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import OtpCode, RegistrationTicket, User
from .serializers import (
AuthStartSerializer,
OtpVerifySerializer,
PasswordLoginSerializer,
RegistrationSerializer,
UserSerializer,
)
from .sms import send_otp, send_registration_submitted
logger = logging.getLogger(__name__)
CODE_LENGTH = 6
def _issue_code(mobile: str, user: User | None) -> OtpCode:
purpose = OtpCode.Purpose.LOGIN if user else OtpCode.Purpose.REGISTRATION
otp, code = OtpCode.issue(mobile=mobile, purpose=purpose, user=user)
send_otp(mobile, code, purpose)
return otp
def _code_payload(mobile: str, otp: OtpCode | None = None) -> dict:
payload = {"next": "otp", "mobile": mobile, "code_length": CODE_LENGTH}
if otp is not None:
payload["expires_at"] = otp.expires_at
payload["resend_available_at"] = otp.resend_available_at
return payload
class AuthStartView(APIView):
"""Step 1: password box, or a code sent by SMS."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "auth_start"
def post(self, request):
serializer = AuthStartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
user = User.objects.filter(mobile=mobile).first()
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
return Response({"next": "password", "mobile": mobile})
return Response(_code_payload(mobile, _issue_code(mobile, user)))
class OtpResendView(APIView):
"""Send a fresh code, honouring the cooldown."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "otp_request"
def post(self, request):
serializer = AuthStartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
user = User.objects.filter(mobile=mobile).first()
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
return Response(_code_payload(mobile))
latest = OtpCode.objects.active().filter(mobile=mobile).first()
if latest and latest.resend_available_at > timezone.now():
return Response(
{
"detail": "A code was just sent. Try again shortly.",
"code": "resend_cooldown",
"resend_available_at": latest.resend_available_at,
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
return Response(_code_payload(mobile, _issue_code(mobile, user)))
class BaseLoginView(KnoxLoginView):
"""Issues a knox token, with the account serialised alongside it."""
authentication_classes = []
permission_classes = [AllowAny]
def issue_token(self, request, user):
request.user = user
return super().post(request, format=None)
def get_post_response_data(self, request, token, instance):
data = super().get_post_response_data(request, token, instance)
data["next"] = "dashboard"
data["user"] = UserSerializer(request.user).data
return data
class PasswordLoginView(BaseLoginView):
"""Step 2, password."""
throttle_scope = "auth_login"
def post(self, request, format=None):
serializer = PasswordLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["mobile"],
password=serializer.validated_data["password"],
)
if user is None:
return Response(
{
"detail": "Incorrect mobile number or password.",
"code": "invalid_credentials",
},
status=status.HTTP_400_BAD_REQUEST,
)
return self.issue_token(request, user)
class VerifyCodeView(BaseLoginView):
"""Step 2, SMS code.
A confirmed code either signs the account in or, when the number has no
account, hands back the ticket the registration form needs.
"""
throttle_scope = "auth_login"
def post(self, request, format=None):
serializer = OtpVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
otp = (
OtpCode.objects.active()
.filter(mobile=mobile)
.select_related("user")
.first()
)
if otp is None:
return Response(
{
"detail": "That code has expired. Request a new one.",
"code": "code_expired",
},
status=status.HTTP_400_BAD_REQUEST,
)
if otp.is_exhausted:
return Response(
{
"detail": "Too many incorrect attempts. Request a new code.",
"code": "code_exhausted",
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not otp.verify(serializer.validated_data["code"]):
return Response(
{
"detail": "That code is not correct.",
"code": "invalid_code",
"attempts_left": max(0, settings.OTP_MAX_ATTEMPTS - otp.attempts),
},
status=status.HTTP_400_BAD_REQUEST,
)
user = otp.user or User.objects.filter(mobile=mobile).first()
if user is None:
ticket = RegistrationTicket.issue(mobile)
return Response(
{
"next": "register",
"registration_token": ticket.key,
"mobile": mobile,
"expires_at": ticket.expires_at,
}
)
if not user.is_active:
return Response(
{"detail": "This account is disabled.", "code": "account_disabled"},
status=status.HTTP_403_FORBIDDEN,
)
if not user.mobile_verified:
user.mobile_verified = True
user.save(update_fields=["mobile_verified", "updated_at"])
return self.issue_token(request, user)
class RegisterView(APIView):
"""Submit the registration form. Creates a pending account - no token."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "register"
def post(self, request):
serializer = RegistrationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
send_registration_submitted(user.mobile)
logger.info("Registration submitted for %s (user %s)", user.mobile, user.pk)
return Response(
{
"status": user.status,
"mobile": user.mobile,
"full_name": user.full_name,
"detail": (
"Your registration is pending approval. We'll text you "
"once it has been reviewed."
),
},
status=status.HTTP_201_CREATED,
)
class MeView(APIView):
"""The signed-in account, for the SPA to hydrate its session."""
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(UserSerializer(request.user).data)