2025-07-17 13:09:24 +02:00
|
|
|
"""
|
2026-01-15 20:45:49 +01:00
|
|
|
Refactored Auth Operations with proper 3DS/3DS2 support
|
|
|
|
|
Implements SetupIntent-first architecture for secure registration flows
|
2025-07-17 13:09:24 +02:00
|
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
2025-07-17 13:09:24 +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],
|
2025-07-17 13:09:24 +02:00
|
|
|
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
|
|
|
"""
|
2025-07-17 13:09:24 +02: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
|
2025-07-17 13:09:24 +02:00
|
|
|
)
|
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
|
2025-07-17 13:09:24 +02:00
|
|
|
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)
|
2025-07-17 13:09:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|