Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/mobile_verification_service.py

188 lines
6.6 KiB
Python
Executable file

from __future__ import annotations
import hashlib
import random
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
from adapters.db.models.user import User
from adapters.db.repositories.user_repo import UserRepository
from adapters.db.repositories.mobile_verification_repo import MobileVerificationRepository
from app.services.providers.sms_provider import SmsProvider
from app.services.system_settings_service import get_effective_notifications_settings
from app.core.responses import ApiError
from app.core.transaction_lock import acquire_sms_rate_lock
from app.utils.phone_utils import normalize_phone_number
from app.core.settings import get_settings
def _hash_otp(otp: str) -> str:
"""Hash کردن OTP برای ذخیره امن"""
settings = get_settings()
return hashlib.sha256(f"{settings.captcha_secret}:{otp}".encode("utf-8")).hexdigest()
def generate_otp() -> str:
"""تولید کد OTP 6 رقمی"""
return str(random.randint(100000, 999999))
class MobileVerificationService:
def __init__(self, db: Session):
self.db = db
self.repo = MobileVerificationRepository(db)
self.user_repo = UserRepository(db)
# تنظیم SMS Provider
notify_cfg = get_effective_notifications_settings(db)
self.sms_provider = SmsProvider(
provider_name=notify_cfg.get("sms_provider_name"),
api_key=notify_cfg.get("sms_api_key"),
sender=notify_cfg.get("sms_sender"),
username=notify_cfg.get("sms_provider_username"),
password=notify_cfg.get("sms_provider_password"),
is_flash=notify_cfg.get("sms_is_flash", False),
)
def create_mobile_verification(self, user_id: int, mobile: str) -> str:
"""
ایجاد کد OTP و ارسال پیامک
Args:
user_id: شناسه کاربر
mobile: شماره موبایل برای تایید
Returns:
OTP code (فقط برای تست، در production نباید برگردانده شود)
Raises:
ApiError: در صورت خطا
"""
# بررسی وجود کاربر
user = self.user_repo.get_by_id(user_id)
if not user:
raise ApiError("USER_NOT_FOUND", "کاربر یافت نشد", http_status=404)
# نرمال‌سازی شماره موبایل
try:
normalized_mobile = normalize_phone_number(mobile)
except ValueError as e:
raise ApiError("INVALID_MOBILE", str(e), http_status=400)
# بررسی اینکه SMS Provider پیکربندی شده باشد
if not self.sms_provider.is_configured():
raise ApiError("SMS_NOT_CONFIGURED", "سرویس پیامک پیکربندی نشده است. لطفاً با مدیر سیستم تماس بگیرید", http_status=503)
# سهمیه ۳/ساعت + جلوگیری از race
acquire_sms_rate_lock(self.db, f"mobile_ver_otp_user:{user_id}")
recent_count = self.repo.count_recent_by_user(user_id, hours=1)
if recent_count >= 3:
raise ApiError("RATE_LIMIT_EXCEEDED", "حداکثر 3 درخواست در ساعت امکان‌پذیر است. لطفاً بعداً تلاش کنید", http_status=429)
# تولید OTP (پس از اطمینان از سهمیه)
otp_code = generate_otp()
otp_hash = _hash_otp(otp_code)
expires_at = datetime.utcnow() + timedelta(minutes=10)
# ذخیره در دیتابیس
self.repo.create(
user_id=user_id,
mobile=normalized_mobile,
otp_code_hash=otp_hash,
expires_at=expires_at,
commit=False
)
self.db.commit()
# ارسال پیامک
message = f"کد تایید شماره موبایل شما: {otp_code}\nاین کد تا 10 دقیقه اعتبار دارد."
success = self.sms_provider.send_text(to_phone=normalized_mobile, text=message)
if not success:
# در صورت خطا در ارسال، log می‌کنیم اما خطا نمی‌دهیم
# چون OTP در دیتابیس ذخیره شده و کاربر می‌تواند درخواست مجدد کند
import structlog
logger = structlog.get_logger()
logger.error("sms_send_failed", user_id=user_id, mobile=normalized_mobile)
return otp_code # فقط برای تست
def verify_mobile_otp(self, user_id: int, otp_code: str) -> bool:
"""
تایید کد OTP
Args:
user_id: شناسه کاربر
otp_code: کد OTP وارد شده
Returns:
True اگر تایید موفق باشد
Raises:
ApiError: در صورت خطا
"""
if not otp_code or len(otp_code) != 6 or not otp_code.isdigit():
raise ApiError("INVALID_OTP_FORMAT", "کد تایید باید 6 رقم باشد", http_status=400)
# دریافت آخرین token فعال
token = self.repo.get_active_by_user(user_id)
if not token:
raise ApiError("OTP_NOT_FOUND", "کد تایید یافت نشد یا منقضی شده است. لطفاً کد جدید دریافت کنید", http_status=404)
# بررسی تعداد تلاش‌ها (حداکثر 5 تلاش)
if token.attempts >= 5:
raise ApiError("OTP_ATTEMPTS_EXCEEDED", "تعداد تلاش‌های مجاز به پایان رسیده است. لطفاً کد جدید دریافت کنید", http_status=429)
# Hash کردن OTP و مقایسه
otp_hash = _hash_otp(otp_code)
if token.otp_code_hash != otp_hash:
# افزایش تعداد تلاش‌های ناموفق
self.repo.increment_attempts(token)
remaining = 5 - token.attempts - 1
raise ApiError("INVALID_OTP", f"کد تایید اشتباه است. {remaining} تلاش باقی مانده است", http_status=400)
# تایید موفق
self.repo.mark_verified(token)
# به‌روزرسانی mobile_verified در User
user = self.user_repo.get_by_id(user_id)
if user:
# بررسی اینکه شماره موبایل کاربر با شماره تایید شده یکسان باشد
user_mobile = getattr(user, 'mobile', None)
if user_mobile:
try:
normalized_user_mobile = normalize_phone_number(user_mobile)
if normalized_user_mobile == token.mobile:
user.mobile_verified = True
self.db.add(user)
self.db.commit()
except ValueError:
pass # اگر شماره معتبر نباشد، فقط verified می‌کنیم
return True
def resend_otp(self, user_id: int) -> str:
"""
ارسال مجدد OTP
Args:
user_id: شناسه کاربر
Returns:
OTP code (فقط برای تست)
Raises:
ApiError: در صورت خطا
"""
user = self.user_repo.get_by_id(user_id)
if not user:
raise ApiError("USER_NOT_FOUND", "کاربر یافت نشد", http_status=404)
mobile = getattr(user, 'mobile', None)
if not mobile:
raise ApiError("NO_MOBILE", "کاربر شماره موبایل ثبت نکرده است", http_status=400)
return self.create_mobile_verification(user_id, mobile)