Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants. Co-authored-by: Cursor <cursoragent@cursor.com>
195 lines
7.3 KiB
Python
195 lines
7.3 KiB
Python
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import require_authenticated, require_platform_admin
|
|
from app.schemas.auth import (
|
|
AuthConfigResponse,
|
|
AuthSessionResponse,
|
|
HandoffRedeemRequest,
|
|
LoginUrlResponse,
|
|
MeResponse,
|
|
MobileAuthStartResponse,
|
|
MobileOtpRequest,
|
|
MobileOtpVerify,
|
|
MobileRegisterVerify,
|
|
OtpRequestResponse,
|
|
PasswordChangeRequest,
|
|
PasswordChangeResponse,
|
|
ProfileUpdateRequest,
|
|
RegisterCompleteResponse,
|
|
RegisterRequest,
|
|
TokenExchangeRequest,
|
|
TokenResponse,
|
|
VerifyMobileResponse,
|
|
)
|
|
from app.services.auth_service import AuthService
|
|
from app.services.handoff_store import handoff_store
|
|
from app.services.mobile_auth_service import MobileAuthService
|
|
from app.services.registration_service import RegistrationService
|
|
from shared.exceptions import NotFoundError, UnauthorizedError
|
|
from shared.security import CurrentUser
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
@router.get("/config", response_model=AuthConfigResponse)
|
|
async def auth_config(db: AsyncSession = Depends(get_db)) -> AuthConfigResponse:
|
|
"""پیکربندی OIDC برای frontend — endpoint عمومی."""
|
|
return AuthService(db).get_oidc_config()
|
|
|
|
|
|
@router.get("/login-url", response_model=LoginUrlResponse)
|
|
async def login_url(db: AsyncSession = Depends(get_db)) -> LoginUrlResponse:
|
|
"""آدرس ورود Keycloak با state و PKCE سمت سرور — endpoint عمومی."""
|
|
return AuthService(db).build_login_url()
|
|
|
|
|
|
@router.post("/token", response_model=TokenResponse)
|
|
async def exchange_token(
|
|
payload: TokenExchangeRequest, db: AsyncSession = Depends(get_db)
|
|
) -> TokenResponse:
|
|
"""تبدیل authorization code به access token (BFF)."""
|
|
return await AuthService(db).exchange_token(payload)
|
|
|
|
|
|
@router.get("/me", response_model=MeResponse)
|
|
async def get_me(
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(require_authenticated),
|
|
) -> MeResponse:
|
|
return await AuthService(db).get_me(user)
|
|
|
|
|
|
@router.patch("/me", response_model=MeResponse)
|
|
async def update_me(
|
|
payload: ProfileUpdateRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(require_authenticated),
|
|
) -> MeResponse:
|
|
"""بهروزرسانی پروفایل کاربر جاری (نام نمایشی و ایمیل)."""
|
|
return await AuthService(db).update_profile(user, payload)
|
|
|
|
|
|
@router.post("/password", response_model=PasswordChangeResponse)
|
|
async def change_password(
|
|
payload: PasswordChangeRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(require_authenticated),
|
|
) -> PasswordChangeResponse:
|
|
"""تغییر یا تنظیم رمز عبور کاربر جاری."""
|
|
return await AuthService(db).change_password(user, payload)
|
|
|
|
|
|
@router.post("/register", response_model=OtpRequestResponse, status_code=201)
|
|
async def register_user(
|
|
payload: RegisterRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> OtpRequestResponse:
|
|
"""ثبتنام با ایمیل/نام کاربری/رمز + ارسال OTP موبایل."""
|
|
return await RegistrationService(db).register_with_credentials(payload)
|
|
|
|
|
|
@router.post("/register/verify-mobile", response_model=AuthSessionResponse)
|
|
async def verify_registration_mobile(
|
|
payload: MobileOtpVerify,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> AuthSessionResponse:
|
|
"""تکمیل ثبتنام پس از تأیید OTP (پس از register)."""
|
|
return await RegistrationService(db).complete_registration_verify(payload)
|
|
|
|
|
|
@router.post("/session/redeem", response_model=TokenResponse)
|
|
async def redeem_session_handoff(payload: HandoffRedeemRequest) -> TokenResponse:
|
|
"""تبدیل کد handoff (از Keycloak theme) به توکن SSO — یکبارمصرف."""
|
|
raw = handoff_store.pop(payload.handoff_code)
|
|
if raw is None:
|
|
raise UnauthorizedError("کد ورود منقضی یا نامعتبر است")
|
|
return TokenResponse(
|
|
access_token=raw["access_token"],
|
|
refresh_token=raw.get("refresh_token"),
|
|
expires_in=raw.get("expires_in", 3600),
|
|
token_type=raw.get("token_type", "Bearer"),
|
|
)
|
|
|
|
|
|
@router.post("/mobile/start", response_model=MobileAuthStartResponse)
|
|
async def mobile_auth_start(
|
|
payload: MobileOtpRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> MobileAuthStartResponse:
|
|
"""ورود/ثبتنام یکپارچه — بکاند تشخیص میدهد کاربر عضو است یا نه."""
|
|
return await MobileAuthService(db).start(payload)
|
|
|
|
|
|
@router.post("/mobile/complete", response_model=AuthSessionResponse)
|
|
async def mobile_auth_complete(
|
|
payload: MobileRegisterVerify,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> AuthSessionResponse:
|
|
"""تأیید OTP و ورود یا ثبتنام + صدور توکن SSO."""
|
|
return await MobileAuthService(db).complete(payload)
|
|
|
|
|
|
@router.post("/login/mobile", response_model=OtpRequestResponse)
|
|
async def login_mobile_request_otp(
|
|
payload: MobileOtpRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> OtpRequestResponse:
|
|
"""درخواست OTP برای ورود کاربر موجود."""
|
|
start = await MobileAuthService(db).start(payload)
|
|
if start.intent != "login":
|
|
raise NotFoundError("حسابی با این شماره یافت نشد. ابتدا ثبتنام کنید")
|
|
return OtpRequestResponse(
|
|
message=start.message,
|
|
expires_in=start.expires_in,
|
|
skipped=start.skipped,
|
|
)
|
|
|
|
|
|
@router.post("/login/mobile/verify", response_model=AuthSessionResponse)
|
|
async def login_mobile_verify(
|
|
payload: MobileOtpVerify,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> AuthSessionResponse:
|
|
"""ورود با OTP برای کاربر موجود."""
|
|
return await MobileAuthService(db).complete_login(payload)
|
|
|
|
|
|
@router.post("/register/mobile", response_model=OtpRequestResponse)
|
|
async def register_mobile_request_otp(
|
|
payload: MobileOtpRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> OtpRequestResponse:
|
|
"""ثبتنام فقط با موبایل — مرحله ارسال OTP."""
|
|
return await RegistrationService(db).request_mobile_register_otp(payload)
|
|
|
|
|
|
@router.post("/register/mobile/verify", response_model=AuthSessionResponse, status_code=201)
|
|
async def register_mobile_verify(
|
|
payload: MobileRegisterVerify,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> AuthSessionResponse:
|
|
"""ثبتنام فقط با موبایل — تأیید OTP و ساخت حساب."""
|
|
return await RegistrationService(db).complete_mobile_register(payload)
|
|
|
|
|
|
@router.post("/mobile/request", response_model=OtpRequestResponse)
|
|
async def request_mobile_verification(
|
|
payload: MobileOtpRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(require_authenticated),
|
|
) -> OtpRequestResponse:
|
|
"""ارسال OTP برای تأیید موبایل کاربر SSO (پس از لاگین)."""
|
|
return await RegistrationService(db).request_verify_mobile(user, payload.mobile)
|
|
|
|
|
|
@router.post("/mobile/verify", response_model=VerifyMobileResponse)
|
|
async def verify_mobile(
|
|
payload: MobileOtpVerify,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(require_authenticated),
|
|
) -> VerifyMobileResponse:
|
|
"""تأیید موبایل کاربر SSO."""
|
|
return await RegistrationService(db).verify_mobile_for_user(user, payload)
|