UPDATE
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "auth"
|
||||
label = "auth_api" # Avoid clash with django.contrib.auth (label "auth")
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Auth",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"description": "Auth API. Request OTP, Verify OTP."
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Request OTP",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"phone_number\": \"\"\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/auth/request-otp/",
|
||||
"description": "Request OTP for the given phone number. In DEBUG mode, response includes debug_note."
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "Success",
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"token\": \"\"\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Verify OTP",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"token\": \"\",\n \"otp_code\": \"\"\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/auth/verify-otp/",
|
||||
"description": "Verify OTP with token from request-otp and otp_code sent to user."
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "Success",
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n },\n \"token\": \"\"\n}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{"key": "baseUrl", "value": "http://localhost:8000"},
|
||||
{"key": "token", "value": ""}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
# --- Register ---
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
"""Request body for POST /api/auth/register/."""
|
||||
|
||||
username = serializers.CharField(max_length=150)
|
||||
email = serializers.EmailField()
|
||||
phone_number = serializers.CharField(max_length=32)
|
||||
password = serializers.CharField(min_length=8, write_only=True)
|
||||
first_name = serializers.CharField(max_length=150, required=False, default="")
|
||||
last_name = serializers.CharField(max_length=150, required=False, default="")
|
||||
|
||||
|
||||
# --- Login ---
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
"""Request body for POST /api/auth/login/.
|
||||
identifier can be username, email, or phone_number."""
|
||||
|
||||
identifier = serializers.CharField()
|
||||
password = serializers.CharField(min_length=8, write_only=True)
|
||||
|
||||
|
||||
# --- RequestOTP (request-otp/) ---
|
||||
class RequestOTPSerializer(serializers.Serializer):
|
||||
"""Request body for POST /api/auth/request-otp/."""
|
||||
|
||||
phone_number = serializers.CharField(max_length=32)
|
||||
|
||||
|
||||
# --- VerifyOTP (verify-otp/) ---
|
||||
class VerifyOTPSerializer(serializers.Serializer):
|
||||
"""Request body for POST /api/auth/verify-otp/."""
|
||||
|
||||
token = serializers.CharField()
|
||||
otp_code = serializers.CharField(max_length=10)
|
||||
|
||||
|
||||
# --- AuthUser (used in VerifyOTPResponse and UpdateProfileResponse) ---
|
||||
class AuthUserSerializer(serializers.Serializer):
|
||||
"""User data returned in auth/account responses."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
username = serializers.CharField()
|
||||
email = serializers.EmailField(allow_blank=True)
|
||||
first_name = serializers.CharField()
|
||||
last_name = serializers.CharField()
|
||||
phone_number = serializers.CharField()
|
||||
@@ -0,0 +1,59 @@
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_otp_sms(phone_number: str, otp_code: str) -> bool:
|
||||
"""Send OTP code via SMS.ir bulk API.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
api_key = getattr(settings, "SMS_IR_API_KEY", "")
|
||||
line_number = getattr(settings, "SMS_IR_LINE_NUMBER", 300000000000)
|
||||
|
||||
if not api_key:
|
||||
logger.error("SMS_IR_API_KEY is not configured.")
|
||||
return False
|
||||
|
||||
message_text = f"کد تایید شما: {otp_code}"
|
||||
|
||||
payload = json.dumps({
|
||||
"lineNumber": line_number,
|
||||
"messageText": message_text,
|
||||
"mobiles": [phone_number],
|
||||
"sendDateTime": None,
|
||||
})
|
||||
|
||||
headers = {
|
||||
"X-API-KEY": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
conn = http.client.HTTPSConnection("api.sms.ir")
|
||||
conn.request("POST", "/v1/send/bulk", payload, headers)
|
||||
res = conn.getresponse()
|
||||
data = res.read().decode("utf-8")
|
||||
conn.close()
|
||||
|
||||
response = json.loads(data)
|
||||
status_code = response.get("status")
|
||||
|
||||
if res.status == 200 and status_code == 1:
|
||||
logger.info("SMS sent successfully to %s", phone_number)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"SMS.ir returned unexpected response: HTTP %s, body: %s",
|
||||
res.status,
|
||||
data,
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send SMS to %s", phone_number)
|
||||
return False
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import AuthenticationView, LoginView, RegisterView
|
||||
|
||||
urlpatterns = [
|
||||
path("register/", RegisterView.as_view(), name="register"),
|
||||
path("login/", LoginView.as_view(), name="login"),
|
||||
# path("request-otp/", AuthenticationView.as_view(), name="request-otp"),
|
||||
# path("verify-otp/", AuthenticationView.as_view(), name="verify-otp"),
|
||||
]
|
||||
@@ -0,0 +1,208 @@
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.core.cache import cache
|
||||
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
|
||||
from django.db import IntegrityError
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
from account.models import User
|
||||
from config.swagger import code_response
|
||||
from .serializers import (
|
||||
AuthUserSerializer,
|
||||
LoginSerializer,
|
||||
RegisterSerializer,
|
||||
RequestOTPSerializer,
|
||||
VerifyOTPSerializer,
|
||||
)
|
||||
from .sms_service import send_otp_sms
|
||||
|
||||
|
||||
OTP_TTL_SECONDS = 300
|
||||
OTP_SIGNER = TimestampSigner(salt="auth.otp")
|
||||
|
||||
|
||||
def _auth_user_to_data(user):
|
||||
if user is None or not getattr(user, "pk", None):
|
||||
return None
|
||||
return {
|
||||
"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 "",
|
||||
}
|
||||
|
||||
|
||||
def _issue_token(user):
|
||||
return str(AccessToken.for_user(user))
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
tags=["Authentication"],
|
||||
request=RegisterSerializer,
|
||||
responses={
|
||||
201: code_response("RegisterResponse", data=AuthUserSerializer(), token=True),
|
||||
400: code_response("RegisterErrorResponse"),
|
||||
},
|
||||
),
|
||||
)
|
||||
class RegisterView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
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)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": 201,
|
||||
"msg": "success",
|
||||
"data": _auth_user_to_data(user),
|
||||
"token": _issue_token(user),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
tags=["Authentication"],
|
||||
request=LoginSerializer,
|
||||
responses={
|
||||
200: code_response("LoginResponse", data=AuthUserSerializer(), token=True),
|
||||
401: code_response("LoginErrorResponse"),
|
||||
},
|
||||
),
|
||||
)
|
||||
class LoginView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
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)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": _auth_user_to_data(user),
|
||||
"token": _issue_token(user),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@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"),
|
||||
},
|
||||
),
|
||||
)
|
||||
class AuthenticationView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
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)
|
||||
sms_sent = send_otp_sms(phone_number, otp_code)
|
||||
|
||||
payload = {"code": 200, "msg": "success", "token": otp_token}
|
||||
if not sms_sent:
|
||||
payload["sms_warning"] = "SMS delivery failed; OTP stored server-side."
|
||||
if settings.DEBUG:
|
||||
payload["debug_otp"] = otp_code
|
||||
return Response(payload, status=status.HTTP_200_OK)
|
||||
|
||||
def _verify_otp(self, request):
|
||||
serializer = VerifyOTPSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
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,
|
||||
defaults={
|
||||
"username": phone_number,
|
||||
"email": f"{phone_number}@otp.local",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": _auth_user_to_data(user),
|
||||
"token": _issue_token(user),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
Reference in New Issue
Block a user