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,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)