Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/auth_service.py
2026-05-05 17:29:47 +03:30

872 lines
No EOL
31 KiB
Python
Executable file

from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
import phonenumbers
from sqlalchemy.orm import Session
from adapters.db.repositories.user_repo import UserRepository
from adapters.db.repositories.api_key_repo import ApiKeyRepository
from app.core.security import hash_password, verify_password, generate_api_key, consteq
from app.core.settings import get_settings
from app.services.captcha_service import validate_captcha
from adapters.db.repositories.password_reset_repo import PasswordResetRepository
import hashlib
def _normalize_email(email: str | None) -> str | None:
return email.lower().strip() if email else None
def _normalize_mobile(mobile: str | None) -> str | None:
if not mobile:
return None
# Clean input: keep digits and leading plus
raw = mobile.strip()
raw = ''.join(ch for ch in raw if ch.isdigit() or ch == '+')
try:
from app.core.settings import get_settings
settings = get_settings()
region = None if raw.startswith('+') else settings.default_phone_region
num = phonenumbers.parse(raw, region)
if not phonenumbers.is_valid_number(num):
return None
return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164)
except Exception:
return None
def _detect_identifier(identifier: str) -> tuple[str, str | None, str | None]:
identifier = identifier.strip()
if "@" in identifier:
return "email", _normalize_email(identifier), None
mobile = _normalize_mobile(identifier)
return ("mobile", None, mobile) if mobile else ("invalid", None, None)
def _generate_referral_code(db: Session) -> str:
from secrets import token_urlsafe
repo = UserRepository(db)
# try a few times to ensure uniqueness
for _ in range(10):
code = token_urlsafe(8).replace('-', '').replace('_', '')[:10]
if not repo.get_by_referral_code(code):
return code
# fallback longer code
return token_urlsafe(12).replace('-', '').replace('_', '')[:12]
def register_user(*, db: Session, first_name: str | None, last_name: str | None, email: str | None, mobile: str | None, password: str, captcha_id: str, captcha_code: str, referrer_code: str | None = None, base_url: str | None = None, client_ip: str | None = None) -> int:
from app.core.responses import ApiError
from app.services.system_settings_service import is_registration_enabled, get_max_users
repo = UserRepository(db)
# در استقرار اولیه (بدون هیچ کاربر)، ثبت‌نام اولیه همیشه مجاز است
is_first_user_bootstrap = repo.count_all() == 0
if not is_first_user_bootstrap and not is_registration_enabled(db):
raise ApiError("REGISTRATION_DISABLED", "ثبت‌نام در حال حاضر غیرفعال است", http_status=403)
# بررسی محدودیت تعداد کاربران
max_users = get_max_users(db)
if max_users > 0: # 0 به معنی نامحدود است
current_user_count = repo.count_all()
if current_user_count >= max_users:
raise ApiError("MAX_USERS_REACHED", f"حداکثر تعداد کاربران ({max_users}) رسیده است", http_status=403)
if not validate_captcha(db, captcha_id, captcha_code, client_ip=client_ip):
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
email_n = _normalize_email(email)
mobile_n = _normalize_mobile(mobile)
if not email_n and not mobile_n:
# اگر کاربر موبایل وارد کرده اما نامعتبر بوده، پیام دقیق‌تر بدهیم
if mobile and mobile.strip():
raise ApiError("INVALID_MOBILE", "Invalid mobile number")
# در غیر این صورت، هیچ شناسهٔ معتبری ارائه نشده است
raise ApiError("IDENTIFIER_REQUIRED", "Email or mobile is required")
if email_n and repo.get_by_email(email_n):
raise ApiError("EMAIL_IN_USE", "Email is already in use")
if mobile_n and repo.get_by_mobile(mobile_n):
raise ApiError("MOBILE_IN_USE", "Mobile is already in use")
pwd_hash = hash_password(password)
referred_by_user_id = None
if referrer_code:
ref_user = repo.get_by_referral_code(referrer_code)
if ref_user:
# prevent self-referral at signup theoretically not applicable; rule kept for safety
referred_by_user_id = ref_user.id
referral_code = _generate_referral_code(db)
# تنظیم email_verified بر اساس enable_email_verification
from app.services.system_settings_service import is_email_verification_enabled
email_verified = not is_email_verification_enabled(db) # اگر verification فعال باشد، false است
user = repo.create(
email=email_n,
mobile=mobile_n,
password_hash=pwd_hash,
first_name=first_name,
last_name=last_name,
referral_code=referral_code,
referred_by_user_id=referred_by_user_id,
email_verified=email_verified
)
# اگر email verification فعال باشد و کاربر ایمیل دارد، token ایجاد و ایمیل ارسال کن
if is_email_verification_enabled(db) and email_n:
try:
from app.services.email_verification_service import create_email_verification_token
create_email_verification_token(db, user.id, email_n, base_url=base_url)
except Exception as e:
# در صورت خطا در ارسال ایمیل، ثبت‌نام ادامه می‌یابد اما log می‌کنیم
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send verification email for user {user.id}: {e}")
return user.id
def login_user(*, db: Session, identifier: str, password: str, captcha_id: str, captcha_code: str, device_id: str | None, user_agent: str | None, ip: str | None) -> tuple[str, datetime | None, dict]:
from app.services.system_settings_service import get_captcha_auth_security_effective
from app.services.auth_security_event_service import (
check_login_backoff,
maybe_firewall_ban_after_login_password_failed,
record_login_password_failed,
)
_cfg = get_captcha_auth_security_effective(db)
if int(_cfg.get("login_backoff_max_fails") or 0) > 0:
check_login_backoff(
db,
client_ip=ip,
identifier=identifier,
max_fails=int(_cfg["login_backoff_max_fails"]),
window_minutes=int(_cfg["login_backoff_window_minutes"]),
backoff_seconds=int(_cfg["login_backoff_seconds"]),
)
if not validate_captcha(db, captcha_id, captcha_code, client_ip=ip):
from app.core.responses import ApiError
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
kind, email, mobile = _detect_identifier(identifier)
if kind == "invalid":
from app.core.responses import ApiError
raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
repo = UserRepository(db)
user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
if not user or not verify_password(password, user.password_hash):
# لاگ‌گیری ورود ناموفق
try:
from app.services.activity_log_service import log_activity
log_activity(
db=db,
user_id=user.id if user else None,
category="user",
action="login_failed",
description=f"تلاش ناموفق برای ورود با {'ایمیل' if email else 'موبایل'}: {email or mobile}",
extra_info={
"ip_address": ip,
"user_agent": user_agent,
"device_id": device_id,
"identifier": email or mobile,
"identifier_type": "email" if email else "mobile"
}
)
db.commit()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to log failed login activity: {e}")
try:
record_login_password_failed(client_ip=ip, identifier=identifier)
except Exception:
pass
try:
maybe_firewall_ban_after_login_password_failed(client_ip=ip, identifier=identifier, cfg=_cfg)
except Exception:
pass
from app.core.responses import ApiError
raise ApiError("INVALID_CREDENTIALS", "Invalid credentials")
if not user.is_active:
from app.core.responses import ApiError
raise ApiError("ACCOUNT_DISABLED", "Your account is disabled")
# تبدیل خودکار رمز bcrypt به Argon2 در صورت نیاز
# این کار باعث می‌شود که به تدریج تمام رمزها به Argon2 تبدیل شوند
if user.password_hash.startswith('$2y$') or user.password_hash.startswith('$2a$') or user.password_hash.startswith('$2b$'):
try:
user.password_hash = hash_password(password)
db.add(user)
db.commit()
import logging
logger = logging.getLogger(__name__)
logger.info(f"Converted bcrypt password to Argon2 for user {user.id}")
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to convert bcrypt password to Argon2 for user {user.id}: {e}")
# در صورت خطا، ادامه می‌دهیم و کاربر می‌تواند وارد شود
settings = get_settings()
api_key, key_hash = generate_api_key()
# Session keys برای کاربران فرانت‌اند نامحدود هستند (expires_at=None)
# فقط personal API keys می‌توانند expires_at داشته باشند
api_repo = ApiKeyRepository(db)
api_repo.create_session_key(user_id=user.id, key_hash=key_hash, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=None)
user_data = {
"id": user.id,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"mobile": user.mobile,
"referral_code": getattr(user, "referral_code", None),
"email_verified": getattr(user, "email_verified", False),
}
# لاگ‌گیری ورود موفق
try:
from app.services.activity_log_service import log_user_activity
log_user_activity(
db=db,
user_id=user.id,
action="login",
description=f"ورود موفق به سیستم",
extra_info={
"ip_address": ip,
"user_agent": user_agent,
"device_id": device_id,
"identifier_type": "email" if email else "mobile"
}
)
db.commit()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to log login activity: {e}")
# Session keys نامحدود هستند، پس expires_at=None برمی‌گردانیم
return api_key, None, user_data
def _hash_reset_token(token: str) -> str:
settings = get_settings()
return hashlib.sha256(f"{settings.captcha_secret}:{token}".encode("utf-8")).hexdigest()
def create_password_reset(*, db: Session, identifier: str, captcha_id: str, captcha_code: str, client_ip: str | None = None) -> str:
if not validate_captcha(db, captcha_id, captcha_code, client_ip=client_ip):
from app.core.responses import ApiError
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
kind, email, mobile = _detect_identifier(identifier)
if kind == "invalid":
from app.core.responses import ApiError
raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
repo = UserRepository(db)
user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
# Always respond OK to avoid user enumeration; but skip creation if user not found
if not user:
return ""
from app.core.transaction_lock import acquire_sms_rate_lock
pr_repo = PasswordResetRepository(db)
acquire_sms_rate_lock(db, f"pwd_reset_link_user:{user.id}")
if pr_repo.count_recent_by_user(user.id, hours=24) >= 3:
from app.core.responses import ApiError
raise ApiError(
"RATE_LIMIT_EXCEEDED",
"حداکثر ۳ درخواست بازیابی رمز عبور در ۲۴ ساعت مجاز است. لطفاً بعداً تلاش کنید.",
http_status=429,
)
settings = get_settings()
from secrets import token_urlsafe
token = token_urlsafe(32)
token_hash = _hash_reset_token(token)
expires_at = datetime.utcnow() + timedelta(seconds=settings.reset_password_ttl_seconds)
pr_repo.create(user_id=user.id, token_hash=token_hash, expires_at=expires_at)
return token
def reset_password(*, db: Session, token: str, new_password: str, captcha_id: str, captcha_code: str, client_ip: str | None = None) -> None:
if not validate_captcha(db, captcha_id, captcha_code, client_ip=client_ip):
from app.core.responses import ApiError
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
pr_repo = PasswordResetRepository(db)
token_hash = _hash_reset_token(token)
pr = pr_repo.get_by_hash(token_hash)
if not pr or pr.expires_at < datetime.utcnow() or pr.used_at is not None:
from app.core.responses import ApiError
raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
# Update user password
from adapters.db.models.user import User
user = db.get(User, pr.user_id)
if not user:
from app.core.responses import ApiError
raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
user.password_hash = hash_password(new_password)
db.add(user)
db.commit()
pr_repo.mark_used(pr)
def change_password(*, db: Session, user_id: int, current_password: str, new_password: str, confirm_password: str, translator=None) -> None:
"""
تغییر کلمه عبور کاربر
"""
# بررسی تطبیق کلمه عبور جدید و تکرار آن
if new_password != confirm_password:
from app.core.responses import ApiError
raise ApiError("PASSWORDS_DO_NOT_MATCH", "New password and confirm password do not match", translator=translator)
# بررسی اینکه کلمه عبور جدید با کلمه عبور فعلی متفاوت باشد
if current_password == new_password:
from app.core.responses import ApiError
raise ApiError("SAME_PASSWORD", "New password must be different from current password", translator=translator)
# دریافت کاربر
from adapters.db.models.user import User
user = db.get(User, user_id)
if not user:
from app.core.responses import ApiError
raise ApiError("USER_NOT_FOUND", "User not found", translator=translator)
# بررسی کلمه عبور فعلی
if not verify_password(current_password, user.password_hash):
from app.core.responses import ApiError
raise ApiError("INVALID_CURRENT_PASSWORD", "Current password is incorrect", translator=translator)
# بررسی اینکه کاربر فعال باشد
if not user.is_active:
from app.core.responses import ApiError
raise ApiError("ACCOUNT_DISABLED", "Your account is disabled", translator=translator)
# تغییر کلمه عبور
user.password_hash = hash_password(new_password)
db.add(user)
db.commit()
# لاگ‌گیری تغییر رمز عبور
try:
from app.services.activity_log_service import log_user_activity
from fastapi import Request
# اگر request در scope باشد، از آن استفاده کن
request = None
import contextvars
try:
# تلاش برای دریافت request از context (اگر در FastAPI باشد)
pass
except:
pass
log_user_activity(
db=db,
user_id=user_id,
action="password_change",
description="رمز عبور تغییر کرد",
request=request
)
except Exception as e:
# اگر خطا در لاگ‌گیری بود، لاگ کن اما ادامه بده
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to log password change activity: {e}")
def send_password_reset_notification(
*,
db: Session,
user_id: int,
token: str,
reset_link: str | None = None,
) -> None:
"""ارسال اعلان‌های پیکربندی‌شده (مثلاً ایمیل/پیامک) برای رویداد auth.password_reset."""
import logging
logger = logging.getLogger(__name__)
try:
from app.services.notification_service import NotificationService
svc = NotificationService(db)
ctx: dict = {"token": token, "reset_link": (reset_link or "")}
svc.send(user_id=user_id, event_key="auth.password_reset", context=ctx)
except Exception as e:
logger.error("password_reset_notification_failed user_id=%s: %s", user_id, e, exc_info=True)
def build_password_reset_web_url(*, request, token: str) -> str | None:
"""
لینک وب برای باز کردن صفحه ورود + دیالوگ تغییر رمز.
الویت: app_public_url → header Origin. اگر نبود None (کلاینت لینک می‌سازد).
"""
from urllib.parse import quote
settings = get_settings()
base = (getattr(settings, "app_public_url", "") or "").strip().rstrip("/")
if not base:
origin = (request.headers.get("origin") or "").strip()
if origin.startswith("http://") or origin.startswith("https://"):
base = origin.rstrip("/")
if not base:
return None
return f"{base}/login?reset_token={quote(token, safe='')}"
def validate_new_password_policy(new_password: str) -> None:
"""قوانین حداقلی طول رمز (هم‌تراز با ثبت‌نام/تغییر رمز)."""
from app.core.responses import ApiError
if not new_password or len(new_password) < 8 or len(new_password) > 128:
raise ApiError("INVALID_PASSWORD", "رمز عبور باید بین ۸ تا ۱۲۸ نویسه باشد", http_status=400)
def admin_set_user_password(
*,
db: Session,
target_user_id: int,
mode: str,
new_password: str | None = None,
confirm_password: str | None = None,
) -> dict:
"""
تنظیم رمز توسط مدیر. mode: direct | random
خروجی: plain_password فقط در حالت random (برای نمایش یک‌باره به مدیر).
"""
from secrets import token_urlsafe
from adapters.db.models.user import User
from app.core.responses import ApiError
if mode not in ("direct", "random"):
raise ApiError("VALIDATION_ERROR", "mode باید direct یا random باشد", http_status=422)
user = db.get(User, target_user_id)
if not user:
raise ApiError("USER_NOT_FOUND", "کاربر یافت نشد", http_status=404)
plain_out: str | None = None
if mode == "direct":
if not new_password or not confirm_password:
raise ApiError("VALIDATION_ERROR", "رمز و تکرار آن الزامی است", http_status=400)
if new_password != confirm_password:
raise ApiError("PASSWORDS_DO_NOT_MATCH", "رمز و تکرار آن یکسان نیست", http_status=400)
validate_new_password_policy(new_password)
user.password_hash = hash_password(new_password)
else:
plain_out = token_urlsafe(16)
user.password_hash = hash_password(plain_out)
db.add(user)
db.commit()
try:
from app.services.activity_log_service import log_user_activity
log_user_activity(
db=db,
user_id=target_user_id,
action="admin_password_set",
description="تنظیم رمز عبور توسط مدیر سیستم",
entity_id=target_user_id,
)
except Exception:
import logging
logging.getLogger(__name__).warning("admin_set_user_password activity log failed", exc_info=True)
return {"plain_password": plain_out, "user_id": target_user_id}
def referral_stats(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None) -> dict:
from adapters.db.repositories.user_repo import UserRepository
repo = UserRepository(db)
# totals
total = repo.count_referred(user_id)
# month
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
month_count = repo.count_referred_between(user_id, month_start, next_month)
# today
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today_start + timedelta(days=1)
today_count = repo.count_referred_between(user_id, today_start, tomorrow)
# custom range
custom = None
if start and end:
custom = repo.count_referred_between(user_id, start, end)
return {
"total": total,
"this_month": month_count,
"today": today_count,
"range": custom,
}
def referral_list(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None, search: str | None = None, page: int = 1, limit: int = 20) -> dict:
from adapters.db.repositories.user_repo import UserRepository
repo = UserRepository(db)
page = max(1, page)
limit = max(1, min(100, limit))
offset = (page - 1) * limit
items = repo.list_referred(user_id, start_dt=start, end_dt=end, search=search, offset=offset, limit=limit)
total = repo.count_referred_filtered(user_id, start_dt=start, end_dt=end, search=search)
def mask_email(email: str | None) -> str | None:
if not email:
return None
try:
local, _, domain = email.partition('@')
if len(local) <= 2:
masked_local = local[0] + "*"
else:
masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
return masked_local + "@" + domain
except Exception:
return email
result = []
for u in items:
result.append({
"id": u.id,
"first_name": u.first_name,
"last_name": u.last_name,
"email": mask_email(u.email),
"created_at": u.created_at.isoformat(),
})
return {"items": result, "total": total, "page": page, "limit": limit}
def check_mobile_availability(db: Session, mobile: str, current_user_id: int) -> dict:
"""
بررسی اینکه آیا شماره موبایل قابل استفاده است یا نه
Args:
db: Database session
mobile: شماره موبایل برای بررسی
current_user_id: شناسه کاربر فعلی
Returns:
{
"available": bool,
"message": str,
"requires_confirmation": bool # برای شماره ثبت شده اما تایید نشده
}
"""
from app.core.responses import ApiError
# نرمال‌سازی شماره موبایل
mobile_n = _normalize_mobile(mobile)
if not mobile_n:
raise ApiError("INVALID_MOBILE", "شماره موبایل نامعتبر است", http_status=400)
repo = UserRepository(db)
existing_user = repo.get_by_mobile(mobile_n)
if not existing_user:
return {"available": True, "message": "", "requires_confirmation": False}
if existing_user.id == current_user_id:
# همان کاربر است
return {"available": True, "message": "", "requires_confirmation": False}
# کاربر دیگری است
if getattr(existing_user, "mobile_verified", False):
return {
"available": False,
"message": "این شماره موبایل قبلاً توسط کاربر دیگری ثبت و تایید شده است",
"requires_confirmation": False
}
else:
return {
"available": False,
"message": "این شماره موبایل قبلاً ثبت شده است اما تایید نشده",
"requires_confirmation": True
}
def check_email_availability(db: Session, email: str, current_user_id: int) -> dict:
"""
بررسی اینکه آیا ایمیل قابل استفاده است یا نه
Args:
db: Database session
email: ایمیل برای بررسی
current_user_id: شناسه کاربر فعلی
Returns:
{
"available": bool,
"message": str,
"requires_confirmation": bool # برای ایمیل ثبت شده اما تایید نشده
}
"""
from app.core.responses import ApiError
# نرمال‌سازی ایمیل
email_n = _normalize_email(email)
if not email_n:
raise ApiError("INVALID_EMAIL", "ایمیل نامعتبر است", http_status=400)
repo = UserRepository(db)
existing_user = repo.get_by_email(email_n)
if not existing_user:
return {"available": True, "message": "", "requires_confirmation": False}
if existing_user.id == current_user_id:
# همان کاربر است
return {"available": True, "message": "", "requires_confirmation": False}
# کاربر دیگری است
if getattr(existing_user, "email_verified", False):
return {
"available": False,
"message": "این ایمیل قبلاً توسط کاربر دیگری ثبت و تایید شده است",
"requires_confirmation": False
}
else:
return {
"available": False,
"message": "این ایمیل قبلاً ثبت شده است اما تایید نشده",
"requires_confirmation": True
}
def update_user_mobile(
*,
db: Session,
user_id: int,
mobile: str,
captcha_id: str,
captcha_code: str,
force_unverified: bool = False,
send_verification_sms: bool = False,
client_ip: str | None = None,
) -> dict:
"""
تغییر شماره موبایل کاربر
Args:
db: Database session
user_id: شناسه کاربر
mobile: شماره موبایل جدید
captcha_id: شناسه کپچا
captcha_code: کد کپچا
force_unverified: اگر True باشد، حتی اگر شماره ثبت شده باشد، اجازه تغییر می‌دهد (برای موارد unverified)
Returns:
{
"mobile": str,
"mobile_verified": bool
}
"""
from app.core.responses import ApiError
from app.core.rate_limiter import get_rate_limiter
from adapters.db.models.user import User
# 1. تایید کپچا
if not validate_captcha(db, captcha_id, captcha_code, client_ip=client_ip):
raise ApiError("INVALID_CAPTCHA", "کد کپچا نامعتبر است", http_status=400)
# 2. بررسی Rate Limiting (حداکثر 3 بار در 24 ساعت)
limiter = get_rate_limiter()
rate_key = f"update_mobile:user_{user_id}"
allowed, remaining, reset_after = limiter.check_rate_limit(
rate_key,
max_requests=3,
window_seconds=86400, # 24 ساعت
)
if not allowed:
raise ApiError(
"RATE_LIMIT_EXCEEDED",
f"تعداد درخواست‌های تغییر شماره موبایل بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
http_status=429
)
# 3. دریافت کاربر
user = db.get(User, user_id)
if not user:
raise ApiError("USER_NOT_FOUND", "کاربر یافت نشد", http_status=404)
# 4. نرمال‌سازی شماره موبایل
mobile_n = _normalize_mobile(mobile)
if not mobile_n:
raise ApiError("INVALID_MOBILE", "شماره موبایل نامعتبر است", http_status=400)
# 5. بررسی اینکه آیا شماره همان شماره فعلی است
if user.mobile == mobile_n:
# شماره تغییر نکرده
return {
"mobile": user.mobile,
"mobile_verified": getattr(user, "mobile_verified", False)
}
# 6. بررسی اینکه آیا شماره قبلی تایید شده بود
if getattr(user, "mobile_verified", False) and user.mobile:
raise ApiError(
"MOBILE_ALREADY_VERIFIED",
"شما نمی‌توانید شماره موبایل تایید شده را تغییر دهید. لطفاً با پشتیبانی تماس بگیرید.",
http_status=400
)
# 7. بررسی تکراری نبودن
availability = check_mobile_availability(db, mobile_n, user_id)
if not availability["available"]:
if availability["requires_confirmation"]:
# شماره ثبت شده اما تایید نشده - نیاز به تایید
if not force_unverified:
raise ApiError(
"MOBILE_IN_USE_UNVERIFIED",
availability["message"],
http_status=400
)
else:
# شماره تایید شده توسط کاربر دیگر
raise ApiError(
"MOBILE_IN_USE_VERIFIED",
availability["message"],
http_status=400
)
# 8. باطل کردن OTP های قبلی برای شماره قبلی (اختیاری - tokens قبلی خود به خود منقضی می‌شوند)
# 9. به‌روزرسانی شماره موبایل
user.mobile = mobile_n
user.mobile_verified = False # Reset verified status
db.add(user)
db.commit()
db.refresh(user)
if send_verification_sms and user.mobile:
from app.services.mobile_verification_service import MobileVerificationService
mobile_verify = MobileVerificationService(db)
mobile_verify.create_mobile_verification(user_id, user.mobile)
return {
"mobile": user.mobile,
"mobile_verified": False
}
def update_user_email(
*,
db: Session,
user_id: int,
email: str,
captcha_id: str,
captcha_code: str,
force_unverified: bool = False,
client_ip: str | None = None,
) -> dict:
"""
تغییر ایمیل کاربر
Args:
db: Database session
user_id: شناسه کاربر
email: ایمیل جدید
captcha_id: شناسه کپچا
captcha_code: کد کپچا
force_unverified: اگر True باشد، حتی اگر ایمیل ثبت شده باشد، اجازه تغییر می‌دهد (برای موارد unverified)
Returns:
{
"email": str,
"email_verified": bool
}
"""
from app.core.responses import ApiError
from app.core.rate_limiter import get_rate_limiter
from adapters.db.models.user import User
# 1. تایید کپچا
if not validate_captcha(db, captcha_id, captcha_code, client_ip=client_ip):
raise ApiError("INVALID_CAPTCHA", "کد کپچا نامعتبر است", http_status=400)
# 2. بررسی Rate Limiting (حداکثر 3 بار در 24 ساعت)
limiter = get_rate_limiter()
rate_key = f"update_email:user_{user_id}"
allowed, remaining, reset_after = limiter.check_rate_limit(
rate_key,
max_requests=3,
window_seconds=86400, # 24 ساعت
)
if not allowed:
raise ApiError(
"RATE_LIMIT_EXCEEDED",
f"تعداد درخواست‌های تغییر ایمیل بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
http_status=429
)
# 3. دریافت کاربر
user = db.get(User, user_id)
if not user:
raise ApiError("USER_NOT_FOUND", "کاربر یافت نشد", http_status=404)
# 4. نرمال‌سازی ایمیل
email_n = _normalize_email(email)
if not email_n:
raise ApiError("INVALID_EMAIL", "ایمیل نامعتبر است", http_status=400)
# 5. بررسی اینکه آیا ایمیل همان ایمیل فعلی است
if user.email == email_n:
# ایمیل تغییر نکرده
return {
"email": user.email,
"email_verified": getattr(user, "email_verified", False)
}
# 6. بررسی اینکه آیا ایمیل قبلی تایید شده بود
if getattr(user, "email_verified", False) and user.email:
raise ApiError(
"EMAIL_ALREADY_VERIFIED",
"شما نمی‌توانید ایمیل تایید شده را تغییر دهید. لطفاً با پشتیبانی تماس بگیرید.",
http_status=400
)
# 7. بررسی تکراری نبودن
availability = check_email_availability(db, email_n, user_id)
if not availability["available"]:
if availability["requires_confirmation"]:
# ایمیل ثبت شده اما تایید نشده - نیاز به تایید
if not force_unverified:
raise ApiError(
"EMAIL_IN_USE_UNVERIFIED",
availability["message"],
http_status=400
)
else:
# ایمیل تایید شده توسط کاربر دیگر
raise ApiError(
"EMAIL_IN_USE_VERIFIED",
availability["message"],
http_status=400
)
# 8. اگر با force_unverified ایمیل کاربر دیگری (تایید نشده) را می‌گیریم، ابتدا ایمیل آن کاربر را خالی می‌کنیم تا محدودیت یکتایی نقض نشود
if not availability["available"] and availability["requires_confirmation"]:
repo = UserRepository(db)
other_user = repo.get_by_email(email_n)
if other_user and other_user.id != user_id:
other_user.email = None
other_user.email_verified = False
db.add(other_user)
db.flush()
# 9. به‌روزرسانی ایمیل
user.email = email_n
user.email_verified = False # Reset verified status
db.add(user)
db.commit()
db.refresh(user)
return {
"email": user.email,
"email_verified": False
}