320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""
|
|
Refactored Auth Operations with proper 3DS/3DS2 support
|
|
Implements SetupIntent-first architecture for secure registration flows
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, Any
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from app.services.auth_service import auth_service, AuthService
|
|
from app.schemas.auth import UserRegistration, UserLogin, UserResponse
|
|
from app.models.users import User
|
|
from shared.exceptions.auth_exceptions import (
|
|
UserCreationError,
|
|
RegistrationError,
|
|
PaymentOrchestrationError
|
|
)
|
|
|
|
# Configure logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Create router
|
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|
|
|
|
|
async def get_auth_service() -> AuthService:
|
|
"""Dependency injection for auth service"""
|
|
return auth_service
|
|
|
|
|
|
@router.post("/start-registration",
|
|
response_model=Dict[str, Any],
|
|
summary="Start secure registration with payment verification")
|
|
async def start_registration(
|
|
user_data: UserRegistration,
|
|
request: Request,
|
|
auth_service: AuthService = Depends(get_auth_service)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Start secure registration flow with SetupIntent-first approach
|
|
|
|
This is the FIRST step in the new registration architecture:
|
|
1. Creates payment customer
|
|
2. Attaches payment method
|
|
3. Creates SetupIntent for verification
|
|
4. Returns SetupIntent to frontend for 3DS handling
|
|
|
|
If 3DS is required, frontend must confirm SetupIntent and call complete-registration
|
|
If no 3DS required, user is created immediately and registration completes
|
|
|
|
Args:
|
|
user_data: User registration data with payment info
|
|
|
|
Returns:
|
|
Registration result (may require 3DS)
|
|
|
|
Raises:
|
|
HTTPException: 400 for validation errors, 500 for server errors
|
|
"""
|
|
try:
|
|
logger.info(f"Starting secure registration flow, email={user_data.email}, plan={user_data.subscription_plan}")
|
|
|
|
# Validate required fields
|
|
if not user_data.email or not user_data.email.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Email is required"
|
|
)
|
|
|
|
if not user_data.password or len(user_data.password) < 8:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Password must be at least 8 characters long"
|
|
)
|
|
|
|
if not user_data.full_name or not user_data.full_name.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Full name is required"
|
|
)
|
|
|
|
if user_data.subscription_plan and not user_data.payment_method_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Payment method ID is required for subscription registration"
|
|
)
|
|
|
|
# Start secure registration flow
|
|
result = await auth_service.start_secure_registration_flow(user_data)
|
|
|
|
# Check if 3DS is required
|
|
if result.get('requires_action', False):
|
|
logger.info(f"Registration requires 3DS verification, email={user_data.email}, setup_intent_id={result.get('setup_intent_id')}")
|
|
|
|
return {
|
|
"requires_action": True,
|
|
"action_type": "setup_intent_confirmation",
|
|
"client_secret": result.get('client_secret'),
|
|
"setup_intent_id": result.get('setup_intent_id'),
|
|
"customer_id": result.get('customer_id'),
|
|
"payment_customer_id": result.get('payment_customer_id'),
|
|
"plan_id": result.get('plan_id'),
|
|
"payment_method_id": result.get('payment_method_id'),
|
|
"billing_cycle": result.get('billing_cycle'),
|
|
"coupon_info": result.get('coupon_info'),
|
|
"trial_info": result.get('trial_info'),
|
|
"email": result.get('email'),
|
|
"message": "Payment verification required. Frontend must confirm SetupIntent to handle 3DS."
|
|
}
|
|
else:
|
|
user = result.get('user')
|
|
user_id = user.id if user else None
|
|
logger.info(f"Registration completed without 3DS, email={user_data.email}, user_id={user_id}, subscription_id={result.get('subscription_id')}")
|
|
|
|
# Return complete registration result
|
|
user_data_response = None
|
|
if user:
|
|
user_data_response = {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"full_name": user.full_name,
|
|
"is_active": user.is_active
|
|
}
|
|
|
|
return {
|
|
"requires_action": False,
|
|
"user": user_data_response,
|
|
"subscription_id": result.get('subscription_id'),
|
|
"payment_customer_id": result.get('payment_customer_id'),
|
|
"status": result.get('status'),
|
|
"message": "Registration completed successfully"
|
|
}
|
|
|
|
except RegistrationError as e:
|
|
logger.error(f"Registration flow failed: {str(e)}, email: {user_data.email}",
|
|
exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Registration failed: {str(e)}"
|
|
) from e
|
|
except PaymentOrchestrationError as e:
|
|
logger.error(f"Payment orchestration failed: {str(e)}, email: {user_data.email}",
|
|
exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Payment setup failed: {str(e)}"
|
|
) from e
|
|
except Exception as e:
|
|
logger.error(f"Unexpected registration error: {str(e)}, email: {user_data.email}",
|
|
exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Registration error: {str(e)}"
|
|
) from e
|
|
|
|
|
|
@router.post("/complete-registration",
|
|
response_model=Dict[str, Any],
|
|
summary="Complete registration after 3DS verification")
|
|
async def complete_registration(
|
|
verification_data: Dict[str, Any],
|
|
request: Request,
|
|
auth_service: AuthService = Depends(get_auth_service)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Complete registration after frontend confirms SetupIntent (3DS handled)
|
|
|
|
This is the SECOND step in the registration architecture:
|
|
1. Called after frontend confirms SetupIntent
|
|
2. Called after user completes 3DS authentication (if required)
|
|
3. Verifies SetupIntent status
|
|
4. Creates subscription with verified payment method
|
|
5. Creates user record
|
|
6. Saves onboarding progress
|
|
|
|
Args:
|
|
verification_data: SetupIntent verification data
|
|
|
|
Returns:
|
|
Complete registration result
|
|
|
|
Raises:
|
|
HTTPException: 400 for validation errors, 500 for server errors
|
|
"""
|
|
try:
|
|
# Validate required fields
|
|
if not verification_data.get('setup_intent_id'):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="SetupIntent ID is required"
|
|
)
|
|
|
|
if not verification_data.get('user_data'):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User data is required"
|
|
)
|
|
|
|
# Extract user data
|
|
user_data_dict = verification_data['user_data']
|
|
user_data = UserRegistration(**user_data_dict)
|
|
|
|
logger.info(f"Completing registration after SetupIntent verification, email={user_data.email}, setup_intent_id={verification_data['setup_intent_id']}")
|
|
|
|
# Complete registration with verified payment
|
|
result = await auth_service.complete_registration_with_verified_payment(
|
|
verification_data['setup_intent_id'],
|
|
user_data
|
|
)
|
|
|
|
logger.info(f"Registration completed successfully after 3DS, user_id={result['user'].id}, email={result['user'].email}, subscription_id={result.get('subscription_id')}")
|
|
|
|
return {
|
|
"success": True,
|
|
"user": {
|
|
"id": str(result['user'].id),
|
|
"email": result['user'].email,
|
|
"full_name": result['user'].full_name,
|
|
"is_active": result['user'].is_active,
|
|
"is_verified": result['user'].is_verified,
|
|
"created_at": result['user'].created_at.isoformat() if result['user'].created_at else None,
|
|
"role": result['user'].role
|
|
},
|
|
"subscription_id": result.get('subscription_id'),
|
|
"payment_customer_id": result.get('payment_customer_id'),
|
|
"status": result.get('status'),
|
|
"access_token": result.get('access_token'),
|
|
"refresh_token": result.get('refresh_token'),
|
|
"message": "Registration completed successfully after 3DS verification"
|
|
}
|
|
|
|
except RegistrationError as e:
|
|
logger.error(f"Registration completion after 3DS failed: {str(e)}, setup_intent_id: {verification_data.get('setup_intent_id')}, email: {user_data_dict.get('email') if user_data_dict else 'unknown'}",
|
|
exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Registration completion failed: {str(e)}"
|
|
) from e
|
|
except Exception as e:
|
|
logger.error(f"Unexpected registration completion error: {str(e)}, setup_intent_id: {verification_data.get('setup_intent_id')}",
|
|
exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Registration completion error: {str(e)}"
|
|
) from e
|
|
|
|
|
|
@router.post("/login",
|
|
response_model=Dict[str, Any],
|
|
summary="User login with subscription validation")
|
|
async def login(
|
|
login_data: UserLogin,
|
|
request: Request,
|
|
auth_service: AuthService = Depends(get_auth_service)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
User login endpoint with subscription validation
|
|
|
|
This endpoint:
|
|
1. Validates user credentials
|
|
2. Checks if user has active subscription (if required)
|
|
3. Returns authentication tokens
|
|
4. Updates last login timestamp
|
|
|
|
Args:
|
|
login_data: User login credentials (email and password)
|
|
|
|
Returns:
|
|
Authentication tokens and user information
|
|
|
|
Raises:
|
|
HTTPException: 401 for invalid credentials, 403 for subscription issues
|
|
"""
|
|
try:
|
|
logger.info(f"Login attempt, email={login_data.email}")
|
|
|
|
# Validate required fields
|
|
if not login_data.email or not login_data.email.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Email is required"
|
|
)
|
|
|
|
if not login_data.password or len(login_data.password) < 8:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Password must be at least 8 characters long"
|
|
)
|
|
|
|
# Call auth service to perform login
|
|
result = await auth_service.login_user(login_data)
|
|
|
|
logger.info(f"Login successful, email={login_data.email}, user_id={result['user'].id}")
|
|
|
|
return {
|
|
"success": True,
|
|
"user": {
|
|
"id": str(result['user'].id),
|
|
"email": result['user'].email,
|
|
"full_name": result['user'].full_name,
|
|
"is_active": result['user'].is_active,
|
|
"last_login": result['user'].last_login.isoformat() if result['user'].last_login else None
|
|
},
|
|
"tokens": result.get('tokens', {}),
|
|
"subscription": result.get('subscription', {}),
|
|
"message": "Login successful"
|
|
}
|
|
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions (like 401 for invalid credentials)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Login failed: {str(e)}, email: {login_data.email}",
|
|
exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Login failed: {str(e)}"
|
|
) from e
|
|
|
|
|