""" 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 ) from shared.auth.decorators import get_current_user_dep # 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 atomic registration architecture: 1. Creates Stripe customer via tenant service 2. Creates SetupIntent with confirm=True 3. Returns SetupIntent data to frontend IMPORTANT: NO subscription or user is created in this step! Two possible outcomes: - requires_action=True: 3DS required, frontend must confirm SetupIntent then call complete-registration - requires_action=False: No 3DS required, but frontend STILL must call complete-registration In BOTH cases, the frontend must call complete-registration to create the subscription and user. This ensures consistent flow and prevents duplicate subscriptions. Args: user_data: User registration data with payment info Returns: SetupIntent result with: - requires_action: True if 3DS required, False if not - setup_intent_id: SetupIntent ID for verification - client_secret: For 3DS authentication (when requires_action=True) - customer_id: Stripe customer ID - Other SetupIntent metadata 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, "setup_intent_id": result.get('setup_intent_id'), "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 SetupIntent 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 This is the SECOND step in the atomic registration architecture: 1. Called after frontend confirms SetupIntent (with or without 3DS) 2. Verifies SetupIntent status with Stripe 3. Creates subscription with verified payment method (FIRST time subscription is created) 4. Creates user record in auth database 5. Saves onboarding progress 6. Generates auth tokens for auto-login This endpoint is called in TWO scenarios: 1. After user completes 3DS authentication (requires_action=True flow) 2. Immediately after start-registration (requires_action=False flow) In BOTH cases, this is where the subscription and user are actually created. This ensures consistent flow and prevents duplicate subscriptions. Args: verification_data: Must contain: - setup_intent_id: Verified SetupIntent ID - user_data: Original user registration data Returns: Complete registration result with: - user: Created user data - subscription_id: Created subscription ID - payment_customer_id: Stripe customer ID - access_token: JWT access token - refresh_token: JWT refresh token Raises: HTTPException: 400 if setup_intent_id is missing, 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}") # Extract tokens from result for top-level response tokens = result.get('tokens', {}) return { "success": True, "access_token": tokens.get('access_token'), "refresh_token": tokens.get('refresh_token'), "token_type": tokens.get('token_type'), "expires_in": tokens.get('expires_in'), "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 }, "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 # ============================================================================ # TOKEN MANAGEMENT ENDPOINTS - NEWLY ADDED # ============================================================================ @router.post("/refresh", response_model=Dict[str, Any], summary="Refresh access token using refresh token") async def refresh_token( request: Request, refresh_data: Dict[str, Any], auth_service: AuthService = Depends(get_auth_service) ) -> Dict[str, Any]: """ Refresh access token using a valid refresh token This endpoint: 1. Validates the refresh token 2. Generates new access and refresh tokens 3. Returns the new tokens Args: refresh_data: Dictionary containing refresh_token Returns: New authentication tokens Raises: HTTPException: 401 for invalid refresh tokens """ try: logger.info("Token refresh request initiated") # Extract refresh token from request refresh_token = refresh_data.get("refresh_token") if not refresh_token: logger.warning("Refresh token missing from request") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Refresh token is required" ) # Use service layer to refresh tokens tokens = await auth_service.refresh_auth_tokens(refresh_token) logger.info("Token refresh successful via service layer") return { "success": True, "access_token": tokens.get("access_token"), "refresh_token": tokens.get("refresh_token"), "token_type": "bearer", "expires_in": 1800, # 30 minutes "message": "Token refresh successful" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Token refresh failed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Token refresh failed: {str(e)}" ) from e @router.post("/verify", response_model=Dict[str, Any], summary="Verify token validity") async def verify_token( request: Request, token_data: Dict[str, Any] ) -> Dict[str, Any]: """ Verify the validity of an access token Args: token_data: Dictionary containing access_token Returns: Token validation result """ try: logger.info("Token verification request initiated") # Extract access token from request access_token = token_data.get("access_token") if not access_token: logger.warning("Access token missing from verification request") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Access token is required" ) # Use service layer to verify token result = await auth_service.verify_access_token(access_token) logger.info("Token verification successful via service layer") return { "success": True, "valid": result.get("valid"), "user_id": result.get("user_id"), "email": result.get("email"), "message": "Token is valid" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Token verification failed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Token verification failed: {str(e)}" ) from e @router.post("/logout", response_model=Dict[str, Any], summary="Logout and revoke refresh token") async def logout( request: Request, logout_data: Dict[str, Any], auth_service: AuthService = Depends(get_auth_service) ) -> Dict[str, Any]: """ Logout user and revoke refresh token Args: logout_data: Dictionary containing refresh_token Returns: Logout confirmation """ try: logger.info("Logout request initiated") # Extract refresh token from request refresh_token = logout_data.get("refresh_token") if not refresh_token: logger.warning("Refresh token missing from logout request") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Refresh token is required" ) # Use service layer to revoke refresh token try: await auth_service.revoke_refresh_token(refresh_token) logger.info("Logout successful via service layer") return { "success": True, "message": "Logout successful" } except Exception as e: logger.error(f"Error during logout: {str(e)}") # Don't fail logout if revocation fails return { "success": True, "message": "Logout successful (token revocation failed but user logged out)" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Logout failed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Logout failed: {str(e)}" ) from e @router.post("/change-password", response_model=Dict[str, Any], summary="Change user password") async def change_password( request: Request, password_data: Dict[str, Any], auth_service: AuthService = Depends(get_auth_service) ) -> Dict[str, Any]: """ Change user password Args: password_data: Dictionary containing current_password and new_password Returns: Password change confirmation """ try: logger.info("Password change request initiated") # Extract user from request state if not hasattr(request.state, 'user') or not request.state.user: logger.warning("Unauthorized password change attempt - no user context") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) user_id = request.state.user.get("user_id") if not user_id: logger.warning("Unauthorized password change attempt - no user_id") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user context" ) # Extract password data current_password = password_data.get("current_password") new_password = password_data.get("new_password") if not current_password or not new_password: logger.warning("Password change missing required fields") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Current password and new password are required" ) if len(new_password) < 8: logger.warning("New password too short") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be at least 8 characters long" ) # Use service layer to change password await auth_service.change_user_password(user_id, current_password, new_password) logger.info(f"Password change successful via service layer, user_id={user_id}") return { "success": True, "message": "Password changed successfully" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Password change failed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Password change failed: {str(e)}" ) from e @router.post("/verify-email", response_model=Dict[str, Any], summary="Verify user email") async def verify_email( request: Request, email_data: Dict[str, Any], auth_service: AuthService = Depends(get_auth_service) ) -> Dict[str, Any]: """ Verify user email (placeholder implementation) Args: email_data: Dictionary containing email and verification_token Returns: Email verification confirmation """ try: logger.info("Email verification request initiated") # Extract email and token email = email_data.get("email") verification_token = email_data.get("verification_token") if not email or not verification_token: logger.warning("Email verification missing required fields") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email and verification token are required" ) # Use service layer to verify email await auth_service.verify_user_email(email, verification_token) logger.info("Email verification successful via service layer") return { "success": True, "message": "Email verified successfully" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Email verification failed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Email verification failed: {str(e)}" ) from e