This commit is contained in:
2026-08-24 15:54:08 +03:30
commit 236002a87c
133 changed files with 5946 additions and 0 deletions
View File
+31
View File
@@ -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),
}
+40
View File
@@ -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)
+135
View File
@@ -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
+25
View File
@@ -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}
+100
View File
@@ -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": "حساب کاربری با موفقیت حذف شد."})