Files
bakery-ia/services/auth/app/api/auth_operations.py

658 lines
24 KiB
Python
Raw Normal View History

"""
2026-01-15 20:45:49 +01:00
Refactored Auth Operations with proper 3DS/3DS2 support
Implements SetupIntent-first architecture for secure registration flows
"""
2026-01-15 20:45:49 +01:00
import logging
2025-10-27 16:33:26 +01:00
from typing import Dict, Any
2026-01-15 20:45:49 +01:00
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
2025-10-27 16:33:26 +01:00
from app.models.users import User
2026-01-15 20:45:49 +01:00
from shared.exceptions.auth_exceptions import (
UserCreationError,
RegistrationError,
PaymentOrchestrationError
)
2026-01-16 15:19:34 +01:00
from shared.auth.decorators import get_current_user_dep
2025-10-06 15:27:01 +02:00
2026-01-15 20:45:49 +01:00
# Configure logging
logger = logging.getLogger(__name__)
2025-10-06 15:27:01 +02:00
2026-01-15 20:45:49 +01:00
# Create router
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
2025-10-06 15:27:01 +02:00
2026-01-15 20:45:49 +01:00
async def get_auth_service() -> AuthService:
"""Dependency injection for auth service"""
return auth_service
2025-10-06 15:27:01 +02:00
2026-01-15 20:45:49 +01:00
@router.post("/start-registration",
response_model=Dict[str, Any],
summary="Start secure registration with payment verification")
async def start_registration(
2026-01-13 22:22:38 +01:00
user_data: UserRegistration,
request: Request,
2026-01-15 20:45:49 +01:00
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
2026-01-13 22:22:38 +01:00
"""
2026-01-15 20:45:49 +01:00
Start secure registration flow with SetupIntent-first approach
2026-01-13 22:22:38 +01:00
2026-01-16 16:09:32 +01:00
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
2026-01-14 13:15:48 +01:00
2026-01-16 16:09:32 +01:00
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.
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
Args:
user_data: User registration data with payment info
Returns:
2026-01-16 16:09:32 +01:00
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
2026-01-15 20:45:49 +01:00
Raises:
HTTPException: 400 for validation errors, 500 for server errors
2026-01-13 22:22:38 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
logger.info(f"Starting secure registration flow, email={user_data.email}, plan={user_data.subscription_plan}")
# Validate required fields
2026-01-13 22:22:38 +01:00
if not user_data.email or not user_data.email.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
2026-01-15 20:45:49 +01:00
2026-01-13 22:22:38 +01:00
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"
)
2026-01-15 20:45:49 +01:00
2026-01-13 22:22:38 +01:00
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"
)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
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"
)
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
# Start secure registration flow
result = await auth_service.start_secure_registration_flow(user_data)
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
# 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
}
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
return {
"requires_action": False,
2026-01-16 16:09:32 +01:00
"setup_intent_id": result.get('setup_intent_id'),
2026-01-15 20:45:49 +01:00
"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}",
2026-01-14 13:15:48 +01:00
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2026-01-15 20:45:49 +01:00
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
2026-01-13 22:22:38 +01:00
except Exception as e:
2026-01-15 20:45:49 +01:00
logger.error(f"Unexpected registration error: {str(e)}, email: {user_data.email}",
exc_info=True)
2026-01-13 22:22:38 +01:00
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2026-01-15 20:45:49 +01:00
detail=f"Registration error: {str(e)}"
) from e
2026-01-13 22:22:38 +01:00
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
@router.post("/complete-registration",
response_model=Dict[str, Any],
2026-01-16 16:09:32 +01:00
summary="Complete registration after SetupIntent verification")
2026-01-15 20:45:49 +01:00
async def complete_registration(
verification_data: Dict[str, Any],
request: Request,
2026-01-15 20:45:49 +01:00
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
2026-01-16 16:09:32 +01:00
Complete registration after frontend confirms SetupIntent
2026-01-15 20:45:49 +01:00
2026-01-16 16:09:32 +01:00
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.
2026-01-15 20:45:49 +01:00
Args:
2026-01-16 16:09:32 +01:00
verification_data: Must contain:
- setup_intent_id: Verified SetupIntent ID
- user_data: Original user registration data
2026-01-15 20:45:49 +01:00
Returns:
2026-01-16 16:09:32 +01:00
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
2026-01-15 20:45:49 +01:00
Raises:
2026-01-16 16:09:32 +01:00
HTTPException: 400 if setup_intent_id is missing, 500 for server errors
2026-01-15 20:45:49 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
# Validate required fields
if not verification_data.get('setup_intent_id'):
2025-07-26 22:03:55 +02:00
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
2026-01-15 20:45:49 +01:00
detail="SetupIntent ID is required"
2025-07-26 22:03:55 +02:00
)
2026-01-15 20:45:49 +01:00
if not verification_data.get('user_data'):
2025-07-26 22:03:55 +02:00
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
2026-01-15 20:45:49 +01:00
detail="User data is required"
2025-07-26 22:03:55 +02:00
)
2026-01-15 20:45:49 +01:00
# 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
)
2026-01-15 20:45:49 +01:00
logger.info(f"Registration completed successfully after 3DS, user_id={result['user'].id}, email={result['user'].email}, subscription_id={result.get('subscription_id')}")
2025-07-26 20:04:24 +02:00
return {
2026-01-15 20:45:49 +01:00
"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"
2025-07-26 20:04:24 +02:00
}
2025-10-06 15:27:01 +02:00
2026-01-15 20:45:49 +01:00
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)
2025-08-08 09:08:41 +02:00
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2026-01-15 20:45:49 +01:00
detail=f"Registration completion failed: {str(e)}"
) from e
except Exception as e:
2026-01-15 20:45:49 +01:00
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,
2026-01-15 20:45:49 +01:00
detail=f"Registration completion error: {str(e)}"
) from e
2025-07-20 08:33:23 +02:00
2026-01-15 20:45:49 +01:00
@router.post("/login",
response_model=Dict[str, Any],
summary="User login with subscription validation")
async def login(
login_data: UserLogin,
2026-01-14 13:15:48 +01:00
request: Request,
2026-01-15 20:45:49 +01:00
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
2026-01-14 13:15:48 +01:00
"""
2026-01-15 20:45:49 +01:00
User login endpoint with subscription validation
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
This endpoint:
1. Validates user credentials
2. Checks if user has active subscription (if required)
3. Returns authentication tokens
4. Updates last login timestamp
2026-01-14 13:15:48 +01:00
Args:
2026-01-15 20:45:49 +01:00
login_data: User login credentials (email and password)
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Authentication tokens and user information
2026-01-14 13:15:48 +01:00
Raises:
2026-01-15 20:45:49 +01:00
HTTPException: 401 for invalid credentials, 403 for subscription issues
2026-01-14 13:15:48 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
logger.info(f"Login attempt, email={login_data.email}")
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Validate required fields
if not login_data.email or not login_data.email.strip():
2026-01-14 13:15:48 +01:00
raise HTTPException(
2026-01-15 20:45:49 +01:00
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
2026-01-14 13:15:48 +01:00
)
2026-01-15 20:45:49 +01:00
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"
)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Call auth service to perform login
result = await auth_service.login_user(login_data)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
logger.info(f"Login successful, email={login_data.email}, user_id={result['user'].id}")
2026-01-14 13:15:48 +01:00
2026-01-16 15:19:34 +01:00
# Extract tokens from result for top-level response
tokens = result.get('tokens', {})
2026-01-15 20:45:49 +01:00
return {
"success": True,
2026-01-16 15:19:34 +01:00
"access_token": tokens.get('access_token'),
"refresh_token": tokens.get('refresh_token'),
"token_type": tokens.get('token_type'),
"expires_in": tokens.get('expires_in'),
2026-01-15 20:45:49 +01:00
"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"
}
2026-01-14 13:15:48 +01:00
except HTTPException:
2026-01-15 20:45:49 +01:00
# Re-raise HTTP exceptions (like 401 for invalid credentials)
2026-01-14 13:15:48 +01:00
raise
except Exception as e:
2026-01-15 20:45:49 +01:00
logger.error(f"Login failed: {str(e)}, email: {login_data.email}",
2026-01-14 13:15:48 +01:00
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2026-01-15 20:45:49 +01:00
detail=f"Login failed: {str(e)}"
) from e
2026-01-14 13:15:48 +01:00
2026-01-16 15:19:34 +01:00
# ============================================================================
# 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