""" 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 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}") # 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