feat(backend): migrate auth and notifications off email
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-21 10:28:04 +03:30
parent b4903f7cb1
commit b7b21a6cc6
35 changed files with 2784 additions and 1390 deletions

View File

@@ -1,16 +1,20 @@
"""Authentication-related API schemas."""
from ninja import Schema, ModelSchema
from datetime import datetime
from typing import Optional
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
from ninja import ModelSchema, Schema
from apps.users.models import User
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
class UserRegistrationSchema(Schema):
username: str
email: str
mobile: str
code: str
password: str
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
university: Optional[str] = None
@@ -18,10 +22,71 @@ class UserRegistrationSchema(Schema):
year_of_study: Optional[int] = None
major: Optional[str] = None
class UserLoginSchema(Schema):
email: str
identifier: str
password: str
class UserOtpLoginSchema(Schema):
mobile: str
code: str
class RegisterOtpVerifySchema(Schema):
mobile: str
code: str
class OtpSendSchema(Schema):
mobile: str
mode: str
class MobileOtpSendSchema(Schema):
mobile: str
class MobileOtpVerifySchema(Schema):
mobile: str
code: str
class GoogleFlowSchema(Schema):
flow: str
class GoogleClaimVerifySchema(Schema):
flow: str
code: str
class GoogleCompleteSchema(Schema):
flow: str
mobile: str
username: Optional[str] = None
student_id: Optional[str] = None
year_of_study: Optional[int] = None
major: Optional[str] = None
university: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
class GoogleFlowResponseSchema(Schema):
status: str
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
avatar_url: Optional[str] = None
resolution: Optional[str] = None
mobile: Optional[str] = None
mobile_hint: Optional[str] = None
detail: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
class UserProfileSchema(ModelSchema):
profile_picture: Optional[str] = None
profile_picture_thumbnail_url: Optional[str] = None
@@ -29,27 +94,32 @@ class UserProfileSchema(ModelSchema):
student_id: Optional[str] = None
major: Optional[str] = None
university: Optional[str] = None
mobile: Optional[str] = None
requires_mobile_verification: bool
has_google_link: bool
class Meta:
model = User
fields = [
'id',
'username',
'email',
'first_name',
'last_name',
'student_id',
'year_of_study',
'major',
'university',
'bio',
'date_joined',
'is_email_verified',
'is_active',
'is_staff',
'is_superuser',
'is_deleted',
'deleted_at',
"id",
"username",
"email",
"mobile",
"first_name",
"last_name",
"student_id",
"year_of_study",
"major",
"university",
"bio",
"date_joined",
"is_email_verified",
"is_mobile_verified",
"is_active",
"is_staff",
"is_superuser",
"is_deleted",
"deleted_at",
]
@staticmethod
@@ -60,14 +130,18 @@ class UserProfileSchema(ModelSchema):
def resolve_university(obj):
return obj.get_university_display()
@staticmethod
def resolve_requires_mobile_verification(obj):
return obj.requires_mobile_verification
@staticmethod
def resolve_has_google_link(obj):
return obj.has_google_link
@staticmethod
def resolve_profile_picture(obj, context):
"""
Resolves the absolute URL for the profile picture.
`context` contains the request object, which is needed for build_absolute_uri.
"""
request = context['request']
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
request = context["request"]
if obj.profile_picture and hasattr(obj.profile_picture, "url"):
return request.build_absolute_uri(obj.profile_picture.url)
return None
@@ -87,27 +161,26 @@ class UserProfileSchema(ModelSchema):
class UserListSchema(ModelSchema):
major: Optional[str] = None
university: Optional[str] = None
mobile: Optional[str] = None
class Meta:
model = User
fields = [
'id',
'username',
'email',
'first_name',
'last_name',
'is_active',
'is_staff',
'is_superuser',
'date_joined',
'major',
'university',
"id",
"username",
"email",
"mobile",
"first_name",
"last_name",
"is_active",
"is_staff",
"is_superuser",
"date_joined",
"major",
"university",
"is_mobile_verified",
]
@staticmethod
def resolve_full_name(obj):
return obj.get_full_name()
@staticmethod
def resolve_major(obj):
return obj.get_major_display()
@@ -116,7 +189,9 @@ class UserListSchema(ModelSchema):
def resolve_university(obj):
return obj.get_university_display()
class UserUpdateSchema(Schema):
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
bio: Optional[str] = None
@@ -125,20 +200,33 @@ class UserUpdateSchema(Schema):
university: Optional[str] = None
student_id: Optional[str] = None
class TokenSchema(Schema):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenRefreshIn(Schema):
refresh_token: str
class PasswordResetRequestSchema(Schema):
email: str
class PasswordResetConfirmSchema(Schema):
token: str
class PasswordResetSchema(Schema):
mobile: str
code: str
new_password: str
class UsernameCheckSchema(Schema):
exists: bool
class MobileLookupSchema(Schema):
exists: bool
has_password: bool
class OtpSendResponseSchema(Schema):
message: str
expires_in_seconds: int
expires_at: datetime

View File

