adding
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.account.models import User
|
||||
|
||||
|
||||
class UserSelector:
|
||||
@staticmethod
|
||||
def get_user_profile(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"phone": user.phone,
|
||||
"name": user.name,
|
||||
"state": user.state,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_tokens_for_user(user: User) -> dict:
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return {
|
||||
"refresh": str(refresh),
|
||||
"access": str(refresh.access_token),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_token_response(user: User) -> dict:
|
||||
return {
|
||||
"message": "ورود با موفقیت انجام شد.",
|
||||
"tokens": UserSelector.get_tokens_for_user(user),
|
||||
"user": UserSelector.get_user_profile(user),
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.account.models import phone_validator
|
||||
|
||||
|
||||
class RequestOTPSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=15, required=True, label="شماره موبایل")
|
||||
referral_code = serializers.CharField(max_length=8, required=False, allow_blank=True, label="کد معرف")
|
||||
|
||||
def validate_phone(self, value):
|
||||
phone_validator(value)
|
||||
return value
|
||||
|
||||
|
||||
class VerifyOTPSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=15, required=True, label="شماره موبایل")
|
||||
otp = serializers.CharField(max_length=6, required=True, label="کد تایید")
|
||||
|
||||
def validate_phone(self, value):
|
||||
phone_validator(value)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
from .services import UserService
|
||||
|
||||
user = UserService.verify_user(attrs["phone"], attrs["otp"])
|
||||
attrs["user"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class LogoutSerializer(serializers.Serializer):
|
||||
refresh = serializers.CharField(required=True, label="توکن رفرش")
|
||||
|
||||
|
||||
class ConfirmWithOTPSerializer(serializers.Serializer):
|
||||
otp = serializers.CharField(max_length=6, required=True, label="کد تایید")
|
||||
|
||||
|
||||
class ProfileUpdateSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=100, allow_blank=True, required=False)
|
||||
@@ -0,0 +1,135 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
|
||||
from apps.account.models import User, phone_validator
|
||||
from apps.account.send_sms import send_otp_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hash_otp(otp: str) -> str:
|
||||
secret_key = settings.SECRET_KEY.encode("utf-8")
|
||||
signature = hmac.new(secret_key, msg=otp.encode("utf-8"), digestmod=hashlib.sha256)
|
||||
return signature.hexdigest()
|
||||
|
||||
|
||||
class UserService:
|
||||
OTP_EXPIRY_SECONDS = 120
|
||||
OTP_LENGTH = 4
|
||||
# فقط برای توسعه/تست — بدون نیاز به سرویس پیامک واقعی (که هنوز در .env
|
||||
# تنظیم نشده)، کد تایید همیشه همین است. فقط زمانی که DEBUG=True باشد
|
||||
# فعال است؛ در پروداکشن (DEBUG=False) خودکار به کد تصادفی واقعی برمیگردد
|
||||
# — وگرنه هرکسی با دانستن شمارهی هرکس میتوانست وارد حسابش شود.
|
||||
DEV_FIXED_OTP = "1111"
|
||||
|
||||
@staticmethod
|
||||
def _generate_otp() -> str:
|
||||
if settings.DEBUG:
|
||||
return UserService.DEV_FIXED_OTP
|
||||
return "".join(random.choices(string.digits, k=UserService.OTP_LENGTH))
|
||||
|
||||
@staticmethod
|
||||
def send_login_code(phone: str, referral_code: str | None = None) -> dict:
|
||||
"""ساخت/یافتن کاربر بر اساس شماره و ارسال کد تایید"""
|
||||
phone_validator(phone)
|
||||
|
||||
with transaction.atomic():
|
||||
user, created = User.objects.get_or_create(
|
||||
phone=phone,
|
||||
defaults={"state": User.State.INACTIVE, "is_active": True},
|
||||
)
|
||||
|
||||
if created and referral_code:
|
||||
from apps.billing.api.services import ReferralService
|
||||
|
||||
ReferralService.link_referrer(user, referral_code)
|
||||
|
||||
if not created and user.state == User.State.BLOCKED:
|
||||
raise DRFValidationError({"phone": "حساب کاربری مسدود شده است"})
|
||||
|
||||
if not created and user.otp_code and user.otp_expiry and timezone.now() < user.otp_expiry:
|
||||
remaining = (user.otp_expiry - timezone.now()).seconds
|
||||
raise DRFValidationError(
|
||||
{"phone": f"کد تایید قبلی هنوز معتبر است. {remaining} ثانیه دیگر منقضی میشود."}
|
||||
)
|
||||
|
||||
otp = UserService._generate_otp()
|
||||
user.otp_code = _hash_otp(otp)
|
||||
user.otp_expiry = timezone.now() + timedelta(seconds=UserService.OTP_EXPIRY_SECONDS)
|
||||
user.save(update_fields=["otp_code", "otp_expiry"])
|
||||
|
||||
send_otp_message(phone, otp)
|
||||
logger.info(f"OTP requested for {phone}")
|
||||
return {"message": "کد تایید با موفقیت ارسال شد"}
|
||||
|
||||
@staticmethod
|
||||
def verify_user(phone: str, otp: str) -> User:
|
||||
try:
|
||||
user = User.objects.get(phone=phone)
|
||||
except User.DoesNotExist:
|
||||
raise DRFValidationError({"phone": "کاربر یافت نشد"})
|
||||
|
||||
if not user.otp_code or not user.otp_expiry:
|
||||
raise DRFValidationError({"otp": "کد تایید درخواست نشده است"})
|
||||
|
||||
if timezone.now() > user.otp_expiry:
|
||||
user.otp_code = None
|
||||
user.otp_expiry = None
|
||||
user.save(update_fields=["otp_code", "otp_expiry"])
|
||||
raise DRFValidationError({"otp": "کد تایید منقضی شده است"})
|
||||
|
||||
if not hmac.compare_digest(user.otp_code, _hash_otp(otp.strip())):
|
||||
raise DRFValidationError({"otp": "کد وارد شده نامعتبر است"})
|
||||
|
||||
user.state = User.State.ACTIVE
|
||||
user.last_login = timezone.now()
|
||||
user.otp_code = None
|
||||
user.otp_expiry = None
|
||||
user.save(update_fields=["state", "last_login", "otp_code", "otp_expiry"])
|
||||
|
||||
from apps.billing.api.services import ReferralService
|
||||
|
||||
ReferralService.grant_bonus_if_needed(user)
|
||||
|
||||
logger.info(f"User {user.phone} verified successfully")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def verify_otp_for_action(user: User, otp: str) -> bool:
|
||||
"""تایید OTP برای عملیات حساس (مثل حذف حساب)"""
|
||||
if not user.otp_code or not user.otp_expiry:
|
||||
raise DRFValidationError({"otp": "ابتدا کد تایید درخواست کنید"})
|
||||
|
||||
if timezone.now() > user.otp_expiry:
|
||||
user.otp_code = None
|
||||
user.otp_expiry = None
|
||||
user.save(update_fields=["otp_code", "otp_expiry"])
|
||||
raise DRFValidationError({"otp": "کد تایید منقضی شده است"})
|
||||
|
||||
if not hmac.compare_digest(user.otp_code, _hash_otp(otp.strip())):
|
||||
raise DRFValidationError({"otp": "کد وارد شده نامعتبر است"})
|
||||
|
||||
user.otp_code = None
|
||||
user.otp_expiry = None
|
||||
user.save(update_fields=["otp_code", "otp_expiry"])
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def update_user_profile(user: User, validated_data: dict) -> User:
|
||||
update_fields = []
|
||||
for key in ("name",):
|
||||
if key in validated_data:
|
||||
setattr(user, key, validated_data[key])
|
||||
update_fields.append(key)
|
||||
if update_fields:
|
||||
user.save(update_fields=update_fields)
|
||||
return user
|
||||
@@ -0,0 +1,25 @@
|
||||
from rest_framework.throttling import SimpleRateThrottle
|
||||
|
||||
|
||||
class PhoneRateThrottle(SimpleRateThrottle):
|
||||
"""محدودیت درخواست OTP بر اساس شماره موبایل (نه IP) تا با یک شماره ثابت اسپم نشود"""
|
||||
|
||||
scope = "otp_send"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
phone = request.data.get("phone")
|
||||
if not phone:
|
||||
return None
|
||||
return self.cache_format % {"scope": self.scope, "ident": phone}
|
||||
|
||||
|
||||
class OTPVerifyThrottle(SimpleRateThrottle):
|
||||
"""محدودیت تلاش برای تایید OTP بر اساس شماره موبایل، برای جلوگیری از brute force"""
|
||||
|
||||
scope = "otp_verify"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
phone = request.data.get("phone")
|
||||
if not phone:
|
||||
return None
|
||||
return self.cache_format % {"scope": self.scope, "ident": phone}
|
||||
@@ -0,0 +1,100 @@
|
||||
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 rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.account.models import TermsAndConditions
|
||||
|
||||
from .selectors import UserSelector
|
||||
from .serializers import (
|
||||
ConfirmWithOTPSerializer,
|
||||
LogoutSerializer,
|
||||
ProfileUpdateSerializer,
|
||||
RequestOTPSerializer,
|
||||
VerifyOTPSerializer,
|
||||
)
|
||||
from .services import UserService
|
||||
from .throttles import OTPVerifyThrottle, PhoneRateThrottle
|
||||
|
||||
|
||||
class RequestOTPView(APIView):
|
||||
"""ارسال کد تایید به شماره موبایل (کاربر جدید هم همینجا خودکار ساخته میشود)"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [PhoneRateThrottle]
|
||||
|
||||
def post(self, request):
|
||||
serializer = RequestOTPSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = UserService.send_login_code(
|
||||
serializer.validated_data["phone"], referral_code=serializer.validated_data.get("referral_code")
|
||||
)
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TermsView(APIView):
|
||||
"""متن قوانین و مقررات — قبل از لاگین هم باید قابلخواندن باشد."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
return Response({"content": TermsAndConditions.get_solo().content})
|
||||
|
||||
|
||||
class VerifyOTPView(APIView):
|
||||
"""تایید کد و دریافت توکنهای JWT"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [OTPVerifyThrottle]
|
||||
|
||||
def post(self, request):
|
||||
serializer = VerifyOTPSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.validated_data["user"]
|
||||
return Response(UserSelector.get_token_response(user), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
serializer = LogoutSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
RefreshToken(serializer.validated_data["refresh"]).blacklist()
|
||||
except Exception:
|
||||
pass
|
||||
return Response({"message": "خروج با موفقیت انجام شد."})
|
||||
|
||||
|
||||
class ProfileView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response({"success": True, "data": UserSelector.get_user_profile(request.user)})
|
||||
|
||||
def patch(self, request):
|
||||
serializer = ProfileUpdateSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = UserService.update_user_profile(request.user, serializer.validated_data)
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "پروفایل با موفقیت بروزرسانی شد.",
|
||||
"data": UserSelector.get_user_profile(user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ProfileDeleteView(APIView):
|
||||
"""حذف حساب کاربری — نیاز به تایید OTP دارد (اول /auth/request-otp/ بزن)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def delete(self, request):
|
||||
serializer = ConfirmWithOTPSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
UserService.verify_otp_for_action(request.user, serializer.validated_data["otp"])
|
||||
request.user.delete()
|
||||
return Response({"message": "حساب کاربری با موفقیت حذف شد."})
|
||||
Reference in New Issue
Block a user