Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/auth.py

2165 lines
67 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Removed __future__ annotations to fix OpenAPI schema generation
import datetime
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import Response, HTMLResponse
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.user_repo import UserRepository
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.services.captcha_service import create_captcha, validate_captcha
from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats
from app.services.email_verification_service import verify_email_token, resend_verification_email
from app.services.pdf import PDFService
from .schemas import (
RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest,
SendLoginOtpRequest, AvailableChannelsRequest, ChangePasswordRequest, UpdateMobileRequest, UpdateEmailRequest,
SendMobileVerificationRequest,
CreateApiKeyRequest, UpdateApiKeyRequest, QueryInfo, FilterItem,
SuccessResponse, CaptchaResponse, LoginResponse, ApiKeyResponse,
ReferralStatsResponse, UserResponse
)
from app.core.settings import get_settings
from app.core.auth_dependency import get_current_user, AuthContext
from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key
from app.services.session_service import list_user_sessions, revoke_session, revoke_other_sessions
from app.core.rate_limiter import get_client_ip, rate_limit
from app.services.auth_dynamic_rate_limit import enforce_auth_rate_limit
router = APIRouter(prefix="/auth", tags=["احراز هویت"])
def _otp_in_response(otp_code: str | None) -> dict:
"""فقط در حالت debug و غیر-production — در production مقدار OTP در پاسخ API برگردانده نمی‌شود."""
settings = get_settings()
is_production = (settings.environment or "").strip().lower() in {"production", "prod"}
if not is_production and settings.debug and (otp_code or ""):
return {"otp_code": otp_code}
return {}
@router.post("/captcha",
summary="تولید کپچای عددی",
description="تولید کپچای عددی برای تأیید هویت در عملیات حساس",
response_model=SuccessResponse,
responses={
200: {
"description": "کپچا با موفقیت تولید شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کپچا تولید شد",
"data": {
"captcha_id": "abc123def456",
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
"ttl_seconds": 180
}
}
}
}
}
}
)
async def generate_captcha(request: Request, db: Session = Depends(get_db)) -> dict:
enforce_auth_rate_limit(
request,
db,
kind="captcha",
error_message="تعداد درخواست‌های کپچا بیش از حد مجاز است. لطفاً کمی صبر کنید.",
)
captcha_id, image_base64, ttl = create_captcha(db, get_client_ip(request))
from app.services.system_settings_service import get_captcha_auth_security_effective
_csec = get_captcha_auth_security_effective(db)
return success_response({
"captcha_id": captcha_id,
"image_base64": image_base64,
"ttl_seconds": ttl,
"captcha_mode": _csec["captcha_mode"],
"captcha_length": _csec["captcha_length"],
})
@router.get(
"/public-config",
summary="تنظیمات عمومی احراز هویت (مهمان)",
description="اطلاعات محدود برای صفحهٔ ورود بدون نیاز به API Key — مثلاً فعال بودن ثبت‌نام",
response_model=SuccessResponse,
)
async def get_public_auth_config(db: Session = Depends(get_db)) -> dict:
from app.services.system_settings_service import (
is_registration_enabled,
legacy_api_import_public_config_dict,
support_tickets_public_config_dict,
theme_public_config_dict,
)
repo = UserRepository(db)
is_first_user_bootstrap = repo.count_all() == 0
data = {
# برای استقرار اولیه، حتی اگر تنظیم ثبت‌نام بسته باشد تب عضویت باید نمایش داده شود
"enable_registration": True if is_first_user_bootstrap else is_registration_enabled(db),
}
data.update(support_tickets_public_config_dict(db))
data.update(legacy_api_import_public_config_dict(db))
data.update(theme_public_config_dict(db))
return success_response(data)
@router.get("/me",
summary="دریافت اطلاعات کاربر کنونی",
description="دریافت اطلاعات کامل کاربری که در حال حاضر وارد سیستم شده است",
response_model=SuccessResponse,
responses={
200: {
"description": "اطلاعات کاربر با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "اطلاعات کاربر دریافت شد",
"data": {
"id": 1,
"email": "user@example.com",
"mobile": "09123456789",
"first_name": "احمد",
"last_name": "احمدی",
"is_active": True,
"referral_code": "ABC123",
"referred_by_user_id": None,
"app_permissions": {"admin": True},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "احراز هویت مورد نیاز است",
"error_code": "UNAUTHORIZED"
}
}
}
}
}
)
def get_current_user_info(
request: Request,
ctx: AuthContext = Depends(get_current_user)
) -> dict:
"""دریافت اطلاعات کاربر کنونی"""
return success_response(ctx.to_dict(), request)
@router.post(
"/activity",
summary="ثبت ضربان فعالیت (آخرین فعالیت در اپ)",
description="""
کلاینت به‌صورت دوره‌ای (مثلاً هر یک دقیقه) این endpoint را با همان کلید ApiKey فراخوانی کند.
سرور فیلد «آخرین فعالیت» را با به‌روزرسانی محدود (throttle پیش‌فرض ~۴۵ ثانیه) در پایگاه داده ذخیره می‌کند.
بدون نیاز به Redis؛ برای نمایش «کاربران اخیراً فعال» در مدیریت سیستم قابل استفاده است.
""",
)
def post_user_activity(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_id = ctx.get_user_id()
if not user_id:
raise ApiError("UNAUTHORIZED", "احراز هویت الزامی است", http_status=401)
repo = UserRepository(db)
ts = repo.touch_last_activity(user_id)
if ts is None:
raise ApiError("USER_NOT_FOUND", "کاربر یافت نشد", http_status=404)
payload = format_datetime_fields({"last_activity_at": ts}, request)
return success_response(payload, request, message=None)
@router.post("/register",
summary="ثبت‌نام کاربر جدید",
description="ثبت‌نام کاربر جدید در سیستم با تأیید کپچا",
response_model=SuccessResponse,
responses={
200: {
"description": "کاربر با موفقیت ثبت‌نام شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ثبت‌نام با موفقیت انجام شد",
"data": {
"api_key": "sk_1234567890abcdef",
"expires_at": None,
"user": {
"id": 1,
"first_name": "احمد",
"last_name": "احمدی",
"email": "ahmad@example.com",
"mobile": "09123456789",
"referral_code": "ABC123",
"app_permissions": None
}
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
409: {
"description": "کاربر با این ایمیل یا موبایل قبلاً ثبت‌نام کرده است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کاربر با این ایمیل قبلاً ثبت‌نام کرده است",
"error_code": "USER_EXISTS"
}
}
}
}
}
)
async def register(request: Request, payload: RegisterRequest, db: Session = Depends(get_db)) -> dict:
enforce_auth_rate_limit(
request,
db,
kind="register",
error_message="تعداد درخواست‌های ثبت‌نام بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
)
import logging
logger = logging.getLogger(__name__)
# ساخت base_url از request برای verification email
base_url = None
if request.headers.get("X-Forwarded-Host"):
proto = request.headers.get("X-Forwarded-Proto", "https")
host = request.headers.get("X-Forwarded-Host")
base_url = f"{proto}://{host}"
elif request.url:
base_url = str(request.url).replace(request.url.path, "").rstrip("/")
try:
user_id = register_user(
db=db,
first_name=payload.first_name,
last_name=payload.last_name,
email=payload.email,
mobile=payload.mobile,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
referrer_code=payload.referrer_code,
base_url=base_url,
client_ip=get_client_ip(request),
)
# Create a session api key similar to login
user_agent = request.headers.get("User-Agent")
ip = request.client.host if request.client else None
from app.core.security import generate_api_key
from adapters.db.repositories.api_key_repo import ApiKeyRepository
api_key, key_hash = generate_api_key()
api_repo = ApiKeyRepository(db)
api_repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=payload.device_id, user_agent=user_agent, ip=ip, expires_at=None)
from adapters.db.models.user import User
user_obj = db.get(User, user_id)
user = {
"id": user_id,
"first_name": payload.first_name,
"last_name": payload.last_name,
"email": payload.email,
"mobile": payload.mobile,
"referral_code": getattr(user_obj, "referral_code", None),
"app_permissions": getattr(user_obj, "app_permissions", None),
"email_verified": getattr(user_obj, "email_verified", False)
}
response_data = {"api_key": api_key, "expires_at": None, "user": user}
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)
except Exception as exc:
from app.core.responses import ApiError
if isinstance(exc, ApiError):
raise
logger.error(
"POST /api/v1/auth/register failed: %s: %s",
type(exc).__name__,
str(exc),
exc_info=True,
extra={"path": request.url.path, "method": request.method},
)
raise
@router.post("/login",
summary="ورود با ایمیل یا موبایل",
description="ورود کاربر به سیستم با استفاده از ایمیل یا شماره موبایل و رمز عبور",
response_model=SuccessResponse,
responses={
200: {
"description": "ورود با موفقیت انجام شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ورود با موفقیت انجام شد",
"data": {
"api_key": "sk_1234567890abcdef",
"expires_at": "2024-01-02T00:00:00Z",
"user": {
"id": 1,
"first_name": "احمد",
"last_name": "احمدی",
"email": "ahmad@example.com",
"mobile": "09123456789",
"referral_code": "ABC123",
"app_permissions": {"admin": True}
}
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
401: {
"description": "اطلاعات ورود نامعتبر است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "ایمیل یا رمز عبور اشتباه است",
"error_code": "INVALID_CREDENTIALS"
}
}
}
}
}
)
async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
enforce_auth_rate_limit(
request,
db,
kind="login_short",
error_message="تعداد تلاش‌های ورود بیش از حد مجاز است. لطفاً کمی صبر کنید.",
)
enforce_auth_rate_limit(
request,
db,
kind="login_long",
error_message="تعداد درخواست‌های ورود بیش از حد مجاز است. لطفاً کمی صبر کنید.",
)
user_agent = request.headers.get("User-Agent")
ip = get_client_ip(request)
api_key, expires_at, user = login_user(
db=db,
identifier=payload.identifier,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
device_id=payload.device_id,
user_agent=user_agent,
ip=ip,
)
# Ensure referral_code is included
from adapters.db.repositories.user_repo import UserRepository
repo = UserRepository(db)
from adapters.db.models.user import User
user_obj = None
if 'id' in user and user['id']:
user_obj = repo.db.get(User, user['id'])
if user_obj is not None:
user["referral_code"] = getattr(user_obj, "referral_code", None)
response_data = {"api_key": api_key, "expires_at": expires_at, "user": user}
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)
@router.post("/forgot-password",
summary="ایجاد توکن بازنشانی رمز عبور",
description="ایجاد توکن برای بازنشانی رمز عبور کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "توکن بازنشانی با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "توکن بازنشانی ارسال شد",
"data": {
"ok": True,
"token": "reset_token_1234567890abcdef"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
404: {
"description": "کاربر یافت نشد",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کاربر با این ایمیل یا موبایل یافت نشد",
"error_code": "USER_NOT_FOUND"
}
}
}
}
}
)
async def forgot_password(request: Request, payload: ForgotPasswordRequest, db: Session = Depends(get_db)) -> dict:
enforce_auth_rate_limit(
request,
db,
kind="forgot",
error_message="تعداد درخواست‌های بازیابی رمز عبور بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
)
# ایجاد token برای reset password
token = create_password_reset(
db=db,
identifier=payload.identifier,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
client_ip=get_client_ip(request),
)
# Send notification via preferred channels
if token:
from adapters.db.repositories.user_repo import UserRepository
from app.services.notification_service import NotificationService
from app.services.auth_service import _detect_identifier
import logging
logger = logging.getLogger(__name__)
# تشخیص نوع identifier و جستجوی صحیح کاربر
kind, email, mobile = _detect_identifier(payload.identifier)
if kind != "invalid":
repo = UserRepository(db)
user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
if user:
try:
svc = NotificationService(db)
svc.send(user_id=user.id, event_key="auth.password_reset", context={"token": token})
except Exception as e:
# در صورت خطا در ارسال notification، log می‌کنیم اما فرآیند ادامه می‌یابد
logger.error(f"Failed to send password reset notification for user {user.id}: {e}")
# همیشه پاسخ موفق برمی‌گردانیم (برای جلوگیری از user enumeration)
# در production نباید token برگردانده شود
return success_response({"ok": True})
@router.post("/reset-password",
summary="بازنشانی رمز عبور با توکن",
description="بازنشانی رمز عبور کاربر با استفاده از توکن دریافتی",
response_model=SuccessResponse,
responses={
200: {
"description": "رمز عبور با موفقیت بازنشانی شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "رمز عبور با موفقیت تغییر کرد",
"data": {
"ok": True
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
404: {
"description": "توکن نامعتبر یا منقضی شده است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "توکن نامعتبر یا منقضی شده است",
"error_code": "INVALID_TOKEN"
}
}
}
}
}
)
async def reset_password_endpoint(request: Request, payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> dict:
enforce_auth_rate_limit(
request,
db,
kind="reset",
error_message="تعداد درخواست‌های بازنشانی رمز عبور بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
)
reset_password(
db=db,
token=payload.token,
new_password=payload.new_password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
client_ip=get_client_ip(request),
)
return success_response({"ok": True})
@router.post(
"/password-reset/send-otp",
summary="ارسال OTP برای بازیابی رمز عبور",
description="ارسال کد OTP 6 رقمی به شماره موبایل برای بازیابی رمز عبور",
response_model=SuccessResponse,
responses={
200: {
"description": "کد OTP با موفقیت ارسال شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کد بازیابی رمز عبور به شماره موبایل ارسال شد"
}
}
}
},
400: {
"description": "شناسه نامعتبر یا کاربر موبایل ندارد",
},
429: {
"description": "تعداد درخواست‌ها بیش از حد مجاز",
},
503: {
"description": "سرویس پیامک پیکربندی نشده است",
}
}
)
def send_password_reset_otp(
request: Request,
payload: ForgotPasswordRequest,
db: Session = Depends(get_db)
) -> dict:
"""ارسال OTP برای بازیابی رمز عبور"""
enforce_auth_rate_limit(
request,
db,
kind="pr_otp",
error_message="تعداد درخواست‌های بازیابی رمز عبور بیش از حد مجاز است. لطفاً چند دقیقه بعد دوباره تلاش کنید.",
)
from app.services.password_reset_otp_service import PasswordResetOtpService
from app.services.captcha_service import validate_captcha
from app.core.responses import ApiError
# تایید کپچا
if not validate_captcha(db, payload.captcha_id, payload.captcha_code, client_ip=get_client_ip(request)):
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
service = PasswordResetOtpService(db)
try:
otp_code = service.send_reset_otp(payload.identifier)
data = {
"ok": True,
"message": "کد بازیابی رمز عبور به شماره موبایل ارسال شد",
}
data.update(_otp_in_response(otp_code))
return success_response(data, request)
except ApiError as e:
raise e
except Exception as e:
raise ApiError("SMS_SEND_FAILED", f"خطا در ارسال پیامک: {str(e)}", http_status=500)
@router.post(
"/password-reset/verify-otp",
summary="تایید OTP بازیابی رمز عبور",
description="تایید کد OTP و دریافت token برای تغییر رمز عبور",
response_model=SuccessResponse,
responses={
200: {
"description": "OTP تایید شد و token دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"data": {
"reset_token": "token_here"
}
}
}
}
},
400: {
"description": "کد OTP نامعتبر",
}
}
)
@rate_limit(
max_requests=10,
window_seconds=600,
key_func=lambda req: f"verify_pr_otp:{get_client_ip(req)}",
error_message="تعداد تلاش‌های تایید کد بازیابی بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
)
def verify_password_reset_otp(
request: Request,
identifier: str = Query(..., description="ایمیل یا شماره موبایل"),
otp_code: str = Query(..., description="کد OTP 6 رقمی"),
db: Session = Depends(get_db)
) -> dict:
"""تایید OTP و دریافت reset token"""
from app.services.password_reset_otp_service import PasswordResetOtpService
service = PasswordResetOtpService(db)
success, reset_token = service.verify_reset_otp(identifier, otp_code)
if not success:
from app.core.responses import ApiError
raise ApiError("OTP_VERIFICATION_FAILED", "تایید OTP ناموفق بود", http_status=400)
return success_response({
"reset_token": reset_token,
"message": "OTP تایید شد. اکنون می‌توانید رمز عبور جدید تنظیم کنید"
}, request)
@router.get("/api-keys",
summary="لیست کلیدهای API شخصی",
description="دریافت لیست کلیدهای API شخصی کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست کلیدهای API با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست کلیدهای API دریافت شد",
"data": [
{
"id": 1,
"name": "کلید اصلی",
"scopes": "read,write",
"device_id": "device123",
"user_agent": "Mozilla/5.0...",
"ip": "192.168.1.1",
"expires_at": None,
"last_used_at": "2024-01-01T12:00:00Z",
"created_at": "2024-01-01T00:00:00Z"
}
]
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def list_keys(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
items = list_personal_keys(db, ctx.user.id)
return success_response(items)
@router.post("/api-keys",
summary="ایجاد کلید API شخصی",
description="ایجاد کلید API جدید برای کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "کلید API با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کلید API ایجاد شد",
"data": {
"id": 1,
"api_key": "sk_1234567890abcdef"
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def create_key(request: Request, payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
from datetime import datetime
expires_at = None
if payload.expires_at:
try:
# پارس کردن تاریخ ISO format (مثال: 2024-12-31T23:59:59)
date_str = payload.expires_at.strip()
# تبدیل Z به +00:00 برای timezone
if date_str.endswith('Z'):
date_str = date_str[:-1] + '+00:00'
expires_at = datetime.fromisoformat(date_str)
except (ValueError, AttributeError) as e:
from app.core.responses import ApiError
raise ApiError("INVALID_INPUT", f"Invalid expires_at format. Use ISO format: YYYY-MM-DDTHH:MM:SS. Error: {str(e)}", http_status=400)
id_, api_key = create_personal_key(
db,
ctx.user.id,
payload.name,
payload.scopes,
expires_at,
payload.ip_whitelist
)
return success_response({"id": id_, "api_key": api_key, "message": "کلید API با موفقیت ایجاد شد. لطفاً آن را ذخیره کنید زیرا فقط یک بار نمایش داده می‌شود."})
@router.post("/change-password",
summary="تغییر رمز عبور",
description="تغییر رمز عبور کاربر با تأیید رمز عبور فعلی",
response_model=SuccessResponse,
responses={
200: {
"description": "رمز عبور با موفقیت تغییر کرد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "رمز عبور با موفقیت تغییر کرد",
"data": {
"ok": True
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "رمز عبور فعلی اشتباه است",
"error_code": "INVALID_CURRENT_PASSWORD"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def change_password_endpoint(request: Request, payload: ChangePasswordRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
# دریافت translator از request state
translator = getattr(request.state, "translator", None)
change_password(
db=db,
user_id=ctx.user.id,
current_password=payload.current_password,
new_password=payload.new_password,
confirm_password=payload.confirm_password,
translator=translator
)
return success_response({"ok": True})
@router.delete("/api-keys/{key_id}",
summary="حذف کلید API",
description="حذف کلید API مشخص شده",
response_model=SuccessResponse,
responses={
200: {
"description": "کلید API با موفقیت حذف شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کلید API حذف شد",
"data": {
"ok": True
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
404: {
"description": "کلید API یافت نشد",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کلید API یافت نشد",
"error_code": "API_KEY_NOT_FOUND"
}
}
}
}
}
)
def delete_key(request: Request, key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
revoke_key(db, ctx.user.id, key_id)
return success_response({"ok": True})
@router.get("/api-keys/{key_id}",
summary="دریافت جزئیات کلید API",
description="دریافت اطلاعات کامل یک کلید API",
response_model=SuccessResponse,
)
def get_key(request: Request, key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
from adapters.db.models.api_key import ApiKey
from app.core.responses import ApiError
obj = db.get(ApiKey, key_id)
if not obj or obj.user_id != ctx.user.id or obj.key_type != "personal":
raise ApiError("NOT_FOUND", "Key not found", http_status=404)
from datetime import datetime
data = {
"id": obj.id,
"name": obj.name,
"scopes": obj.scopes,
"ip": obj.ip,
"user_agent": obj.user_agent,
"created_at": obj.created_at.isoformat() if obj.created_at else None,
"expires_at": obj.expires_at.isoformat() if obj.expires_at else None,
"last_used_at": obj.last_used_at.isoformat() if obj.last_used_at else None,
"revoked_at": obj.revoked_at.isoformat() if obj.revoked_at else None,
"is_active": obj.revoked_at is None and (obj.expires_at is None or obj.expires_at > datetime.utcnow()),
}
return success_response(data)
@router.put("/api-keys/{key_id}",
summary="ویرایش کلید API",
description="ویرایش اطلاعات یک کلید API (نام، محدوده دسترسی، تاریخ انقضا، IP whitelist)",
response_model=SuccessResponse,
)
def update_key(request: Request, key_id: int, payload: UpdateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
from datetime import datetime
from app.services.api_key_service import update_api_key
expires_at = None
if payload.expires_at is not None:
if payload.expires_at == "":
expires_at = None
else:
try:
# پارس کردن تاریخ ISO format
date_str = payload.expires_at.strip()
# تبدیل Z به +00:00 برای timezone
if date_str.endswith('Z'):
date_str = date_str[:-1] + '+00:00'
expires_at = datetime.fromisoformat(date_str)
except (ValueError, AttributeError) as e:
from app.core.responses import ApiError
raise ApiError("INVALID_INPUT", f"Invalid expires_at format. Use ISO format: YYYY-MM-DDTHH:MM:SS. Error: {str(e)}", http_status=400)
update_api_key(
db,
ctx.user.id,
key_id,
payload.name,
payload.scopes,
expires_at,
payload.ip_whitelist
)
return success_response({"ok": True, "message": "کلید API با موفقیت به‌روزرسانی شد"})
@router.get("/sessions",
summary="لیست سشن‌های ورود",
description="دریافت لیست تمام session های فعال کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست session ها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"data": [
{
"id": 123,
"device_name": "Chrome on Windows",
"device_id": "device-uuid-123",
"user_agent": "Mozilla/5.0...",
"ip": "192.168.1.100",
"is_current": True,
"created_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:25:00Z",
"last_used_relative": "2 ساعت پیش",
"browser": "Chrome",
"os": "Windows",
"device_type": "desktop"
}
]
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def list_sessions(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""لیست تمام session های فعال کاربر"""
# دریافت hash کلید API فعلی
from adapters.db.models.api_key import ApiKey
current_key = db.get(ApiKey, ctx.api_key_id)
if not current_key:
from app.core.responses import ApiError
raise ApiError("INTERNAL_ERROR", "API key not found", http_status=500)
current_key_hash = current_key.key_hash
sessions = list_user_sessions(db, ctx.user.id, current_key_hash)
return success_response(sessions, request)
@router.delete("/sessions/others",
summary="حذف همه سشن‌های دیگر",
description="حذف تمام session های کاربر به جز session فعلی",
response_model=SuccessResponse,
responses={
200: {
"description": "تمام session های دیگر حذف شدند",
"content": {
"application/json": {
"example": {
"success": True,
"message": "تمام سشن‌های دیگر حذف شدند",
"data": {"deleted_count": 5}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def revoke_other_sessions_endpoint(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""حذف تمام session های دیگر (به جز فعلی)"""
# دریافت hash کلید API فعلی
from adapters.db.models.api_key import ApiKey
current_key = db.get(ApiKey, ctx.api_key_id)
if not current_key:
from app.core.responses import ApiError
raise ApiError("INTERNAL_ERROR", "API key not found", http_status=500)
current_key_hash = current_key.key_hash
deleted_count = revoke_other_sessions(db, ctx.user.id, current_key_hash)
return success_response(
{"deleted_count": deleted_count, "message": f"{deleted_count} سشن حذف شد"},
request
)
@router.delete("/sessions/{session_id}",
summary="حذف سشن ورود",
description="حذف یک session خاص. کاربر نمی‌تواند session فعلی را حذف کند.",
response_model=SuccessResponse,
responses={
200: {
"description": "Session با موفقیت حذف شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "سشن با موفقیت حذف شد",
"data": {"ok": True}
}
}
}
},
400: {
"description": "نمی‌توانید session فعلی را حذف کنید"
},
404: {
"description": "Session یافت نشد"
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def revoke_session_endpoint(
session_id: int,
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""حذف یک session"""
# دریافت hash کلید API فعلی
from adapters.db.models.api_key import ApiKey
current_key = db.get(ApiKey, ctx.api_key_id)
if not current_key:
from app.core.responses import ApiError
raise ApiError("INTERNAL_ERROR", "API key not found", http_status=500)
current_key_hash = current_key.key_hash
revoke_session(db, ctx.user.id, session_id, current_key_hash)
return success_response({"ok": True, "message": "سشن با موفقیت حذف شد"}, request)
@router.get("/referrals/stats",
summary="آمار معرفی‌ها",
description="دریافت آمار معرفی‌های کاربر فعلی",
response_model=SuccessResponse,
responses={
200: {
"description": "آمار معرفی‌ها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "آمار معرفی‌ها دریافت شد",
"data": {
"total_referrals": 25,
"active_referrals": 20,
"recent_referrals": 5,
"referral_rate": 0.8
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str = Query(None, description="تاریخ شروع (ISO format)"), end: str = Query(None, description="تاریخ پایان (ISO format)")):
from datetime import datetime
start_dt = datetime.fromisoformat(start) if start else None
end_dt = datetime.fromisoformat(end) if end else None
stats = referral_stats(db=db, user_id=ctx.user.id, start=start_dt, end=end_dt)
return success_response(stats)
@router.post("/referrals/list",
summary="لیست معرفی‌ها با فیلتر پیشرفته",
description="دریافت لیست معرفی‌ها با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست معرفی‌ها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست معرفی‌ها دریافت شد",
"data": {
"items": [
{
"id": 1,
"first_name": "علی",
"last_name": "احمدی",
"email": "ali@example.com",
"mobile": "09123456789",
"created_at": "2024-01-01T00:00:00Z"
}
],
"total": 1,
"page": 1,
"limit": 10,
"total_pages": 1,
"has_next": False,
"has_prev": False
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def get_referral_list_advanced(
request: Request,
query_info: QueryInfo,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""
دریافت لیست معرفی‌ها با قابلیت فیلتر پیشرفته
پارامترهای QueryInfo:
- sort_by: فیلد مرتب‌سازی (مثال: created_at, first_name, last_name, email)
- sort_desc: ترتیب نزولی (true/false)
- take: تعداد رکورد در هر صفحه (پیش‌فرض: 10)
- skip: تعداد رکورد صرف‌نظر شده (پیش‌فرض: 0)
- search: عبارت جستجو
- search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"])
- filters: آرایه فیلترها با ساختار:
[
{
"property": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00"
},
{
"property": "first_name",
"operator": "*",
"value": "احمد"
}
]
"""
from adapters.db.repositories.user_repo import UserRepository
from adapters.db.models.user import User
from datetime import datetime
# Create a custom query for referrals
repo = UserRepository(db)
# Add filter for referrals only (users with referred_by_user_id = current user)
referral_filter = FilterItem(
property="referred_by_user_id",
operator="=",
value=ctx.user.id
)
# Add referral filter to existing filters
if query_info.filters is None:
query_info.filters = [referral_filter]
else:
query_info.filters.append(referral_filter)
# Set default search fields for referrals
if query_info.search_fields is None:
query_info.search_fields = ["first_name", "last_name", "email"]
# Execute query with filters
referrals, total = repo.query_with_filters(query_info)
# Convert to dictionary format
referral_dicts = [repo.to_dict(referral) for referral in referrals]
# Format datetime fields
formatted_referrals = format_datetime_fields(referral_dicts, request)
# Calculate pagination info
page = (query_info.skip // query_info.take) + 1
total_pages = (total + query_info.take - 1) // query_info.take
return success_response({
"items": formatted_referrals,
"total": total,
"page": page,
"limit": query_info.take,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1
}, request)
@router.post("/referrals/export/pdf",
summary="خروجی PDF لیست معرفی‌ها",
description="خروجی PDF لیست معرفی‌ها با قابلیت فیلتر و انتخاب سطرهای خاص",
responses={
200: {
"description": "فایل PDF با موفقیت تولید شد",
"content": {
"application/pdf": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def export_referrals_pdf(
request: Request,
query_info: QueryInfo,
selected_only: bool = False,
selected_indices: str | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Response:
"""
خروجی PDF لیست معرفی‌ها
پارامترها:
- selected_only: آیا فقط سطرهای انتخاب شده export شوند
- selected_indices: لیست ایندکس‌های انتخاب شده (JSON string)
- سایر پارامترهای QueryInfo برای فیلتر
"""
from app.services.pdf import PDFService
from app.services.auth_service import referral_stats
import json
# Parse selected indices if provided
indices = None
if selected_only and selected_indices:
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
# Get stats for the report
stats = None
try:
# Extract date range from filters if available
start_date = None
end_date = None
if query_info.filters:
for filter_item in query_info.filters:
if filter_item.property == 'created_at':
if filter_item.operator == '>=':
start_date = filter_item.value
elif filter_item.operator == '<':
end_date = filter_item.value
stats = referral_stats(
db=db,
user_id=ctx.user.id,
start=start_date,
end=end_date
)
except Exception:
pass # Continue without stats
# Get calendar type from request headers
calendar_header = request.headers.get("X-Calendar-Type", "jalali")
calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian"
# Generate PDF using new modular service
pdf_service = PDFService()
# Get locale from request headers
locale_header = request.headers.get("Accept-Language", "fa")
locale = "fa" if locale_header.startswith("fa") else "en"
pdf_bytes = pdf_service.generate_pdf(
module_name='marketing',
data={}, # Empty data - module will fetch its own data
calendar_type=calendar_type,
locale=locale,
db=db,
user_id=ctx.user.id,
query_info=query_info,
selected_indices=indices,
stats=stats
)
# Return PDF response
from fastapi.responses import Response
import datetime
filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes))
}
)
@router.post("/referrals/export/excel",
summary="خروجی Excel لیست معرفی‌ها",
description="خروجی Excel لیست معرفی‌ها با قابلیت فیلتر و انتخاب سطرهای خاص",
responses={
200: {
"description": "فایل Excel با موفقیت تولید شد",
"content": {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def export_referrals_excel(
request: Request,
query_info: QueryInfo,
selected_only: bool = False,
selected_indices: str | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Response:
"""
خروجی Excel لیست معرفی‌ها (فایل Excel واقعی برای دانلود)
پارامترها:
- selected_only: آیا فقط سطرهای انتخاب شده export شوند
- selected_indices: لیست ایندکس‌های انتخاب شده (JSON string)
- سایر پارامترهای QueryInfo برای فیلتر
"""
from app.services.pdf import PDFService
import json
import io
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
# Parse selected indices if provided
indices = None
if selected_only and selected_indices:
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
# Get calendar type from request headers
calendar_header = request.headers.get("X-Calendar-Type", "jalali")
calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian"
# Generate Excel data using new modular service
pdf_service = PDFService()
# Get locale from request headers
locale_header = request.headers.get("Accept-Language", "fa")
locale = "fa" if locale_header.startswith("fa") else "en"
excel_data = pdf_service.generate_excel_data(
module_name='marketing',
data={}, # Empty data - module will fetch its own data
calendar_type=calendar_type,
locale=locale,
db=db,
user_id=ctx.user.id,
query_info=query_info,
selected_indices=indices
)
# Create Excel workbook
wb = Workbook()
ws = wb.active
ws.title = "Referrals"
# Define styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Add headers
if excel_data:
headers = list(excel_data[0].keys())
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Add data rows
for row, data in enumerate(excel_data, 2):
for col, header in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=data.get(header, ""))
cell.border = border
# Center align for numbers and dates
if header in ["ردیف", "Row", "تاریخ ثبت", "Registration Date"]:
cell.alignment = Alignment(horizontal="center")
# Auto-adjust column widths
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Save to BytesIO
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
# Generate filename
filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
# Return Excel file as response
return Response(
content=excel_buffer.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
)
@router.get(
"/verify-email",
summary="تایید ایمیل کاربر",
description="تایید ایمیل کاربر با استفاده از token ارسال شده در ایمیل verification",
response_model=SuccessResponse,
responses={
200: {
"description": "ایمیل با موفقیت تایید شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ایمیل با موفقیت تایید شد",
"data": {
"user_id": 1,
"email": "user@example.com",
"email_verified": True
}
}
}
}
},
400: {
"description": "Token نامعتبر یا منقضی شده",
"content": {
"application/json": {
"example": {
"success": False,
"error_code": "INVALID_TOKEN",
"message": "Token نامعتبر یا استفاده شده است"
}
}
}
}
}
)
def verify_email(
request: Request,
token: str = Query(..., description="Token verification از ایمیل"),
db: Session = Depends(get_db)
):
"""تایید ایمیل کاربر با token. در مرورگر یک صفحهٔ خوانا و در API همان JSON برمی‌گردد."""
accept = request.headers.get("accept", "") or ""
wants_html = "text/html" in accept
try:
user = verify_email_token(db, token)
except ApiError as e:
if wants_html:
msg = "لینک نامعتبر یا منقضی شده است."
if isinstance(getattr(e, "detail", None), dict):
err = e.detail.get("error") if isinstance(e.detail.get("error"), dict) else {}
msg = err.get("message", msg)
html = _verify_email_html(success=False, message=msg)
return HTMLResponse(content=html, status_code=e.status_code)
raise
response_data = {
"user_id": user.id,
"email": user.email,
"email_verified": user.email_verified
}
if wants_html:
html = _verify_email_html(success=True, message="ایمیل شما با موفقیت تایید شد.")
return HTMLResponse(content=html)
return success_response(response_data, request, message="EMAIL_VERIFIED")
def _verify_email_html(*, success: bool, message: str) -> str:
"""صفحهٔ HTML ساده برای نمایش نتیجهٔ تایید ایمیل در مرورگر (RTL)."""
title = "تایید ایمیل" if success else "خطا در تایید ایمیل"
icon = "✅" if success else "❌"
color = "#22c55e" if success else "#dc2626"
return f"""<!DOCTYPE html>
<html dir="rtl" lang="fa">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
body {{ font-family: Tahoma, Arial, sans-serif; background: #f8fafc; margin: 0; padding: 2rem; display: flex; justify-content: center; align-items: center; min-height: 100vh; box-sizing: border-box; }}
.card {{ background: white; border-radius: 12px; padding: 2rem; max-width: 420px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); text-align: center; }}
.icon {{ font-size: 3rem; margin-bottom: 1rem; }}
.message {{ color: #334155; font-size: 1.1rem; line-height: 1.6; }}
</style>
</head>
<body>
<div class="card">
<div class="icon">{icon}</div>
<h1 style="color: {color}; margin: 0 0 1rem;">{title}</h1>
<p class="message">{message}</p>
</div>
</body>
</html>"""
@router.post(
"/resend-verification",
summary="ارسال مجدد ایمیل verification",
description="ارسال مجدد ایمیل verification برای کاربر. نیاز به authentication دارد.",
response_model=SuccessResponse,
responses={
200: {
"description": "ایمیل verification با موفقیت ارسال شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ایمیل verification با موفقیت ارسال شد"
}
}
}
},
400: {
"description": "ایمیل قبلاً تایید شده یا تنظیم نشده",
"content": {
"application/json": {
"example": {
"success": False,
"error_code": "EMAIL_ALREADY_VERIFIED",
"message": "ایمیل کاربر قبلاً تایید شده است"
}
}
}
},
429: {
"description": "تعداد درخواست‌ها بیش از حد مجاز",
"content": {
"application/json": {
"example": {
"success": False,
"error_code": "RATE_LIMIT_EXCEEDED",
"message": "شما بیش از حد مجاز درخواست ارسال مجدد داده‌اید"
}
}
}
}
}
)
def resend_verification(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""ارسال مجدد ایمیل verification"""
user_id = ctx.get_user_id()
# ساخت base_url از request
base_url = None
if request.headers.get("X-Forwarded-Host"):
proto = request.headers.get("X-Forwarded-Proto", "https")
host = request.headers.get("X-Forwarded-Host")
base_url = f"{proto}://{host}"
elif request.url:
base_url = str(request.url).replace(request.url.path, "").rstrip("/")
resend_verification_email(db, user_id, base_url)
return success_response({}, request, message="VERIFICATION_EMAIL_SENT")
@router.post(
"/send-mobile-verification",
summary="ارسال کد تایید به شماره موبایل",
description="ارسال کد OTP 6 رقمی به شماره موبایل کاربر برای تایید",
response_model=SuccessResponse,
responses={
200: {
"description": "کد تایید با موفقیت ارسال شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کد تایید به شماره موبایل ارسال شد"
}
}
}
},
400: {
"description": "شماره موبایل نامعتبر یا SMS Provider پیکربندی نشده",
},
404: {
"description": "کاربر با این شماره موبایل یافت نشد",
},
429: {
"description": "تعداد درخواست‌ها بیش از حد مجاز",
},
503: {
"description": "سرویس پیامک پیکربندی نشده است",
}
}
)
@rate_limit(
max_requests=8,
window_seconds=3600,
key_func=lambda req: f"send_mobile_verif:{get_client_ip(req)}",
error_message="تعداد درخواست‌های ارسال کد تایید موبایل بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
)
def send_mobile_verification(
request: Request,
payload: SendMobileVerificationRequest,
db: Session = Depends(get_db)
) -> dict:
"""ارسال کد OTP به شماره موبایل کاربر (همراه کپچا در بدنه)"""
from app.services.mobile_verification_service import MobileVerificationService
from app.services.captcha_service import validate_captcha
from adapters.db.repositories.user_repo import UserRepository
from app.services.auth_service import _normalize_mobile
from app.core.responses import ApiError
if not validate_captcha(db, payload.captcha_id, payload.captcha_code, client_ip=get_client_ip(request)):
raise ApiError("INVALID_CAPTCHA", "کد کپچا نامعتبر است", http_status=400)
# نرمال‌سازی شماره موبایل (همان مسیری که در ثبت‌نام است)
normalized_mobile = _normalize_mobile(payload.mobile)
if not normalized_mobile:
raise ApiError("INVALID_MOBILE", "شماره موبایل نامعتبر است", http_status=400)
# پیدا کردن کاربر از شماره موبایل
user_repo = UserRepository(db)
user = user_repo.get_by_mobile(normalized_mobile)
if not user:
raise ApiError("USER_NOT_FOUND", "کاربری با این شماره موبایل یافت نشد", http_status=404)
service = MobileVerificationService(db)
try:
otp_code = service.create_mobile_verification(user.id, payload.mobile)
data = {"message": "کد تایید به شماره موبایل ارسال شد"}
data.update(_otp_in_response(otp_code))
return success_response(data, request)
except ApiError as e:
raise e
except Exception as e:
raise ApiError("SMS_SEND_FAILED", f"خطا در ارسال پیامک: {str(e)}", http_status=500)
@router.post(
"/verify-mobile",
summary="تایید شماره موبایل با کد OTP",
description="تایید شماره موبایل کاربر با استفاده از کد OTP ارسال شده",
response_model=SuccessResponse,
responses={
200: {
"description": "شماره موبایل با موفقیت تایید شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "شماره موبایل با موفقیت تایید شد",
"data": {
"user_id": 1,
"mobile_verified": True
}
}
}
}
},
400: {
"description": "کد OTP نامعتبر",
},
404: {
"description": "کد تایید یافت نشد یا منقضی شده است",
},
429: {
"description": "تعداد تلاش‌های مجاز به پایان رسیده است",
}
}
)
def verify_mobile(
request: Request,
otp_code: str = Query(..., description="کد OTP 6 رقمی"),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""تایید شماره موبایل با کد OTP"""
from app.services.mobile_verification_service import MobileVerificationService
from adapters.db.models.user import User
service = MobileVerificationService(db)
user_id = ctx.get_user_id()
service.verify_mobile_otp(user_id, otp_code)
# به‌روزرسانی اطلاعات کاربر
user = db.get(User, user_id)
response_data = {
"user_id": user_id,
"mobile_verified": user.mobile_verified if user else False
}
return success_response(response_data, request, message="MOBILE_VERIFIED")
@router.post(
"/resend-mobile-verification",
summary="ارسال مجدد کد تایید موبایل",
description="ارسال مجدد کد OTP به شماره موبایل کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "کد تایید مجدداً ارسال شد",
},
400: {
"description": "کاربر شماره موبایل ثبت نکرده است",
},
429: {
"description": "تعداد درخواست‌ها بیش از حد مجاز",
}
}
)
@rate_limit(
max_requests=10,
window_seconds=3600,
key_func=lambda req: f"resend_mobile_verif:{get_client_ip(req)}",
error_message="تعداد درخواست‌های ارسال مجدد کد بیش از حد مجاز است. لطفاً بعداً تلاش کنید.",
)
def resend_mobile_verification(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""ارسال مجدد کد تایید موبایل"""
from app.services.mobile_verification_service import MobileVerificationService
service = MobileVerificationService(db)
user_id = ctx.get_user_id()
otp_code = service.resend_otp(user_id)
data = {"message": "کد تایید مجدداً ارسال شد"}
data.update(_otp_in_response(otp_code))
return success_response(data, request)
@router.post(
"/update-mobile",
summary="تغییر شماره موبایل کاربر",
description="تغییر شماره موبایل کاربر با تایید کپچا و بررسی تایید شده بودن توسط کاربر دیگر",
response_model=SuccessResponse,
responses={
200: {
"description": "شماره موبایل با موفقیت به‌روزرسانی شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "شماره موبایل با موفقیت به‌روزرسانی شد",
"data": {
"mobile": "09123456789",
"mobile_verified": False
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی یا تایید شده بودن توسط کاربر دیگر",
"content": {
"application/json": {
"example": {
"success": False,
"error_code": "MOBILE_IN_USE_VERIFIED",
"message": "این شماره موبایل قبلاً توسط کاربر دیگری ثبت و تایید شده است"
}
}
}
},
429: {
"description": "تعداد درخواست‌ها بیش از حد مجاز"
}
}
)
def update_mobile(
request: Request,
payload: UpdateMobileRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""تغییر شماره موبایل کاربر"""
from app.services.auth_service import update_user_mobile
user_id = ctx.get_user_id()
try:
result = update_user_mobile(
db=db,
user_id=user_id,
mobile=payload.mobile,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
force_unverified=payload.force_unverified,
send_verification_sms=payload.send_verification_sms,
client_ip=get_client_ip(request),
)
return success_response(
result,
request,
message="شماره موبایل با موفقیت به‌روزرسانی شد"
)
except Exception as e:
# خطاها از طریق ApiError برمی‌گردند
raise
@router.post(
"/update-email",
summary="تغییر ایمیل کاربر",
description="تغییر ایمیل کاربر با تایید کپچا و بررسی تایید شده بودن توسط کاربر دیگر",
response_model=SuccessResponse,
responses={
200: {
"description": "ایمیل با موفقیت به‌روزرسانی شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ایمیل با موفقیت به‌روزرسانی شد",
"data": {
"email": "newemail@example.com",
"email_verified": False
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی یا تایید شده بودن توسط کاربر دیگر",
"content": {
"application/json": {
"example": {
"success": False,
"error_code": "EMAIL_IN_USE_VERIFIED",
"message": "این ایمیل قبلاً توسط کاربر دیگری ثبت و تایید شده است"
}
}
}
},
429: {
"description": "تعداد درخواست‌ها بیش از حد مجاز"
}
}
)
def update_email(
request: Request,
payload: UpdateEmailRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""تغییر ایمیل کاربر"""
from app.services.auth_service import update_user_email
user_id = ctx.get_user_id()
try:
result = update_user_email(
db=db,
user_id=user_id,
email=payload.email,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
force_unverified=payload.force_unverified,
client_ip=get_client_ip(request),
)
return success_response(
result,
request,
message="ایمیل با موفقیت به‌روزرسانی شد"
)
except Exception as e:
# خطاها از طریق ApiError برمی‌گردند
raise
@router.get(
"/login/otp-channel-status",
summary="وضعیت پیکربندی کانال‌های OTP ورود",
description="نمایش اینکه هر کانال (پیامک، ایمیل، تلگرام، بله) روی سرور پیکربندی شده یا نه — بدون اطلاع از کاربر.",
response_model=SuccessResponse,
)
@rate_limit(
max_requests=40,
window_seconds=60,
key_func=lambda req: f"login_otp_channel_status:{get_client_ip(req)}",
error_message="تعداد درخواست‌ها بیش از حد مجاز است. لطفاً کمی صبر کنید.",
)
def get_otp_channel_status(request: Request, db: Session = Depends(get_db)) -> dict:
from app.services.otp_login_service import OtpLoginService
service = OtpLoginService(db)
return success_response(service.get_otp_channel_status_flags(), request)
@router.post(
"/login/available-channels",
summary="دریافت کانال‌های در دسترس برای ورود با OTP",
description=(
"دریافت فهرست کانال‌های اولیه (فقط SMS یا ایمیل) بر اساس فرمت شناسه، "
"بدون افشای وجود کاربر. نیاز به کپتچای معتبر دارد."
),
response_model=SuccessResponse,
)
@rate_limit(
max_requests=12,
window_seconds=300,
key_func=lambda req: f"login_otp_channels:{get_client_ip(req)}",
error_message="تعداد درخواست‌های دریافت کانال ورود بیش از حد مجاز است. لطفاً چند دقیقه بعد دوباره تلاش کنید.",
)
def post_login_available_channels(
request: Request,
payload: AvailableChannelsRequest,
db: Session = Depends(get_db)
) -> dict:
"""کانال‌های قابل انتخاب قبل از ارسال OTP (ضد user enumeration)."""
from app.services.otp_login_service import OtpLoginService
if not validate_captcha(db, payload.captcha_id, payload.captcha_code, client_ip=get_client_ip(request)):
raise ApiError("INVALID_CAPTCHA", "کد کپتچا نامعتبر است", http_status=400)
service = OtpLoginService(db)
channels_info = service.get_public_otp_channel_options(payload.identifier)
return success_response(channels_info, request)
@router.post(
"/login/send-otp",
summary="ارسال OTP برای ورود",
description="ارسال کد OTP 6 رقمی به ایمیل، شماره موبایل یا تلگرام برای ورود بدون نیاز به رمز عبور",
response_model=SuccessResponse,
responses={
200: {
"description": "کد OTP با موفقیت ارسال شد",
"content": {
"application/json": {
"example": {
"success": True,
"data": {
"session_id": "session_id_here",
"message": "کد ورود ارسال شد",
"available_channels": ["sms", "email", "telegram"]
}
}
}
}
},
400: {
"description": "شناسه، کانال یا کپتچا نامعتبر",
},
429: {
"description": "تعداد درخواست‌ها بیش از حد مجاز",
},
503: {
"description": "سرویس مورد نظر پیکربندی نشده است",
}
}
)
@rate_limit(
max_requests=5,
window_seconds=300,
key_func=lambda req: f"login_otp:{get_client_ip(req)}",
error_message="تعداد درخواست‌های ارسال کد ورود بیش از حد مجاز است. لطفاً چند دقیقه بعد دوباره تلاش کنید."
)
def send_login_otp(
request: Request,
payload: SendLoginOtpRequest,
db: Session = Depends(get_db)
) -> dict:
"""ارسال OTP برای ورود"""
from app.services.otp_login_service import OtpLoginService
from app.services.captcha_service import validate_captcha
from app.core.responses import ApiError
# تایید کپتچا
if not validate_captcha(db, payload.captcha_id, payload.captcha_code, client_ip=get_client_ip(request)):
raise ApiError("INVALID_CAPTCHA", "کد کپتچا نامعتبر است", http_status=400)
service = OtpLoginService(db)
# دریافت IP و User Agent
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent")
try:
from app.services.otp_login_service import OTP_LOGIN_CHANNEL_PUBLIC_MESSAGE
success, new_session_id, channels_info = service.send_login_otp(
identifier=payload.identifier,
channel=payload.channel,
ip_address=ip_address,
user_agent=user_agent,
session_id=payload.session_id
)
if not success:
raise ApiError("SEND_FAILED", OTP_LOGIN_CHANNEL_PUBLIC_MESSAGE, http_status=400)
channel_messages = {
"sms": "کد ورود به شماره موبایل ارسال شد",
"email": "کد ورود به ایمیل ارسال شد",
"telegram": "کد ورود به تلگرام ارسال شد",
"bale": "کد ورود به بله ارسال شد"
}
return success_response({
"session_id": new_session_id,
"message": channel_messages.get(payload.channel, "کد ورود ارسال شد"),
"available_channels": channels_info.get("available_channels", [])
}, request)
except ApiError as e:
raise e
except Exception as e:
raise ApiError("OTP_SEND_FAILED", f"خطا در ارسال OTP: {str(e)}", http_status=500)
@router.post(
"/login/verify-otp",
summary="تایید OTP و ورود",
description="تایید کد OTP و ورود کاربر به سیستم",
response_model=SuccessResponse,
responses={
200: {
"description": "ورود با موفقیت انجام شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ورود با موفقیت انجام شد",
"data": {
"api_key": "sk_1234567890abcdef",
"expires_at": "2024-01-02T00:00:00Z",
"user": {
"id": 1,
"first_name": "احمد",
"last_name": "احمدی",
"email": "ahmad@example.com",
"mobile": "09123456789"
}
}
}
}
}
},
400: {
"description": "کد OTP نامعتبر",
},
404: {
"description": "Session یا کاربر یافت نشد",
},
429: {
"description": "تعداد تلاش‌های مجاز به پایان رسیده است",
}
}
)
@rate_limit(
max_requests=10,
window_seconds=600,
key_func=lambda req: f"verify_login_otp:{get_client_ip(req)}",
error_message="تعداد تلاش‌های تایید کد ورود بیش از حد مجاز است. لطفاً بعداً تلاش کنید."
)
def verify_login_otp(
request: Request,
session_id: str = Query(..., description="شناسه session"),
otp_code: str = Query(..., description="کد OTP 6 رقمی"),
device_id: str = Query(None, description="شناسه دستگاه (اختیاری)"),
db: Session = Depends(get_db)
) -> dict:
"""تایید OTP و ورود"""
from app.services.otp_login_service import OtpLoginService
from app.core.responses import ApiError
service = OtpLoginService(db)
# دریافت IP و User Agent
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("User-Agent")
try:
success, user_data, api_key = service.verify_login_otp(
session_id=session_id,
otp_code=otp_code,
device_id=device_id,
user_agent=user_agent,
ip=ip_address
)
if not success or not user_data or not api_key:
raise ApiError("OTP_VERIFICATION_FAILED", "تایید OTP ناموفق بود", http_status=400)
response_data = {
"api_key": api_key,
"expires_at": None, # اگر expires_at نیاز باشد، باید از service برگردانده شود
"user": user_data
}
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request, message="LOGIN_SUCCESS")
except ApiError as e:
raise e
except Exception as e:
raise ApiError("LOGIN_FAILED", f"خطا در ورود: {str(e)}", http_status=500)