@@ -1,36 +1,80 @@
from typing import List
from __future__ import annotations
import jwt
import uuid
from django.conf import settings
from django.contrib.auth import authenticate
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.core.files.base import ContentFile
import uuid
import jwt
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from ninja import Query, Router
from core.media import delete_image_derivatives
from apps.users.models import User, Major, University
from apps.users.tasks import send_verification_email, send_password_reset_email
from apps.users.api.schemas import (
PasswordResetConfirmSchema,
PasswordResetRequestSchema,
GoogleClaimVerifySchema,
GoogleCompleteSchema,
GoogleFlowResponseSchema,
GoogleFlowSchema,
MobileLookupSchema,
MobileOtpSendSchema,
MobileOtpVerifySchema,
OtpSendResponseSchema,
OtpSendSchema,
PasswordResetSchema,
RegisterOtpVerifySchema,
TokenRefreshIn,
TokenSchema,
UserListSchema,
UserLoginSchema,
UserOtpLoginSchema,
UserProfileSchema,
UserRegistrationSchema,
UserUpdateSchema,
UsernameCheckSchema,
)
from apps.users.email_identity import normalize_email_identity
from apps.users.models import Major, University, User
from apps.users.services.auth import (
AuthServiceError,
RegistrationPayload,
generate_and_send_otp,
get_tokens_for_user,
login_with_otp,
login_with_password,
lookup_mobile_registration_state,
register_user,
reset_password_with_otp,
send_authenticated_mobile_otp,
verify_register_otp,
verify_authenticated_mobile_otp,
)
from apps.users.services.google_oauth import (
GoogleOAuthFlowError,
build_authenticated_flow_payload,
build_google_authorization_url,
build_google_callback_redirect_url,
build_pending_google_flow_payload,
complete_google_signup,
consume_google_state,
create_google_flow,
exchange_code_for_google_profile,
find_social_account_for_profile,
get_google_flow,
send_google_claim_otp,
sync_user_from_google_profile,
verify_google_claim,
)
from core.api.schemas import ErrorSchema, MessageSchema
from core.authentication import create_jwt_token, create_refresh_token, jwt_auth
from core.media import delete_image_derivatives
auth_router = Router()
def _error_response(exc: AuthServiceError | GoogleOAuthFlowError):
return exc.status_code, {"error": exc.message}
def _get_major_from_code(code: str | None):
if not code:
return None
@@ -45,84 +89,90 @@ def _get_university_from_code(code: str | None):
@auth_router.post("/register", response={201: MessageSchema, 400: ErrorSchema})
def register(request, data: UserRegistrationSchema):
"""Register a new user"""
try:
if data.student_id and len(str(data.student_id)) < 10:
return 400, {"error": "Student ID must be at least 10 characters long."}
major_obj = None
if data.major:
major_obj = _get_major_from_code(data.major)
if not major_obj:
return 400, {"error": "Selected major is not recognized."}
university_obj = None
if data.university:
university_obj = _get_university_from_code(data.university)
if not university_obj:
return 400, {"error": "Selected university is not recognized."}
if User.objects.filter(username=data.username).exists():
return 400, {"error": "Username is already in use."}
if User.objects.filter(email=data.email).exists():
return 400, {"error": "Email is already registered."}
if (
data.student_id
and university_obj
and User.objects.filter(
university=university_obj, student_id=data.student_id
).exists()
):
return 400, {"error": "This student ID is already registered at that university."}
User.objects.create_user(
username=data.username,
email=data.email,
password=data.password,
student_id=data.student_id,
first_name=data.first_name or "",
last_name=data.last_name or "",
year_of_study=data.year_of_study,
major=major_obj,
university=university_obj,
user = register_user(
RegistrationPayload(
username=data.username,
mobile=data.mobile,
password=data.password,
code=data.code,
email=data.email,
first_name=data.first_name or "",
last_name=data.last_name or "",
university=data.university,
student_id=data.student_id,
year_of_study=data.year_of_study,
major=data.major,
)
)
except AuthServiceError as exc:
return _error_response(exc)
return 201, {"message": f"ثبت‌نام با موفقیت انجام شد. خوش آمدید {user.get_full_name() or user.username}."}
return 201, {"message": "Registration successful. Please check your inbox to verify your email."}
except Exception as e:
return 400, {
"error": "Unable to register user.",
"details": str(e),
}
@auth_router.post("/otp/send", response={200: OtpSendResponseSchema, 400: ErrorSchema})
def send_otp(request, data: OtpSendSchema):
try:
payload = generate_and_send_otp(data.mobile, data.mode)
except AuthServiceError as exc:
return _error_response(exc)
return 200, payload
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema})
@auth_router.post("/otp/verify-register", response={200: MessageSchema, 400: ErrorSchema})
def verify_register_otp_view(request, data: RegisterOtpVerifySchema):
try:
verify_register_otp(data.mobile, data.code)
except AuthServiceError as exc:
return _error_response(exc)
return 200, {"message": "کد تایید با موفقیت تایید شد."}
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema, 400: ErrorSchema})
def login(request, data: UserLoginSchema):
"""Login user and return JWT tokens"""
user = authenticate(email=data.email, password=data.password)
if not user:
return 401, {"error": "ایمیل یا رمز عبور نادرست است."}
if not user.is_email_verified:
return 401, {"error": "برای ورود، ابتدا ایمیل خود را تأیید کنید."}
if not user.is_active:
return 401, {"error": "حساب کاربری شما غیرفعال است."}
access_token = create_jwt_token(user)
refresh_token = create_refresh_token(user)
return 200, {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer"
}
try:
return 200, login_with_password(data.identifier, data.password)
except AuthServiceError as exc:
return _error_response(exc)
@auth_router.post("/login/otp", response={200: TokenSchema, 401: ErrorSchema, 400: ErrorSchema})
def login_otp(request, data: UserOtpLoginSchema):
try:
return 200, login_with_otp(data.mobile, data.code)
except AuthServiceError as exc:
return _error_response(exc)
@auth_router.post("/reset-password", response={200: MessageSchema, 400: ErrorSchema})
def reset_password(request, data: PasswordResetSchema):
try:
reset_password_with_otp(data.mobile, data.code, data.new_password)
except AuthServiceError as exc:
return _error_response(exc)
return 200, {"message": "رمز عبور با موفقیت تغییر کرد."}
@auth_router.post("/mobile/send-otp", response={200: OtpSendResponseSchema, 400: ErrorSchema}, auth=jwt_auth)
def send_mobile_verify_otp(request, data: MobileOtpSendSchema):
try:
payload = send_authenticated_mobile_otp(request.auth, data.mobile)
except AuthServiceError as exc:
return _error_response(exc)
return 200, payload
@auth_router.post("/mobile/verify", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
def verify_mobile(request, data: MobileOtpVerifySchema):
try:
user = verify_authenticated_mobile_otp(request.auth, data.mobile, data.code)
except AuthServiceError as exc:
return _error_response(exc)
return 200, user
@auth_router.post("/refresh", response={200: TokenSchema, 401: ErrorSchema})
def refresh_tokens(request, data: TokenRefreshIn):
"""Exchange a valid refresh token for a new access (and refresh) token."""
try:
payload = jwt.decode(
data.refresh_token,
@@ -136,77 +186,137 @@ def refresh_tokens(request, data: TokenRefreshIn):
if not user_id:
return 401, {"error": "داده‌های توکن نامعتبر است."}
user = get_object_or_404(User, id=user_id)
if not user.is_email_verified:
return 401, {"error": "برای استفاده، ابتدا ایمیل خود را تأیید کنید."}
if not user.is_active:
return 401, {"error": "حساب کاربری شما غیرفعال است."}
user = get_object_or_404(User, id=user_id, is_active=True)
except jwt.ExpiredSignatureError:
return 401, {"error": "رفرش‌توکن منقضی شده است."}
except jwt.InvalidTokenError:
return 401, {"error": "رفرش‌توکن نامعتبر است."}
access_token = create_jwt_token(user)
refresh_token = create_refresh_token(user)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"access_token": create_jwt_token(user),
"refresh_token": create_refresh_token(user),
"token_type": "bearer",
}
@auth_router.get("/verify-email/{token}", response={200: MessageSchema, 400: ErrorSchema})
def verify_email(request, token: str):
"""Verify user email with token"""
try:
user = get_object_or_404(User, email_verification_token=token)
if user.is_email_verified:
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
user.is_email_verified = True
user.save(update_fields=['is_email_verified'])
return 200, {"message": "ایمیل شما با موفقیت تأیید شد."}
except User.DoesNotExist:
return 400, {"error": "توکن تأیید نامعتبر است."}
@auth_router.post("/resend-verification", response={200: MessageSchema, 400: ErrorSchema})
def resend_verification(request, email: str):
"""Resend verification email"""
@auth_router.get("/oauth/google/start")
def google_oauth_start(request):
return HttpResponseRedirect(build_google_authorization_url())
@auth_router.get("/oauth/google/callback")
def google_oauth_callback(request):
if request.GET.get("error"):
flow = create_google_flow(
{
"status": "error",
"detail": request.GET.get("error_description") or "Google sign-in was cancelled.",
}
)
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
try:
user = get_object_or_404(User, email=email)
if user.is_email_verified:
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
# Generate new token
user.regenerate_verification_token()
user.email_verification_sent_at = timezone.now()
user.save(update_fields=['email_verification_sent_at'])
# Send verification email
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}"
send_verification_email.delay(user.id, verification_url)
return 200, {"message": "ایمیل تأیید برای شما ارسال شد."}
except User.DoesNotExist:
return 400, {"error": "کاربر یافت نشد."}
consume_google_state(request.GET.get("state"))
profile = exchange_code_for_google_profile(request.GET.get("code"))
social_account = find_social_account_for_profile(profile)
if social_account and social_account.user.is_mobile_verified:
sync_user_from_google_profile(social_account.user, profile)
flow_payload = build_authenticated_flow_payload(social_account.user)
elif social_account:
sync_user_from_google_profile(social_account.user, profile)
flow_payload = build_pending_google_flow_payload(profile)
flow_payload["resolution"] = "existing_email_claim"
flow_payload["target_user_id"] = social_account.user.id
flow_payload["mobile_hint"] = social_account.user.mobile
else:
flow_payload = build_pending_google_flow_payload(profile)
flow = create_google_flow(flow_payload)
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
except GoogleOAuthFlowError as exc:
flow = create_google_flow({"status": "error", "detail": exc.message})
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
@auth_router.get("/oauth/google/flow", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
def google_oauth_flow(request, flow: str):
try:
return 200, get_google_flow(flow)
except GoogleOAuthFlowError as exc:
return _error_response(exc)
@auth_router.post("/oauth/google/complete", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
def google_oauth_complete(request, data: GoogleCompleteSchema):
try:
payload = complete_google_signup(
flow=data.flow,
mobile=data.mobile,
username=data.username,
student_id=data.student_id,
year_of_study=data.year_of_study,
major=data.major,
university=data.university,
first_name=data.first_name,
last_name=data.last_name,
)
return 200, payload
except (GoogleOAuthFlowError, AuthServiceError) as exc:
return _error_response(exc)
@auth_router.post("/oauth/google/claim/send-otp", response={200: MessageSchema, 400: ErrorSchema})
def google_oauth_claim_send_otp(request, data: GoogleFlowSchema):
try:
return 200, send_google_claim_otp(data.flow)
except (GoogleOAuthFlowError, AuthServiceError) as exc:
return _error_response(exc)
@auth_router.post("/oauth/google/claim/verify", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
def google_oauth_claim_verify(request, data: GoogleClaimVerifySchema):
try:
return 200, verify_google_claim(data.flow, data.code)
except (GoogleOAuthFlowError, AuthServiceError) as exc:
return _error_response(exc)
@auth_router.get("/verify-email/{token}", response=MessageSchema)
def verify_email_legacy_guidance(request, token: str):
return {
"message": "تایید ایمیل غیرفعال شده است. برای بازیابی حساب از ورود با گوگل یا تایید شماره موبایل استفاده کنید."
}
@auth_router.post("/resend-verification", response=MessageSchema)
def resend_verification_legacy_guidance(request, email: str):
return {
"message": "ارسال ایمیل تایید غیرفعال شده است. برای ادامه، شماره موبایل خود را ثبت یا از ورود با گوگل استفاده کنید."
}
@auth_router.post("/request-password-reset", response=MessageSchema)
def request_password_reset_legacy_guidance(request):
return {
"message": "بازیابی رمز عبور با ایمیل غیرفعال شده است. از بازیابی با کد پیامکی یا ورود با گوگل استفاده کنید."
}
@auth_router.post("/reset-password-confirm", response=MessageSchema)
def reset_password_confirm_legacy_guidance(request):
return {
"message": "لینک بازیابی ایمیلی غیرفعال شده است. لطفاً از بازیابی رمز عبور با موبایل استفاده کنید."
}
@auth_router.get("/profile", response=UserProfileSchema, auth=jwt_auth)
def get_profile(request):
"""Get current user profile"""
return request.auth
@auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
def update_profile(request, data: UserUpdateSchema):
"""Update current user profile"""
user = request.auth
payload = data.dict(exclude_unset=True)
@@ -215,7 +325,7 @@ def update_profile(request, data: UserUpdateSchema):
if code:
major_obj = _get_major_from_code(code)
if not major_obj:
return 400, {"error": "UcO_ O<>OrU?UOU? O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
return 400, {"error": "رشته انتخابی معتبر نیست."}
payload["major"] = major_obj
else:
payload["major"] = None
@@ -225,108 +335,61 @@ def update_profile(request, data: UserUpdateSchema):
if code:
uni_obj = _get_university_from_code(code)
if not uni_obj:
return 400, {"error": "UcO U.U^OO<>O_ O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
return 400, {"error": "دانشگاه انتخابی معتبر نیست."}
payload["university"] = uni_obj
else:
payload["university"] = None
if "email" in payload:
normalized_email = normalize_email_identity(payload["email"])
existing = User.objects.filter(email=normalized_email).exclude(pk=user.pk) if normalized_email else User.objects.none()
if existing.exists():
return 400, {"error": "این ایمیل قبلاً ثبت شده است."}
payload["email"] = normalized_email
payload["is_email_verified"] = False if normalized_email else False
for field, value in payload.items():
setattr(user, field, value)
user.save()
return 200, user
@auth_router.post("/profile/picture", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def upload_profile_picture(request):
"""Upload profile picture"""
if 'file' not in request.FILES:
if "file" not in request.FILES:
return 400, {"error": "فایلی ارسال نشده است."}
file = request.FILES['file']
# Validate file type
if not file.content_type.startswith('image/'):
file = request.FILES["file"]
if not file.content_type.startswith("image/"):
return 400, {"error": "فایل باید از نوع تصویر باشد."}
# Validate file size (5MB max)
if file.size > 5 * 1024 * 1024:
return 400, {"error": "حجم فایل باید کمتر از ۵ مگابایت باشد."}
user = request.auth
# Save new profile picture
filename = f"profile_pictures/{user.id}_{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
user.profile_picture.save(filename, ContentFile(file.read()))
return 200, {"message": "تصویر پروفایل با موفقیت به‌روزرسانی شد."}
@auth_router.delete("/profile/picture", response={200: MessageSchema}, auth=jwt_auth)
def delete_profile_picture(request):
"""Delete current user's profile picture"""
user = request.auth
if user.profile_picture:
delete_image_derivatives(user.profile_picture, "profile_picture", delete_original=True)
user.profile_picture = None
user.save(update_fields=['profile_picture'])
user.save(update_fields=["profile_picture"])
return 200, {"message": "تصویر پروفایل با موفقیت حذف شد."}
@auth_router.post("/request-password-reset", response={200: MessageSchema, 400: ErrorSchema})
def request_password_reset(request, data: PasswordResetRequestSchema):
"""Request a password reset email"""
try:
user = get_object_or_404(User, email=data.email)
user.set_password_reset_token()
reset_url = f"{settings.FRONTEND_PASSWORD_RESET_PAGE}/{user.password_reset_token}"
send_password_reset_email.delay(user.id, reset_url)
# پیام عمومیِ یکسان برای جلوگیری از افشای وجود/عدم وجود ایمیل
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
except User.DoesNotExist:
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
except Exception as e:
return 400, {"error": "درخواست بازنشانی رمز عبور انجام نشد.", "details": str(e)}
@auth_router.post("/reset-password-confirm", response={200: MessageSchema, 400: ErrorSchema})
def reset_password_confirm(request, data: PasswordResetConfirmSchema):
"""Confirm password reset with token and new password"""
try:
user = get_object_or_404(User, password_reset_token=data.token)
if user.password_reset_token_expires_at < timezone.now():
user.password_reset_token = None
user.password_reset_token_expires_at = None
user.save(update_fields=['password_reset_token', 'password_reset_token_expires_at'])
return 400, {"error": "زمان استفاده از لینک تغییر رمز عبور به پایان رسیده است. لطفاً دوباره اقدام کنید."}
user.set_password(data.new_password)
user.password_reset_token = None
user.password_reset_token_expires_at = None
user.save(update_fields=['password', 'password_reset_token', 'password_reset_token_expires_at'])
return 200, {"message": "رمز عبور شما با موفقیت تغییر کرد."}
except User.DoesNotExist:
return 400, {"error": "توکن بازنشانی رمز عبور نامعتبر یا منقضی شده است."}
except Exception as e:
return 400, {"error": "تغییر رمز عبور انجام نشد.", "details": str(e)}
@auth_router.get("/users/deleted", response={200: List[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth)
@auth_router.get("/users/deleted", response={200: list[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth)
def list_deleted_users(request):
"""List soft-deleted users via the dedicated manager (Admin/Committee only)."""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "اجازه دسترسی ندارید."}
return User.deleted_objects.all()
@auth_router.post("/users/{user_id}/restore", response={200: MessageSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
def restore_user(request, user_id: int):
"""Restore a soft-deleted user (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "اجازه دسترسی ندارید."}
@@ -336,10 +399,9 @@ def restore_user(request, user_id: int):
return 200, {"message": f"کاربر {user.username} با موفقیت بازیابی شد."}
except User.DoesNotExist:
return 400, {"error": "کاربر یافت نشد یا حذف نرم نشده است."}
except Exception as e:
return 400, {"error": "بازیابی کاربر انجام نشد.", "details": str(e)}
@auth_router.get("/users", response={200: List[UserListSchema], 403: ErrorSchema}, auth=jwt_auth)
@auth_router.get("/users", response={200: list[UserListSchema], 403: ErrorSchema}, auth=jwt_auth)
def list_users(
request,
search: str | None = Query(None),
@@ -356,43 +418,42 @@ def list_users(
return 403, {"error": "اجازه دسترسی ندارید."}
queryset = User.objects.order_by("-date_joined")
if search:
queryset = queryset.filter(
Q(username__icontains=search)
| Q(email__icontains=search)
| Q(mobile__icontains=search)
| Q(first_name__icontains=search)
| Q(last_name__icontains=search)
)
if role == "staff":
queryset = queryset.filter(is_staff=True)
elif role == "superuser":
queryset = queryset.filter(is_superuser=True)
if student_id:
queryset = queryset.filter(student_id__icontains=student_id)
if university:
queryset = queryset.filter(
Q(university__code__icontains=university) | Q(university__name__icontains=university)
)
if major:
queryset = queryset.filter(
Q(major__code__icontains=major) | Q(major__name__icontains=major)
)
queryset = queryset.filter(Q(major__code__icontains=major) | Q(major__name__icontains=major))
if is_active is not None:
if is_active.lower() in ("true", "1"):
queryset = queryset.filter(is_active=True)
elif is_active.lower() in ("false", "0"):
queryset = queryset.filter(is_active=False)
return queryset[offset : offset + limit]
@auth_router.get("/check-username", response=UsernameCheckSchema)
def check_username_availability(request, username: str):
"""Check if a username is available for registration"""
exists = User.objects.filter(username=username).exists()
return {"exists": exists}
return {"exists": User.objects.filter(username=username).exists()}
@auth_router.get("/check-mobile", response={200: MobileLookupSchema, 400: ErrorSchema})
def check_mobile_availability(request, mobile: str):
try:
return 200, lookup_mobile_registration_state(mobile)
except AuthServiceError as exc:
return _error_response(exc)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
PLACEHOLDER_EMAIL_SUFFIX = "@noemail.local"
def normalize_email_identity(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
return normalized or None
def normalize_mobile_number(value: str | None) -> str | None:
if value is None:
return None
normalized = "".join(ch for ch in value if ch.isdigit())
return normalized or None
def is_valid_mobile_number(value: str | None) -> bool:
normalized = normalize_mobile_number(value)
return bool(normalized and len(normalized) == 11 and normalized.startswith("09"))
def mask_mobile(value: str | None) -> str | None:
if not value:
return None
if len(value) <= 4:
return value
return f"{value[:2]}{'*' * max(len(value) - 6, 1)}{value[-4:]}"
def is_placeholder_email(value: str | None) -> bool:
normalized = normalize_email_identity(value)
return bool(normalized and normalized.endswith(PLACEHOLDER_EMAIL_SUFFIX))

View File

@@ -0,0 +1 @@
"""User management commands."""

View File

@@ -0,0 +1 @@
"""User management commands package."""

View File

@@ -0,0 +1,42 @@
from collections import defaultdict
from django.core.management.base import BaseCommand
from apps.users.email_identity import is_placeholder_email, normalize_email_identity
from apps.users.models import User
class Command(BaseCommand):
help = "Audit legacy email identities for Google auto-link safety."
def handle(self, *args, **options):
duplicate_map: dict[str, list[User]] = defaultdict(list)
placeholder_users: list[User] = []
email_only_users: list[User] = []
for user in User.all_objects.all().order_by("id"):
normalized_email = normalize_email_identity(user.email)
if normalized_email:
duplicate_map[normalized_email].append(user)
if is_placeholder_email(user.email):
placeholder_users.append(user)
if normalized_email and not user.mobile:
email_only_users.append(user)
duplicates = {email: users for email, users in duplicate_map.items() if len(users) > 1}
self.stdout.write(self.style.WARNING(f"Case-insensitive duplicate emails: {len(duplicates)}"))
for email, users in duplicates.items():
user_ids = ", ".join(str(user.id) for user in users)
self.stdout.write(f" {email}: user_ids=[{user_ids}]")
self.stdout.write(self.style.WARNING(f"Placeholder emails: {len(placeholder_users)}"))
for user in placeholder_users[:50]:
self.stdout.write(f" user_id={user.id} email={user.email}")
self.stdout.write(self.style.WARNING(f"Email-only users needing mobile bind: {len(email_only_users)}"))
for user in email_only_users[:100]:
self.stdout.write(f" user_id={user.id} email={user.email}")
if not duplicates and not placeholder_users:
self.stdout.write(self.style.SUCCESS("No blocking legacy email issues were found."))

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.5 on 2026-05-20 18:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_remove_legacy_fields'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_mobile_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='user',
name='mobile',
field=models.CharField(blank=True, default=None, max_length=11, null=True, unique=True),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(blank=True, default=None, max_length=254, null=True, unique=True),
),
migrations.CreateModel(
name='UserSocialAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('provider', models.CharField(choices=[('google', 'google')], max_length=32)),
('provider_user_id', models.CharField(max_length=255)),
('email', models.EmailField(blank=True, default=None, max_length=254, null=True)),
('email_verified', models.BooleanField(default=False)),
('avatar_url', models.URLField(blank=True, default='')),
('is_active', models.BooleanField(default=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Social Account',
'verbose_name_plural': 'User Social Accounts',
'db_table': 'user_social_accounts',
'ordering': ['-updated_at', '-created_at'],
'indexes': [models.Index(fields=['provider', 'provider_user_id'], name='user_social_provider_uid_idx'), models.Index(fields=['provider', 'email'], name='user_social_provider_email_idx')],
'constraints': [models.UniqueConstraint(fields=('provider', 'provider_user_id'), name='user_social_account_provider_uid_uniq')],
},
),
]

View File

@@ -11,6 +11,7 @@ from core.media import (
safe_process_public_image,
)
from core.models import BaseModel
from apps.users.email_identity import normalize_email_identity, normalize_mobile_number
class University(BaseModel):
@@ -38,7 +39,8 @@ class Major(BaseModel):
class User(AbstractUser, BaseModel):
email = models.EmailField(unique=True)
email = models.EmailField(unique=True, null=True, blank=True, default=None)
mobile = models.CharField(max_length=11, unique=True, null=True, blank=True, default=None)
bio = models.TextField(null=True, blank=True)
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
@@ -59,14 +61,15 @@ class User(AbstractUser, BaseModel):
related_name='users',
)
is_email_verified = models.BooleanField(default=False)
is_mobile_verified = models.BooleanField(default=False)
email_verification_token = models.UUIDField(default=uuid.uuid4, unique=True)
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
password_reset_token = models.UUIDField(null=True, blank=True, unique=True)
password_reset_token_expires_at = models.DateTimeField(null=True, blank=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = []
class Meta:
db_table = 'users'
@@ -74,7 +77,8 @@ class User(AbstractUser, BaseModel):
verbose_name_plural = 'Users'
def __str__(self):
return f"{self.get_full_name()} ({self.email})"
identity = self.mobile or self.email or self.username
return f"{self.get_full_name() or self.username} ({identity})"
def get_full_name(self):
return f"{self.first_name} {self.last_name}".strip()
@@ -89,6 +93,17 @@ class User(AbstractUser, BaseModel):
return self.university.name
return None
@property
def requires_mobile_verification(self):
return not self.is_mobile_verified
@property
def has_google_link(self):
return self.social_accounts.filter(
provider=UserSocialAccount.ProviderType.GOOGLE,
is_active=True,
).exists()
def regenerate_verification_token(self):
self.email_verification_token = uuid.uuid4()
self.save(update_fields=['email_verification_token'])
@@ -102,12 +117,8 @@ class User(AbstractUser, BaseModel):
def save(self, *args, **kwargs):
previous_image_name = get_image_previous_name(self, "profile_picture")
current_image_name = self.profile_picture.name if self.profile_picture else None
send_verified_success = False
if self.pk is not None:
prev = type(self).objects.filter(pk=self.pk).values_list('is_email_verified', flat=True).first()
if prev is not None and prev is False and self.is_email_verified is True:
send_verified_success = True
self.email = normalize_email_identity(self.email)
self.mobile = normalize_mobile_number(self.mobile)
super().save(*args, **kwargs)
@@ -122,9 +133,42 @@ class User(AbstractUser, BaseModel):
if previous_image_name != current_image_name and self.profile_picture:
safe_process_public_image(self.profile_picture, "profile_picture")
if send_verified_success:
try:
from apps.users.tasks import send_email_verified_success
send_email_verified_success.delay(self.id)
except Exception:
pass
class UserSocialAccount(BaseModel):
class ProviderType(models.TextChoices):
GOOGLE = "google", "google"
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="social_accounts",
)
provider = models.CharField(max_length=32, choices=ProviderType.choices)
provider_user_id = models.CharField(max_length=255)
email = models.EmailField(blank=True, null=True, default=None)
email_verified = models.BooleanField(default=False)
avatar_url = models.URLField(blank=True, default="")
is_active = models.BooleanField(default=True)
class Meta:
verbose_name = "User Social Account"
verbose_name_plural = "User Social Accounts"
db_table = "user_social_accounts"
ordering = ["-updated_at", "-created_at"]
constraints = [
models.UniqueConstraint(
fields=("provider", "provider_user_id"),
name="user_social_account_provider_uid_uniq",
)
]
indexes = [
models.Index(fields=["provider", "provider_user_id"], name="user_social_provider_uid_idx"),
models.Index(fields=["provider", "email"], name="user_social_provider_email_idx"),
]
def __str__(self):
return f"{self.provider}:{self.provider_user_id}"
def save(self, *args, **kwargs):
self.email = normalize_email_identity(self.email)
super().save(*args, **kwargs)

View File

@@ -0,0 +1 @@
"""User authentication and OAuth services."""

309
apps/users/services/auth.py Normal file
View 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()),
}

View File

@@ -0,0 +1,559 @@
from __future__ import annotations
import secrets
from dataclasses import asdict, dataclass
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlparse
import requests
from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile
from apps.users.email_identity import (
is_placeholder_email,
is_valid_mobile_number,
mask_mobile,
normalize_email_identity,
normalize_mobile_number,
)
from apps.users.models import Major, University, User, UserSocialAccount
from apps.users.services.auth import (
AuthServiceError,
RegistrationPayload,
generate_and_send_otp,
get_tokens_for_user,
verify_otp_code,
)
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
GOOGLE_STATE_TTL_SECONDS = 300
GOOGLE_FLOW_TTL_SECONDS = 900
GOOGLE_STATE_CACHE_PREFIX = "google_oauth_state"
GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow"
class GoogleOAuthFlowError(AuthServiceError):
def __init__(
self,
message: str,
*,
code: str = "google_flow_error",
status_code: int = 409,
extra: dict[str, Any] | None = None,
):
super().__init__(message, field="detail", status_code=status_code)
self.code = code
self.extra = extra or {}
@dataclass
class GoogleProfile:
provider_user_id: str
email: str
email_verified: bool
first_name: str
last_name: str
avatar_url: str
def _cache_key(prefix: str, token: str) -> str:
return f"{prefix}:{token}"
def _create_token() -> str:
return secrets.token_urlsafe(32)
def _required_setting(name: str) -> str:
value = getattr(settings, name, "")
if not value:
raise GoogleOAuthFlowError(f"{name} is not configured.", status_code=500)
return value
def _public_flow_payload(flow_payload: dict[str, Any]) -> dict[str, Any]:
status = flow_payload.get("status")
base = {"status": status}
for key in (
"email",
"first_name",
"last_name",
"avatar_url",
"resolution",
"mobile",
"mobile_hint",
"detail",
"access_token",
"refresh_token",
):
if key in flow_payload:
base[key] = flow_payload[key]
return base
def _profile_payload(profile: GoogleProfile) -> dict[str, Any]:
return asdict(profile)
def _profile_from_payload(payload: dict[str, Any]) -> GoogleProfile:
raw = payload.get("google_profile")
if not isinstance(raw, dict):
raise GoogleOAuthFlowError(
"Google flow profile data is missing.",
code="google_flow_invalid_state",
status_code=400,
)
return GoogleProfile(**raw)
def create_google_state() -> str:
state = _create_token()
cache.set(_cache_key(GOOGLE_STATE_CACHE_PREFIX, state), {"valid": True}, GOOGLE_STATE_TTL_SECONDS)
return state
def consume_google_state(state: str) -> None:
key = _cache_key(GOOGLE_STATE_CACHE_PREFIX, state or "")
payload = cache.get(key)
cache.delete(key)
if not payload:
raise GoogleOAuthFlowError(
"Google sign-in state is invalid or expired.",
code="google_flow_invalid_state",
status_code=400,
)
def create_google_flow(payload: dict[str, Any]) -> str:
flow = _create_token()
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
return flow
def get_google_flow_payload(flow: str) -> dict[str, Any]:
payload = cache.get(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow))
if not payload:
raise GoogleOAuthFlowError(
"Google sign-in flow is invalid or expired.",
code="google_flow_invalid_state",
status_code=400,
)
return payload
def get_google_flow(flow: str) -> dict[str, Any]:
return _public_flow_payload(get_google_flow_payload(flow))
def update_google_flow(flow: str, payload: dict[str, Any]) -> None:
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
def build_google_authorization_url() -> str:
state = create_google_state()
params = {
"client_id": _required_setting("GOOGLE_OAUTH_CLIENT_ID"),
"redirect_uri": _required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "online",
"prompt": "select_account",
}
return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
def exchange_code_for_google_profile(code: str) -> GoogleProfile:
if not code:
raise GoogleOAuthFlowError("Missing Google authorization code.", status_code=400)
try:
token_response = requests.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": _required_setting("GOOGLE_OAUTH_CLIENT_ID"),
"client_secret": _required_setting("GOOGLE_OAUTH_CLIENT_SECRET"),
"redirect_uri": _required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
"grant_type": "authorization_code",
},
timeout=10,
)
token_response.raise_for_status()
token_payload = token_response.json()
except requests.RequestException as exc:
raise GoogleOAuthFlowError("Google token exchange failed.", status_code=400) from exc
access_token = token_payload.get("access_token")
if not access_token:
raise GoogleOAuthFlowError("Google did not return an access token.", status_code=400)
try:
userinfo_response = requests.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
userinfo_response.raise_for_status()
userinfo = userinfo_response.json()
except requests.RequestException as exc:
raise GoogleOAuthFlowError("Google user profile lookup failed.", status_code=400) from exc
email = normalize_email_identity(userinfo.get("email"))
if not userinfo.get("sub") or not email or not userinfo.get("email_verified"):
raise GoogleOAuthFlowError(
"Google account must have a verified email address.",
status_code=400,
)
return GoogleProfile(
provider_user_id=userinfo.get("sub"),
email=email,
email_verified=bool(userinfo.get("email_verified")),
first_name=userinfo.get("given_name", "") or "",
last_name=userinfo.get("family_name", "") or "",
avatar_url=userinfo.get("picture", "") or "",
)
def build_google_callback_redirect_url(flow: str) -> str:
callback = _required_setting("GOOGLE_OAUTH_FRONTEND_CALLBACK_URL")
sep = "&" if "?" in callback else "?"
return f"{callback}{sep}flow={flow}"
def find_social_account_for_profile(profile: GoogleProfile) -> UserSocialAccount | None:
return (
UserSocialAccount.objects.select_related("user")
.filter(provider=UserSocialAccount.ProviderType.GOOGLE, provider_user_id=profile.provider_user_id)
.first()
)
def _avatar_file_extension(profile: GoogleProfile) -> str:
path = urlparse(profile.avatar_url or "").path
if "." in path:
suffix = path.rsplit(".", 1)[-1].lower()
if suffix in {"jpg", "jpeg", "png", "webp", "gif"}:
return suffix
return "jpg"
def sync_user_from_google_profile(user: User, profile: GoogleProfile) -> None:
update_fields: list[str] = []
if not user.first_name and profile.first_name:
user.first_name = profile.first_name
update_fields.append("first_name")
if not user.last_name and profile.last_name:
user.last_name = profile.last_name
update_fields.append("last_name")
if not user.email and profile.email:
user.email = profile.email
user.is_email_verified = True
update_fields.extend(["email", "is_email_verified"])
if not user.profile_picture and profile.avatar_url:
try:
avatar_response = requests.get(profile.avatar_url, timeout=10)
avatar_response.raise_for_status()
except requests.RequestException:
avatar_response = None
if avatar_response and avatar_response.content:
filename = f"google-{profile.provider_user_id}.{_avatar_file_extension(profile)}"
user.profile_picture.save(filename, ContentFile(avatar_response.content), save=False)
update_fields.append("profile_picture")
if update_fields:
user.save(update_fields=update_fields)
def build_authenticated_flow_payload(user: User) -> dict[str, Any]:
tokens = get_tokens_for_user(user)
return {
"status": "authenticated",
"access_token": tokens["access_token"],
"refresh_token": tokens["refresh_token"],
}
def _is_google_claim_candidate(user: User | None) -> bool:
if user is None:
return False
if is_placeholder_email(user.email):
return False
normalized_email = normalize_email_identity(user.email)
return bool(normalized_email)
def build_pending_google_flow_payload(profile: GoogleProfile) -> dict[str, Any]:
existing_email_user = None
if not is_placeholder_email(profile.email):
existing_email_user = User.objects.filter(email=profile.email).first()
resolution = "existing_email_claim" if _is_google_claim_candidate(existing_email_user) else "new_account"
mobile_hint = mask_mobile(existing_email_user.mobile) if existing_email_user else None
has_verified_mobile = bool(existing_email_user and existing_email_user.mobile and existing_email_user.is_mobile_verified)
return {
"status": "collect_profile",
"google_profile": _profile_payload(profile),
"email": profile.email,
"first_name": profile.first_name,
"last_name": profile.last_name,
"avatar_url": profile.avatar_url,
"resolution": resolution,
"target_user_id": existing_email_user.id if existing_email_user else None,
"mobile_hint": mobile_hint,
"has_verified_mobile": has_verified_mobile,
}
def _resolve_major(code: str | None) -> Major | None:
if not code:
return None
return Major.objects.filter(code=code, is_deleted=False).first()
def _resolve_university(code: str | None) -> University | None:
if not code:
return None
return University.objects.filter(code=code, is_deleted=False).first()
def complete_google_signup(
*,
flow: str,
mobile: str,
username: str | None = None,
student_id: str | None = None,
year_of_study: int | None = None,
major: str | None = None,
university: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
) -> dict[str, Any]:
flow_payload = get_google_flow_payload(flow)
if flow_payload.get("status") != "collect_profile":
raise GoogleOAuthFlowError(
"Google sign-in flow is in an unexpected state.",
code="google_flow_invalid_state",
status_code=400,
)
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise GoogleOAuthFlowError("شماره موبایل معتبر نیست.", status_code=400)
profile = _profile_from_payload(flow_payload)
resolution = flow_payload.get("resolution", "new_account")
target_user_id = flow_payload.get("target_user_id")
target_user = User.objects.filter(pk=target_user_id).first() if target_user_id else None
if resolution == "existing_email_claim":
if target_user is None:
raise GoogleOAuthFlowError(
"Target account could not be found.",
code="google_flow_invalid_state",
status_code=400,
)
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=target_user.pk).first()
if conflict and normalize_email_identity(conflict.email) not in (None, profile.email):
raise GoogleOAuthFlowError(
"این شماره موبایل قبلاً به حساب دیگری متصل شده است.",
code="google_mobile_belongs_to_other_email",
status_code=409,
)
generate_and_send_otp(normalized_mobile, "google_claim")
claim_payload = {
"status": "claim_required",
"google_profile": _profile_payload(profile),
"resolution": resolution,
"target_user_id": target_user.id,
"mobile": normalized_mobile,
"email": profile.email,
"mobile_hint": mask_mobile(normalized_mobile),
"detail": "مالکیت شماره موبایل را تایید کنید تا ورود با گوگل تکمیل شود.",
}
update_google_flow(flow, claim_payload)
return _public_flow_payload(claim_payload)
if not username:
raise GoogleOAuthFlowError("نام کاربری الزامی است.", code="google_missing_username", status_code=400)
if User.objects.filter(username=username).exists():
raise GoogleOAuthFlowError("نام کاربری قبلاً استفاده شده است.", code="google_username_taken", status_code=400)
if student_id and len(str(student_id)) < 10:
raise GoogleOAuthFlowError("شماره دانشجویی باید حداقل ۱۰ رقم باشد.", code="google_invalid_student_id", status_code=400)
major_obj = _resolve_major(major)
if major and not major_obj:
raise GoogleOAuthFlowError("رشته انتخابی معتبر نیست.", code="google_invalid_major", status_code=400)
university_obj = _resolve_university(university)
if university and not university_obj:
raise GoogleOAuthFlowError("دانشگاه انتخابی معتبر نیست.", code="google_invalid_university", status_code=400)
if student_id and university_obj and User.objects.filter(university=university_obj, student_id=student_id).exists():
raise GoogleOAuthFlowError(
"این شماره دانشجویی در دانشگاه انتخابی قبلاً ثبت شده است.",
code="google_student_id_taken",
status_code=400,
)
conflict = User.objects.filter(mobile=normalized_mobile).first()
if conflict:
existing_email = normalize_email_identity(conflict.email)
if existing_email not in (None, profile.email):
raise GoogleOAuthFlowError(
"این شماره موبایل قبلاً به حساب دیگری متصل شده است.",
code="google_mobile_belongs_to_other_email",
status_code=409,
)
generate_and_send_otp(normalized_mobile, "google_claim")
claim_payload = {
"status": "claim_required",
"google_profile": _profile_payload(profile),
"resolution": "new_account",
"mobile": normalized_mobile,
"email": profile.email,
"username": username,
"student_id": student_id,
"year_of_study": year_of_study,
"major": major,
"university": university,
"first_name": first_name or profile.first_name,
"last_name": last_name or profile.last_name,
"detail": "کد تایید موبایل را وارد کنید تا حساب جدید شما ساخته شود.",
}
update_google_flow(flow, claim_payload)
return _public_flow_payload(claim_payload)
def send_google_claim_otp(flow: str) -> dict[str, str]:
flow_payload = get_google_flow_payload(flow)
if flow_payload.get("status") != "claim_required":
raise GoogleOAuthFlowError(
"Google sign-in flow is in an unexpected state.",
code="google_flow_invalid_state",
status_code=400,
)
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str) or not mobile:
raise GoogleOAuthFlowError("Claim mobile number is missing.", status_code=400)
generate_and_send_otp(mobile, "google_claim")
return {"message": "کد تایید مجدداً ارسال شد."}
def _link_google_account(*, user: User, profile: GoogleProfile) -> None:
social = find_social_account_for_profile(profile)
if social and social.user_id != user.id:
raise GoogleOAuthFlowError(
"این حساب گوگل قبلاً به کاربر دیگری متصل شده است.",
code="google_already_linked",
status_code=409,
)
sync_user_from_google_profile(user, profile)
if social:
social.email = profile.email
social.email_verified = profile.email_verified
social.avatar_url = profile.avatar_url
social.is_active = True
social.save(update_fields=["email", "email_verified", "avatar_url", "is_active"])
return
UserSocialAccount.objects.create(
user=user,
provider=UserSocialAccount.ProviderType.GOOGLE,
provider_user_id=profile.provider_user_id,
email=profile.email,
email_verified=profile.email_verified,
avatar_url=profile.avatar_url,
is_active=True,
)
def verify_google_claim(flow: str, code: str) -> dict[str, Any]:
flow_payload = get_google_flow_payload(flow)
if flow_payload.get("status") != "claim_required":
raise GoogleOAuthFlowError(
"Google sign-in flow is in an unexpected state.",
code="google_flow_invalid_state",
status_code=400,
)
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str) or not mobile:
raise GoogleOAuthFlowError("Claim mobile number is missing.", status_code=400)
verify_otp_code(mobile=mobile, code=code, mode="google_claim")
profile = _profile_from_payload(flow_payload)
resolution = flow_payload.get("resolution", "new_account")
if resolution == "existing_email_claim":
target_user_id = flow_payload.get("target_user_id")
user = User.objects.filter(pk=target_user_id).first()
if user is None or normalize_email_identity(user.email) != profile.email:
raise GoogleOAuthFlowError(
"The matching account could not be verified.",
code="google_email_claim_failed",
status_code=409,
)
user.mobile = mobile
user.is_mobile_verified = True
user.is_email_verified = True
user.save(update_fields=["mobile", "is_mobile_verified", "is_email_verified"])
_link_google_account(user=user, profile=profile)
authenticated = build_authenticated_flow_payload(user)
update_google_flow(flow, authenticated)
return _public_flow_payload(authenticated)
normalized_email = normalize_email_identity(profile.email)
if normalized_email and User.objects.filter(email=normalized_email).exists():
raise GoogleOAuthFlowError(
"این ایمیل قبلاً به حساب دیگری متصل شده است.",
code="google_email_taken",
status_code=409,
)
payload = RegistrationPayload(
username=flow_payload.get("username", ""),
mobile=mobile,
password=_create_token(),
code=code,
email=normalized_email,
first_name=flow_payload.get("first_name") or profile.first_name,
last_name=flow_payload.get("last_name") or profile.last_name,
university=flow_payload.get("university"),
student_id=flow_payload.get("student_id"),
year_of_study=flow_payload.get("year_of_study"),
major=flow_payload.get("major"),
)
if not payload.username:
raise GoogleOAuthFlowError("نام کاربری برای ساخت حساب جدید الزامی است.", status_code=400)
major_obj = _resolve_major(payload.major)
university_obj = _resolve_university(payload.university)
user = User.objects.create_user(
username=payload.username,
email=payload.email,
mobile=payload.mobile,
password=None,
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_obj,
university=university_obj,
is_mobile_verified=True,
is_email_verified=bool(payload.email),
)
user.set_unusable_password()
user.save(update_fields=["password"])
_link_google_account(user=user, profile=profile)
authenticated = build_authenticated_flow_payload(user)
update_google_flow(flow, authenticated)
return _public_flow_payload(authenticated)

View File

@@ -2,26 +2,12 @@ import uuid
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from django.conf import settings
from apps.users.models import User
from apps.users.tasks import send_verification_email
@receiver(post_save, sender=User)
def send_verification_email_on_registration(sender, instance, created, **kwargs):
if created:
if not instance.username:
instance.username = str(uuid.uuid4())[:10]
instance.save(update_fields=['username'])
if not instance.is_email_verified and instance.email:
# Update the email verification sent timestamp
instance.email_verification_sent_at = timezone.now()
instance.save(update_fields=['email_verification_sent_at'])
# Generate verification URL (you'll need to adjust this based on your frontend)
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{instance.email_verification_token}"
# Send verification email asynchronously
send_verification_email.delay(instance.id, verification_url)
def ensure_username_on_registration(sender, instance, created, **kwargs):
if created and not instance.username:
instance.username = str(uuid.uuid4())[:10]
instance.save(update_fields=["username"])

View File

@@ -1,99 +1,90 @@
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from django.utils.html import strip_tags
from celery import shared_task
import logging
from apps.users.models import User
import requests
from celery import shared_task
from django.conf import settings
logger = logging.getLogger(__name__)
SMS_ENDPOINT = "https://api.sms.ir/v1/send/verify"
SMS_TEMPLATE_MAP = {
"auth_register_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"auth_login_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"auth_reset_password_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"auth_verify_mobile_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"event_cancellation": "SMS_EVENT_CANCELLATION_TEMPLATE_ID",
"event_reschedule": "SMS_EVENT_RESCHEDULE_TEMPLATE_ID",
"payment_status": "SMS_PAYMENT_STATUS_TEMPLATE_ID",
}
def _template_id_for_kind(kind: str) -> str:
setting_name = SMS_TEMPLATE_MAP.get(kind, "")
return getattr(settings, setting_name, "") if setting_name else ""
def _send_sms(receptor: str, template_id: str | int, variables: list[dict] | None = None):
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"x-api-key": settings.SMS_APIKEY,
}
payload = {
"mobile": receptor,
"templateId": int(template_id),
"parameters": variables or [],
}
response = requests.post(
SMS_ENDPOINT,
json=payload,
headers=headers,
timeout=10,
)
response.raise_for_status()
return response
@shared_task(bind=True, max_retries=3)
def send_critical_sms(self, mobile: str, kind: str, code_or_message: str):
try:
template_id = _template_id_for_kind(kind)
if not template_id or not settings.SMS_APIKEY:
logger.info(
"SMS skipped for mobile=%s kind=%s template=%s configured=%s payload=%s",
mobile,
kind,
bool(template_id),
bool(settings.SMS_APIKEY),
code_or_message,
)
return {"mobile": mobile, "kind": kind, "sent": False}
variables = [{"name": "OTP", "value": str(code_or_message)}]
if kind in {"event_cancellation", "event_reschedule", "payment_status"}:
variables = [{"name": "MESSAGE", "value": str(code_or_message)}]
_send_sms(mobile, template_id, variables=variables)
logger.info("SMS sent to %s for kind=%s", mobile, kind)
return {"mobile": mobile, "kind": kind, "sent": True}
except Exception as exc:
logger.error("Failed to send SMS to %s for kind=%s: %s", mobile, kind, exc)
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=1)
def send_verification_email(self, user_id, verification_url):
try:
user = User.objects.get(id=user_id)
subject = 'تایید ایمیل | انجمن علمی مهندسی کامپیوتر'
html_message = render_to_string('emails/verification_email.html', {
'user': user,
'verification_url': verification_url,
})
plain_message = strip_tags(html_message)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"Verification email sent to {user.email}")
return f"Verification email sent to {user.email}"
except Exception as exc:
logger.error(f"Failed to send verification email: {exc}")
raise self.retry(exc=exc, countdown=60)
logger.info("Legacy verification email task skipped for user=%s url=%s", user_id, verification_url)
return {"skipped": True}
@shared_task(bind=True, max_retries=3)
@shared_task(bind=True, max_retries=1)
def send_password_reset_email(self, user_id, reset_url):
try:
user = User.objects.get(id=user_id)
subject = 'بازیابی رمز عبور | انجمن علمی مهندسی کامپیوتر'
html_message = render_to_string('emails/password_reset_email.html', {
'user': user,
'reset_url': reset_url,
})
plain_message = strip_tags(html_message)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"Password reset email sent to {user.email}")
return f"Password reset email sent to {user.email}"
except Exception as exc:
logger.error(f"Failed to send password reset email: {exc}")
raise self.retry(exc=exc, countdown=60)
logger.info("Legacy password reset email task skipped for user=%s url=%s", user_id, reset_url)
return {"skipped": True}
@shared_task(bind=True, max_retries=3)
@shared_task(bind=True, max_retries=1)
def send_email_verified_success(self, user_id: int):
"""
ارسال ایمیل «ایمیل شما با موفقیت تأیید شد» پس از تغییر وضعیت تأیید.
"""
try:
user = User.objects.get(pk=user_id)
subject = "تأیید ایمیل شما با موفقیت انجام شد"
context = {
"user": user,
"home_url": getattr(settings, "FRONTEND_ROOT", "/"),
}
html_message = render_to_string("emails/verification_success.html", context)
plain_message = strip_tags(html_message)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"verified success email sent to {user.email}")
return f"verified success email sent to {user.email}"
except Exception as exc:
logger.error(f"Failed to send verified success email: {exc}")
raise self.retry(exc=exc, countdown=60)
logger.info("Legacy verification success email task skipped for user=%s", user_id)
return {"skipped": True}