feat(backend): migrate auth and notifications off email
This commit is contained in:
309
apps/users/services/auth.py
Normal file
309
apps/users/services/auth.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import password_validation
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
from apps.users.email_identity import (
|
||||
is_valid_mobile_number,
|
||||
normalize_email_identity,
|
||||
normalize_mobile_number,
|
||||
)
|
||||
from apps.users.models import Major, University, User
|
||||
from apps.users.tasks import send_critical_sms
|
||||
from core.authentication import create_jwt_token, create_refresh_token
|
||||
|
||||
OTP_EXPIRY_SECONDS = 120
|
||||
VERIFIED_OTP_EXPIRY_SECONDS = 900
|
||||
OTP_KEY_PREFIX = "auth_otp"
|
||||
OTP_VERIFIED_KEY_PREFIX = "auth_otp_verified"
|
||||
SMS_KIND_REGISTER = "auth_register_otp"
|
||||
SMS_KIND_LOGIN = "auth_login_otp"
|
||||
SMS_KIND_RESET_PASSWORD = "auth_reset_password_otp"
|
||||
SMS_KIND_VERIFY_MOBILE = "auth_verify_mobile_otp"
|
||||
|
||||
OTP_MODE_SMS_KIND = {
|
||||
"register": SMS_KIND_REGISTER,
|
||||
"login": SMS_KIND_LOGIN,
|
||||
"reset_password": SMS_KIND_RESET_PASSWORD,
|
||||
"verify_mobile": SMS_KIND_VERIFY_MOBILE,
|
||||
"google_claim": SMS_KIND_VERIFY_MOBILE,
|
||||
}
|
||||
|
||||
|
||||
class AuthServiceError(Exception):
|
||||
def __init__(self, message: str, *, field: str = "detail", status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.field = field
|
||||
self.status_code = status_code
|
||||
|
||||
def to_response(self) -> dict[str, str]:
|
||||
return {"error": self.message}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistrationPayload:
|
||||
username: str
|
||||
mobile: str
|
||||
password: str
|
||||
code: str
|
||||
email: str | None = None
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
university: str | None = None
|
||||
student_id: str | None = None
|
||||
year_of_study: int | None = None
|
||||
major: str | None = None
|
||||
|
||||
|
||||
def _otp_key(mode: str, mobile: str) -> str:
|
||||
return f"{OTP_KEY_PREFIX}:{mode}:{mobile}"
|
||||
|
||||
|
||||
def _verified_otp_key(mode: str, mobile: str) -> str:
|
||||
return f"{OTP_VERIFIED_KEY_PREFIX}:{mode}:{mobile}"
|
||||
|
||||
|
||||
def _redis():
|
||||
return get_redis_connection("default")
|
||||
|
||||
|
||||
def _validate_new_password(password: str, *, user: User | None = None, field_name: str = "password") -> None:
|
||||
try:
|
||||
password_validation.validate_password(password, user=user)
|
||||
except DjangoValidationError as exc:
|
||||
message = exc.messages[0] if len(exc.messages) == 1 else exc.messages[0]
|
||||
raise AuthServiceError(message, field=field_name)
|
||||
|
||||
|
||||
def _resolve_user_by_identifier(identifier: str) -> User | None:
|
||||
normalized_mobile = normalize_mobile_number(identifier)
|
||||
if is_valid_mobile_number(normalized_mobile):
|
||||
return User.objects.filter(mobile=normalized_mobile).first()
|
||||
normalized_email = normalize_email_identity(identifier)
|
||||
if normalized_email:
|
||||
return User.objects.filter(email=normalized_email).first()
|
||||
return None
|
||||
|
||||
|
||||
def _get_major_from_code(code: str | None) -> Major | None:
|
||||
if not code:
|
||||
return None
|
||||
return Major.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def _get_university_from_code(code: str | None) -> University | None:
|
||||
if not code:
|
||||
return None
|
||||
return University.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def _normalize_otp_code(code: str) -> str:
|
||||
normalized = (
|
||||
str(code or "")
|
||||
.strip()
|
||||
.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"))
|
||||
)
|
||||
if len(normalized) != 5 or not normalized.isdigit():
|
||||
raise AuthServiceError("کد تایید باید شامل ۵ رقم باشد.", field="code")
|
||||
return normalized
|
||||
|
||||
|
||||
def _issue_otp(mobile: str, mode: str) -> dict[str, str | int]:
|
||||
verification_code = "".join(random.choices(string.digits, k=5))
|
||||
redis_conn = _redis()
|
||||
redis_conn.setex(_otp_key(mode, mobile), OTP_EXPIRY_SECONDS, verification_code)
|
||||
send_critical_sms.delay(mobile, OTP_MODE_SMS_KIND.get(mode, SMS_KIND_VERIFY_MOBILE), verification_code)
|
||||
expires_at = timezone.now() + timedelta(seconds=OTP_EXPIRY_SECONDS)
|
||||
return {
|
||||
"message": "کد تایید با موفقیت ارسال شد.",
|
||||
"expires_in_seconds": OTP_EXPIRY_SECONDS,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def verify_otp_code(*, mobile: str, code: str, mode: str) -> None:
|
||||
normalized_code = _normalize_otp_code(code)
|
||||
redis_conn = _redis()
|
||||
stored_code = redis_conn.get(_otp_key(mode, mobile))
|
||||
if not stored_code or stored_code.decode("utf-8") != normalized_code:
|
||||
raise AuthServiceError("کد تایید نامعتبر است یا منقضی شده است.", field="code")
|
||||
redis_conn.delete(_otp_key(mode, mobile))
|
||||
|
||||
|
||||
def verify_register_otp(mobile: str, code: str) -> None:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="register")
|
||||
_redis().setex(_verified_otp_key("register", normalized_mobile), VERIFIED_OTP_EXPIRY_SECONDS, "1")
|
||||
|
||||
|
||||
def _consume_verified_otp(mobile: str, mode: str) -> bool:
|
||||
redis_conn = _redis()
|
||||
key = _verified_otp_key(mode, mobile)
|
||||
if redis_conn.get(key):
|
||||
redis_conn.delete(key)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_tokens_for_user(user: User) -> dict[str, str]:
|
||||
return {
|
||||
"access_token": create_jwt_token(user),
|
||||
"refresh_token": create_refresh_token(user),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
def generate_and_send_otp(mobile: str, mode: str) -> dict[str, str | int]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
user_exists = User.objects.filter(mobile=normalized_mobile).exists()
|
||||
|
||||
if mode == "register" and user_exists:
|
||||
raise AuthServiceError("این شماره قبلاً ثبت شده است.", field="mobile")
|
||||
if mode in {"login", "reset_password"} and not user_exists:
|
||||
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
|
||||
|
||||
return _issue_otp(normalized_mobile, mode)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def register_user(payload: RegistrationPayload) -> User:
|
||||
if payload.student_id and len(str(payload.student_id)) < 10:
|
||||
raise AuthServiceError("شماره دانشجویی باید حداقل ۱۰ رقم باشد.", field="student_id")
|
||||
|
||||
if User.objects.filter(username=payload.username).exists():
|
||||
raise AuthServiceError("نام کاربری قبلاً استفاده شده است.", field="username")
|
||||
|
||||
normalized_email = normalize_email_identity(payload.email)
|
||||
if normalized_email and User.objects.filter(email=normalized_email).exists():
|
||||
raise AuthServiceError("این ایمیل قبلاً ثبت شده است.", field="email")
|
||||
|
||||
normalized_mobile = normalize_mobile_number(payload.mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
if not _consume_verified_otp(normalized_mobile, "register"):
|
||||
verify_otp_code(mobile=normalized_mobile, code=payload.code, mode="register")
|
||||
_validate_new_password(payload.password)
|
||||
|
||||
major = _get_major_from_code(payload.major)
|
||||
if payload.major and not major:
|
||||
raise AuthServiceError("رشته انتخابی معتبر نیست.", field="major")
|
||||
|
||||
university = _get_university_from_code(payload.university)
|
||||
if payload.university and not university:
|
||||
raise AuthServiceError("دانشگاه انتخابی معتبر نیست.", field="university")
|
||||
|
||||
if payload.student_id and university and User.objects.filter(
|
||||
university=university,
|
||||
student_id=payload.student_id,
|
||||
).exists():
|
||||
raise AuthServiceError(
|
||||
"این شماره دانشجویی در دانشگاه انتخابی قبلاً ثبت شده است.",
|
||||
field="student_id",
|
||||
)
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=payload.username,
|
||||
email=normalized_email,
|
||||
mobile=normalized_mobile,
|
||||
password=payload.password,
|
||||
first_name=payload.first_name or "",
|
||||
last_name=payload.last_name or "",
|
||||
student_id=payload.student_id,
|
||||
year_of_study=payload.year_of_study,
|
||||
major=major,
|
||||
university=university,
|
||||
is_email_verified=bool(normalized_email),
|
||||
is_mobile_verified=True,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def login_with_password(identifier: str, password: str) -> dict[str, str]:
|
||||
user = _resolve_user_by_identifier(identifier)
|
||||
if not user or not user.check_password(password):
|
||||
raise AuthServiceError("شناسه ورود یا رمز عبور نادرست است.", status_code=401)
|
||||
if not user.is_active:
|
||||
raise AuthServiceError("حساب کاربری شما غیرفعال است.", status_code=401)
|
||||
return get_tokens_for_user(user)
|
||||
|
||||
|
||||
def login_with_otp(mobile: str, code: str) -> dict[str, str]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
user = User.objects.filter(mobile=normalized_mobile).first()
|
||||
if not user:
|
||||
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
|
||||
if not user.is_active:
|
||||
raise AuthServiceError("حساب کاربری شما غیرفعال است.", status_code=401)
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="login")
|
||||
if not user.is_mobile_verified:
|
||||
user.is_mobile_verified = True
|
||||
user.save(update_fields=["is_mobile_verified"])
|
||||
return get_tokens_for_user(user)
|
||||
|
||||
|
||||
def reset_password_with_otp(mobile: str, code: str, password: str) -> None:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
user = User.objects.filter(mobile=normalized_mobile).first()
|
||||
if not user:
|
||||
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
|
||||
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="reset_password")
|
||||
_validate_new_password(password, user=user)
|
||||
if user.check_password(password):
|
||||
raise AuthServiceError("رمز عبور جدید نباید با رمز قبلی یکسان باشد.", field="password")
|
||||
user.set_password(password)
|
||||
user.save(update_fields=["password"])
|
||||
|
||||
|
||||
def send_authenticated_mobile_otp(user: User, mobile: str) -> dict[str, str | int]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=user.pk).exists()
|
||||
if conflict:
|
||||
raise AuthServiceError("این شماره موبایل قبلاً به حساب دیگری متصل شده است.", field="mobile")
|
||||
return _issue_otp(normalized_mobile, "verify_mobile")
|
||||
|
||||
|
||||
def verify_authenticated_mobile_otp(user: User, mobile: str, code: str) -> User:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=user.pk).exists()
|
||||
if conflict:
|
||||
raise AuthServiceError("این شماره موبایل قبلاً به حساب دیگری متصل شده است.", field="mobile")
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="verify_mobile")
|
||||
user.mobile = normalized_mobile
|
||||
user.is_mobile_verified = True
|
||||
user.save(update_fields=["mobile", "is_mobile_verified"])
|
||||
return user
|
||||
|
||||
|
||||
def lookup_mobile_registration_state(mobile: str) -> dict[str, bool]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
user = User.objects.filter(mobile=normalized_mobile).first()
|
||||
return {
|
||||
"exists": bool(user),
|
||||
"has_password": bool(user and user.has_usable_password()),
|
||||
}
|
||||
Reference in New Issue
Block a user