1139 lines
47 KiB
Python
1139 lines
47 KiB
Python
"""
|
|
Refactored Auth Service with proper separation of concerns
|
|
Focuses only on user management, delegates payment to tenant service
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, Any, Optional
|
|
from datetime import datetime
|
|
from uuid import UUID
|
|
from fastapi import HTTPException, status
|
|
from shared.clients.tenant_client import TenantServiceClient
|
|
from app.core.config import settings
|
|
from shared.exceptions.auth_exceptions import (
|
|
UserCreationError,
|
|
RegistrationError,
|
|
PaymentOrchestrationError
|
|
)
|
|
from app.core.security import SecurityManager
|
|
from app.repositories.user_repository import UserRepository
|
|
from app.repositories.onboarding_repository import OnboardingRepository
|
|
from app.models.users import User
|
|
from app.schemas.auth import UserRegistration, UserLogin
|
|
|
|
# Configure logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuthService:
|
|
"""
|
|
Auth Service focused solely on user management
|
|
Delegates all payment operations to Tenant Service
|
|
"""
|
|
|
|
def __init__(self, database_manager=None):
|
|
"""Initialize auth service"""
|
|
self.database_manager = database_manager
|
|
self.tenant_client = TenantServiceClient(settings)
|
|
|
|
async def register_user(self, user_data: UserRegistration) -> User:
|
|
"""
|
|
Atomic: Create user record (ONLY after payment verification)
|
|
|
|
Args:
|
|
user_data: User registration data
|
|
|
|
Returns:
|
|
Created user
|
|
|
|
Raises:
|
|
UserCreationError: If user creation fails
|
|
"""
|
|
try:
|
|
logger.info(f"Creating user record, email={user_data.email}")
|
|
|
|
# Create user in database
|
|
async with self.database_manager.get_session() as session:
|
|
user_repo = UserRepository(User, session)
|
|
|
|
# Hash the password before storing
|
|
from app.core.security import SecurityManager
|
|
hashed_password = SecurityManager.hash_password(user_data.password)
|
|
|
|
user = await user_repo.create_user({
|
|
"email": user_data.email,
|
|
"hashed_password": hashed_password,
|
|
"full_name": user_data.full_name,
|
|
"is_active": True,
|
|
"is_verified": False,
|
|
"role": user_data.role or "admin"
|
|
})
|
|
|
|
logger.info(f"User created successfully, user_id={user.id}, email={user.email}")
|
|
|
|
return user
|
|
|
|
except Exception as e:
|
|
logger.error(f"User creation failed, error={str(e)}, email={user_data.email}", exc_info=True)
|
|
raise UserCreationError(f"User creation failed: {str(e)}") from e
|
|
|
|
# The save_onboarding_progress method has been integrated into complete_registration_after_payment_verification
|
|
# to ensure proper transaction handling and avoid foreign key constraint violations
|
|
# See complete_registration_after_payment_verification method for the implementation
|
|
|
|
async def initiate_registration_payment_setup(
|
|
self,
|
|
user_data: UserRegistration
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Initiate payment setup via tenant service orchestration
|
|
This is the FIRST step in secure registration flow
|
|
|
|
Args:
|
|
user_data: User registration data
|
|
|
|
Returns:
|
|
Payment setup result from tenant service
|
|
|
|
Raises:
|
|
PaymentOrchestrationError: If payment setup fails
|
|
"""
|
|
try:
|
|
logger.info(f"Initiating registration payment setup via tenant service, email={user_data.email}, plan={user_data.subscription_plan}")
|
|
|
|
# Prepare user data for tenant service
|
|
user_data_for_tenant = {
|
|
"email": user_data.email,
|
|
"full_name": user_data.full_name,
|
|
"payment_method_id": user_data.payment_method_id,
|
|
"plan_id": user_data.subscription_plan or "professional",
|
|
"billing_cycle": user_data.billing_cycle or "monthly",
|
|
"coupon_code": user_data.coupon_code
|
|
}
|
|
|
|
# Call tenant service orchestration endpoint
|
|
result = await self.tenant_client.create_registration_payment_setup(user_data_for_tenant)
|
|
|
|
logger.info(f"Registration payment setup completed via tenant service, email={user_data.email}, requires_action={result.get('requires_action')}, setup_intent_id={result.get('setup_intent_id')}")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Registration payment setup via tenant service failed, error={str(e)}, email={user_data.email}", exc_info=True)
|
|
raise PaymentOrchestrationError(f"Payment setup failed: {str(e)}") from e
|
|
|
|
async def complete_registration_after_payment_verification(
|
|
self,
|
|
setup_intent_id: Optional[str],
|
|
user_data: UserRegistration,
|
|
payment_setup_result: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Complete registration after successful payment verification.
|
|
|
|
This is the CORE method that creates the subscription and user.
|
|
It is called by complete_registration_with_verified_payment in both scenarios:
|
|
1. After 3DS authentication (requires_action=True flow)
|
|
2. Immediately after start-registration (requires_action=False flow)
|
|
|
|
CRITICAL: This is the FIRST and ONLY place where:
|
|
- Subscription is created via tenant service
|
|
- User record is created in auth database
|
|
- Onboarding progress is saved
|
|
- Auth tokens are generated
|
|
|
|
This ensures that subscriptions are only created after SetupIntent verification,
|
|
preventing duplicate subscriptions and maintaining payment security.
|
|
|
|
Args:
|
|
setup_intent_id: Verified SetupIntent ID (must not be None)
|
|
user_data: User registration data
|
|
payment_setup_result: Optional payment setup result with customer_id etc.
|
|
|
|
Returns:
|
|
Complete registration result with:
|
|
- user: Created user data
|
|
- subscription_id: Created subscription ID
|
|
- payment_customer_id: Stripe customer ID
|
|
- status: Subscription status
|
|
- access_token: JWT access token
|
|
- refresh_token: JWT refresh token
|
|
|
|
Raises:
|
|
RegistrationError: If registration completion fails (e.g., missing setup_intent_id)
|
|
"""
|
|
try:
|
|
logger.info(f"Completing registration after verification, email={user_data.email}, setup_intent_id={setup_intent_id}")
|
|
|
|
if not setup_intent_id:
|
|
raise RegistrationError("SetupIntent ID is required for registration completion")
|
|
|
|
# Get customer_id and other data from payment_setup_result
|
|
customer_id = ""
|
|
payment_method_id = ""
|
|
trial_period_days = 0
|
|
|
|
if payment_setup_result:
|
|
customer_id = payment_setup_result.get('customer_id') or payment_setup_result.get('payment_customer_id', '')
|
|
payment_method_id = payment_setup_result.get('payment_method_id', '')
|
|
trial_period_days = payment_setup_result.get('trial_period_days', 0)
|
|
|
|
# Call tenant service to verify SetupIntent and CREATE subscription
|
|
subscription_result = await self.tenant_client.verify_and_complete_registration(
|
|
setup_intent_id,
|
|
{
|
|
"email": user_data.email,
|
|
"full_name": user_data.full_name,
|
|
"plan_id": user_data.subscription_plan or "professional",
|
|
"subscription_plan": user_data.subscription_plan or "professional",
|
|
"billing_cycle": user_data.billing_cycle or "monthly",
|
|
"billing_interval": user_data.billing_cycle or "monthly",
|
|
"coupon_code": user_data.coupon_code,
|
|
"customer_id": customer_id,
|
|
"payment_method_id": payment_method_id or user_data.payment_method_id,
|
|
"trial_period_days": trial_period_days
|
|
}
|
|
)
|
|
|
|
# Use a single database session for both user creation and onboarding progress
|
|
# to ensure proper transaction handling and avoid foreign key constraint violations
|
|
async with self.database_manager.get_session() as session:
|
|
# Step 2: Create user (ONLY after successful payment verification)
|
|
user_repo = UserRepository(User, session)
|
|
|
|
# Hash the password before storing
|
|
from app.core.security import SecurityManager
|
|
hashed_password = SecurityManager.hash_password(user_data.password)
|
|
|
|
user = await user_repo.create_user({
|
|
"email": user_data.email,
|
|
"hashed_password": hashed_password,
|
|
"full_name": user_data.full_name,
|
|
"is_active": True,
|
|
"is_verified": False,
|
|
"role": user_data.role or "admin"
|
|
})
|
|
|
|
# Step 3: Save onboarding progress with subscription data using the same session
|
|
onboarding_repo = OnboardingRepository(session)
|
|
|
|
progress_data = {
|
|
'user_id': str(user.id),
|
|
'email': user_data.email,
|
|
'full_name': user_data.full_name,
|
|
'subscription_plan': user_data.subscription_plan,
|
|
'billing_cycle': user_data.billing_cycle,
|
|
'payment_customer_id': subscription_result.get('payment_customer_id'),
|
|
'subscription_id': subscription_result.get('subscription_id'),
|
|
'payment_method_id': user_data.payment_method_id,
|
|
'coupon_code': user_data.coupon_code,
|
|
'registration_completed': subscription_result.get('subscription_id') is not None,
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
|
|
# Save user registration step using the same session
|
|
await onboarding_repo.upsert_user_step(
|
|
user_id=str(user.id),
|
|
step_name="user_registered",
|
|
completed=True,
|
|
step_data=progress_data
|
|
)
|
|
|
|
# Commit the transaction to ensure both records are persisted
|
|
await session.commit()
|
|
|
|
logger.info(f"Registration completed successfully, user_id={user.id}, email={user.email}, subscription_id={subscription_result.get('subscription_id')}")
|
|
|
|
# Generate auth tokens for auto-login after registration
|
|
tokens = await self._generate_auth_tokens(user)
|
|
|
|
return {
|
|
'user': user,
|
|
'subscription_id': subscription_result.get('subscription_id'),
|
|
'payment_customer_id': subscription_result.get('payment_customer_id'),
|
|
'status': subscription_result.get('status'),
|
|
'access_token': tokens.get('access_token'),
|
|
'refresh_token': tokens.get('refresh_token'),
|
|
'message': 'Registration completed successfully'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Registration completion after payment verification failed, error={str(e)}, email={user_data.email}, setup_intent_id={setup_intent_id}", exc_info=True)
|
|
raise RegistrationError(f"Registration completion failed: {str(e)}") from e
|
|
|
|
async def start_secure_registration_flow(
|
|
self,
|
|
user_data: UserRegistration
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Start secure registration flow with SetupIntent-first approach
|
|
|
|
This is the FIRST step in the atomic registration flow:
|
|
1. Creates Stripe customer
|
|
2. Creates SetupIntent with confirm=True
|
|
3. Returns SetupIntent data to frontend
|
|
|
|
IMPORTANT: NO subscription is created in this step!
|
|
Subscription creation happens in complete_registration_after_payment_verification
|
|
|
|
Two possible outcomes:
|
|
- requires_action=True: 3DS required, frontend must confirm SetupIntent
|
|
- requires_action=False: No 3DS required, but frontend STILL must call complete-registration
|
|
|
|
Args:
|
|
user_data: User registration data
|
|
|
|
Returns:
|
|
Registration flow result with SetupIntent data
|
|
- 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)
|
|
- Other SetupIntent metadata
|
|
|
|
Raises:
|
|
RegistrationError: If registration flow fails
|
|
"""
|
|
try:
|
|
logger.info(f"Starting secure registration flow, email={user_data.email}, plan={user_data.subscription_plan}")
|
|
|
|
# Step 1: Initiate payment setup via tenant service
|
|
payment_setup_result = await self.initiate_registration_payment_setup(user_data)
|
|
|
|
# Check if SetupIntent requires action (3DS)
|
|
if payment_setup_result.get('requires_action', False):
|
|
logger.info(f"Registration requires SetupIntent confirmation (3DS), email={user_data.email}, setup_intent_id={payment_setup_result.get('setup_intent_id')}")
|
|
|
|
# Return SetupIntent for frontend to handle 3DS
|
|
# Note: NO subscription exists yet - subscription is created after verification
|
|
return {
|
|
'requires_action': True,
|
|
'action_type': 'setup_intent_confirmation',
|
|
'client_secret': payment_setup_result.get('client_secret'),
|
|
'setup_intent_id': payment_setup_result.get('setup_intent_id'),
|
|
'customer_id': payment_setup_result.get('customer_id'),
|
|
'payment_customer_id': payment_setup_result.get('payment_customer_id'),
|
|
'plan_id': payment_setup_result.get('plan_id'),
|
|
'payment_method_id': payment_setup_result.get('payment_method_id'),
|
|
'billing_cycle': payment_setup_result.get('billing_cycle'),
|
|
'trial_period_days': payment_setup_result.get('trial_period_days', 0),
|
|
'coupon_code': payment_setup_result.get('coupon_code'),
|
|
'email': payment_setup_result.get('email'),
|
|
'message': 'Payment verification required. Frontend must confirm SetupIntent.'
|
|
}
|
|
else:
|
|
# No 3DS required - SetupIntent already succeeded
|
|
logger.info(f"Registration SetupIntent succeeded without 3DS, email={user_data.email}, setup_intent_id={payment_setup_result.get('setup_intent_id')}")
|
|
|
|
# Note: NO subscription created yet - frontend must call complete-registration
|
|
# This ensures consistent flow whether 3DS is required or not
|
|
return {
|
|
'requires_action': False,
|
|
'setup_intent_id': payment_setup_result.get('setup_intent_id'),
|
|
'customer_id': payment_setup_result.get('customer_id'),
|
|
'payment_customer_id': payment_setup_result.get('payment_customer_id'),
|
|
'plan_id': payment_setup_result.get('plan_id'),
|
|
'payment_method_id': payment_setup_result.get('payment_method_id'),
|
|
'billing_cycle': payment_setup_result.get('billing_cycle'),
|
|
'trial_period_days': payment_setup_result.get('trial_period_days', 0),
|
|
'coupon_code': payment_setup_result.get('coupon_code'),
|
|
'email': payment_setup_result.get('email'),
|
|
'message': 'SetupIntent succeeded without 3DS. Frontend must call complete-registration to create subscription.'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Secure registration flow failed, error={str(e)}, email={user_data.email}", exc_info=True)
|
|
raise RegistrationError(f"Secure registration flow failed: {str(e)}") from e
|
|
|
|
async def complete_registration_with_verified_payment(
|
|
self,
|
|
setup_intent_id: str,
|
|
user_data: UserRegistration
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Complete registration after frontend confirms SetupIntent
|
|
|
|
This is the SECOND step in the atomic registration flow and is called in TWO scenarios:
|
|
1. After user completes 3DS authentication (when requires_action=True)
|
|
2. Immediately after start-registration (when requires_action=False)
|
|
|
|
In BOTH cases, this method:
|
|
- Verifies the SetupIntent status with Stripe
|
|
- Calls tenant service to create the subscription (first time subscription is created)
|
|
- Creates the user record in auth database
|
|
- Saves onboarding progress
|
|
- Generates auth tokens for auto-login
|
|
|
|
This ensures a CONSISTENT flow whether 3DS is required or not:
|
|
Step 1: start-registration creates SetupIntent only (NO subscription yet)
|
|
Step 2: complete-registration verifies SetupIntent and creates subscription
|
|
|
|
Args:
|
|
setup_intent_id: SetupIntent ID that was confirmed (either by Stripe or frontend)
|
|
user_data: User registration data
|
|
|
|
Returns:
|
|
Complete registration result with user data, tokens, and subscription info
|
|
|
|
Raises:
|
|
RegistrationError: If completion fails
|
|
"""
|
|
try:
|
|
logger.info(f"Completing registration after SetupIntent verification, email={user_data.email}, setup_intent_id={setup_intent_id}")
|
|
|
|
# Complete registration after payment verification
|
|
result = await self.complete_registration_after_payment_verification(
|
|
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['subscription_id']}")
|
|
|
|
return {
|
|
'success': True,
|
|
'user': result['user'],
|
|
'subscription_id': result['subscription_id'],
|
|
'payment_customer_id': result['payment_customer_id'],
|
|
'status': result['status'],
|
|
'access_token': result.get('access_token'),
|
|
'refresh_token': result.get('refresh_token'),
|
|
'message': 'Registration completed successfully after 3DS verification'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Registration completion after 3DS failed, error={str(e)}, email={user_data.email}, setup_intent_id={setup_intent_id}", exc_info=True)
|
|
raise RegistrationError(f"Registration completion after 3DS failed: {str(e)}") from e
|
|
|
|
async def login_user(self, login_data: UserLogin) -> Dict[str, Any]:
|
|
"""
|
|
Perform user login with subscription validation
|
|
|
|
This method:
|
|
1. Validates user credentials
|
|
2. Checks if user has active subscription (if required)
|
|
3. Generates authentication tokens
|
|
4. Updates last login timestamp
|
|
|
|
Args:
|
|
login_data: User login credentials (email and password)
|
|
|
|
Returns:
|
|
Dictionary with user information, tokens, and subscription details
|
|
|
|
Raises:
|
|
HTTPException: 401 for invalid credentials, 403 for subscription issues
|
|
"""
|
|
try:
|
|
logger.info(f"Processing login request, email={login_data.email}")
|
|
|
|
# Validate user credentials using repository
|
|
async with self.database_manager.get_session() as session:
|
|
user_repo = UserRepository(User, session)
|
|
|
|
# Authenticate user
|
|
user = await user_repo.authenticate_user(login_data.email, login_data.password)
|
|
|
|
if not user:
|
|
logger.warning(f"Login failed - user not found or invalid password, email={login_data.email}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid credentials"
|
|
)
|
|
|
|
# Check if user is active
|
|
if not user.is_active:
|
|
logger.warning(f"Login failed - inactive user, email={login_data.email}, user_id={user.id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Account is inactive"
|
|
)
|
|
|
|
# Validate subscription (if required)
|
|
subscription_valid = await self._validate_user_subscription(user)
|
|
|
|
if not subscription_valid:
|
|
logger.warning(f"Login failed - invalid subscription, email={login_data.email}, user_id={user.id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Subscription required or expired"
|
|
)
|
|
|
|
# Update last login timestamp
|
|
await user_repo.update_last_login(str(user.id))
|
|
|
|
# Get subscription details
|
|
subscription_details = await self._get_user_subscription_details(user)
|
|
|
|
logger.info(f"Login successful, email={login_data.email}, user_id={user.id}")
|
|
|
|
# Generate authentication tokens
|
|
tokens = await self._generate_auth_tokens(user)
|
|
|
|
return {
|
|
"user": user,
|
|
"tokens": tokens,
|
|
"subscription": subscription_details
|
|
}
|
|
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Login processing failed, error={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
|
|
|
|
async def _validate_user_subscription(self, user: User) -> bool:
|
|
"""
|
|
Validate user subscription status
|
|
|
|
Args:
|
|
user: User object
|
|
|
|
Returns:
|
|
True if subscription is valid, False otherwise
|
|
"""
|
|
try:
|
|
logger.info(f"Validating user subscription, user_id={user.id}, email={user.email}")
|
|
|
|
# Since tenant relationships are managed by tenant service, we need to call tenant service
|
|
# to get the user's primary tenant and validate subscription status
|
|
|
|
# Get user's primary tenant from tenant service
|
|
primary_tenant = await self.tenant_client.get_user_primary_tenant(str(user.id))
|
|
|
|
if primary_tenant:
|
|
tenant_id = primary_tenant.get('tenant_id')
|
|
logger.info(f"User has primary tenant - subscription validation required, user_id={user.id}, tenant_id={tenant_id}")
|
|
|
|
# Call tenant service to validate subscription status
|
|
subscription_status = await self.tenant_client.get_subscription_status(tenant_id)
|
|
|
|
if subscription_status:
|
|
status = subscription_status.get('status', 'unknown')
|
|
|
|
logger.info(f"Subscription status retrieved from tenant service, user_id={user.id}, tenant_id={tenant_id}, status={status}")
|
|
|
|
# Consider subscription valid if it's active, trialing, or in grace period
|
|
valid_statuses = ['active', 'trialing', 'grace_period']
|
|
return status in valid_statuses
|
|
else:
|
|
logger.warning(f"No subscription status returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
|
|
return False
|
|
else:
|
|
# Users without primary tenant might be in registration flow or using free tier
|
|
logger.info(f"User without primary tenant - no subscription validation required, user_id={user.id}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Subscription validation failed, error={str(e)}, user_id={user.id}, email={user.email}", exc_info=True)
|
|
# If validation fails, assume subscription is invalid
|
|
return False
|
|
|
|
async def _get_user_subscription_details(self, user: User) -> Dict[str, Any]:
|
|
"""
|
|
Get user subscription details
|
|
|
|
Args:
|
|
user: User object
|
|
|
|
Returns:
|
|
Dictionary with subscription details
|
|
"""
|
|
try:
|
|
logger.info(f"Getting subscription details, user_id={user.id}")
|
|
|
|
# Get user's primary tenant from tenant service
|
|
primary_tenant = await self.tenant_client.get_user_primary_tenant(str(user.id))
|
|
|
|
if primary_tenant:
|
|
tenant_id = primary_tenant.get('tenant_id')
|
|
|
|
# Call tenant service to get subscription details
|
|
subscription = await self.tenant_client.get_subscription_details(tenant_id)
|
|
|
|
if subscription:
|
|
logger.info(f"Subscription details retrieved from tenant service, user_id={user.id}, tenant_id={tenant_id}, plan={subscription.get('plan')}, status={subscription.get('status')}")
|
|
# Add tenant_id to subscription details for JWT
|
|
subscription['tenant_id'] = tenant_id
|
|
return subscription
|
|
else:
|
|
logger.warning(f"No subscription details returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
|
|
return {
|
|
"status": "no_subscription",
|
|
"plan": None,
|
|
"billing_cycle": None,
|
|
"current_period_end": None,
|
|
"trial_period_days": 0,
|
|
"tenant_id": tenant_id
|
|
}
|
|
else:
|
|
logger.info(f"User without primary tenant - no subscription details available, user_id={user.id}")
|
|
return {
|
|
"status": "no_tenant",
|
|
"plan": None,
|
|
"billing_cycle": None,
|
|
"current_period_end": None,
|
|
"trial_period_days": 0
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get subscription details, error={str(e)}, user_id={user.id}", exc_info=True)
|
|
return {
|
|
"status": "unknown",
|
|
"plan": None,
|
|
"billing_cycle": None,
|
|
"current_period_end": None,
|
|
"trial_period_days": 0
|
|
}
|
|
|
|
async def get_user_by_id(self, user_id: str) -> Optional[User]:
|
|
"""
|
|
Get user by ID
|
|
|
|
Args:
|
|
user_id: User ID to retrieve
|
|
|
|
Returns:
|
|
User object if found, None otherwise
|
|
"""
|
|
try:
|
|
logger.info(f"Getting user by ID, user_id={user_id}")
|
|
|
|
async with self.database_manager.get_session() as session:
|
|
user_repo = UserRepository(User, session)
|
|
user = await user_repo.get_by_id(user_id)
|
|
|
|
if user:
|
|
logger.info(f"User retrieved successfully, user_id={user_id}, email={user.email}")
|
|
else:
|
|
logger.warning(f"User not found, user_id={user_id}")
|
|
|
|
return user
|
|
|
|
except Exception as e:
|
|
logger.error(f"Get user by ID failed, error={str(e)}, user_id={user_id}", exc_info=True)
|
|
raise
|
|
|
|
async def _generate_auth_tokens(self, user: User) -> Dict[str, Any]:
|
|
"""
|
|
Generate authentication tokens for user
|
|
|
|
Args:
|
|
user: User object
|
|
|
|
Returns:
|
|
Dictionary with access and refresh tokens
|
|
"""
|
|
try:
|
|
logger.info(f"Generating auth tokens, user_id={user.id}")
|
|
|
|
# Get subscription details for JWT payload
|
|
subscription_details = await self._get_user_subscription_details(user)
|
|
|
|
# Prepare access token payload
|
|
access_token_data = {
|
|
"user_id": str(user.id),
|
|
"email": user.email,
|
|
"full_name": user.full_name,
|
|
"is_verified": user.is_verified,
|
|
"is_active": user.is_active,
|
|
"role": user.role or "admin",
|
|
"type": "access"
|
|
}
|
|
|
|
# Add subscription data to access token
|
|
if subscription_details:
|
|
access_token_data["subscription"] = subscription_details
|
|
if subscription_details.get("tenant_id"):
|
|
access_token_data["tenant_id"] = subscription_details["tenant_id"]
|
|
|
|
# Generate access token using SecurityManager
|
|
access_token = SecurityManager.create_access_token(access_token_data)
|
|
|
|
# Prepare refresh token payload
|
|
refresh_token_data = {
|
|
"user_id": str(user.id),
|
|
"email": user.email,
|
|
"type": "refresh"
|
|
}
|
|
|
|
# Generate refresh token using SecurityManager
|
|
refresh_token = SecurityManager.create_refresh_token(refresh_token_data)
|
|
|
|
# ✅ CRITICAL FIX: Store refresh token in Redis for later validation
|
|
await SecurityManager.store_refresh_token(str(user.id), refresh_token)
|
|
|
|
logger.info(f"Auth tokens generated successfully, user_id={user.id}, access_token_length={len(access_token)}, refresh_token_length={len(refresh_token)}")
|
|
|
|
return {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"expires_in": 3600,
|
|
"token_type": "bearer"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Token generation failed, error={str(e)}, user_id={user.id}",
|
|
exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Token generation failed: {str(e)}"
|
|
) from e
|
|
|
|
async def refresh_auth_tokens(self, refresh_token: str) -> Dict[str, Any]:
|
|
"""
|
|
Refresh authentication tokens using a valid refresh token
|
|
|
|
Args:
|
|
refresh_token: Valid refresh token
|
|
|
|
Returns:
|
|
Dictionary with new access and refresh tokens
|
|
|
|
Raises:
|
|
HTTPException: 401 for invalid refresh tokens
|
|
"""
|
|
try:
|
|
logger.info("Refreshing auth tokens using refresh token")
|
|
|
|
# Import JWT dependencies
|
|
from jose import jwt, JWTError
|
|
from app.core.config import settings
|
|
from app.core.security import SecurityManager
|
|
|
|
# Decode refresh token to get user info
|
|
payload = jwt.decode(
|
|
refresh_token,
|
|
settings.JWT_SECRET_KEY,
|
|
algorithms=[settings.JWT_ALGORITHM]
|
|
)
|
|
|
|
# Verify this is a refresh token
|
|
if payload.get("type") != "refresh":
|
|
logger.warning("Invalid token type for refresh")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token type"
|
|
)
|
|
|
|
user_id = payload.get("user_id")
|
|
email = payload.get("email")
|
|
|
|
if not user_id or not email:
|
|
logger.warning("Invalid refresh token payload")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token"
|
|
)
|
|
|
|
# Check if refresh token is valid in Redis using SecurityManager
|
|
is_valid = await SecurityManager.is_refresh_token_valid(user_id, refresh_token)
|
|
if not is_valid:
|
|
logger.warning(f"Invalid or expired refresh token for user {user_id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired refresh token"
|
|
)
|
|
|
|
logger.info(f"Refresh token validated for user {user_id}, email={email}")
|
|
|
|
# Get user from database
|
|
async with self.database_manager.get_session() as session:
|
|
user_repo = UserRepository(User, session)
|
|
user = await user_repo.get_by_id(user_id)
|
|
|
|
if not user:
|
|
logger.warning(f"User not found for refresh token, user_id={user_id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found"
|
|
)
|
|
|
|
if not user.is_active:
|
|
logger.warning(f"Inactive user attempted token refresh, user_id={user_id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Account is inactive"
|
|
)
|
|
|
|
# Generate new tokens using existing method
|
|
tokens = await self._generate_auth_tokens(user)
|
|
|
|
logger.info(f"Token refresh successful, user_id={user_id}, email={email}")
|
|
|
|
return tokens
|
|
|
|
except JWTError as e:
|
|
logger.warning(f"JWT decode error during refresh: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token"
|
|
)
|
|
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
|
|
|
|
async def verify_access_token(self, access_token: str) -> Dict[str, Any]:
|
|
"""
|
|
Verify the validity of an access token
|
|
|
|
Args:
|
|
access_token: Access token to verify
|
|
|
|
Returns:
|
|
Dictionary with token validation result and user info
|
|
|
|
Raises:
|
|
HTTPException: 401 for invalid access tokens
|
|
"""
|
|
try:
|
|
logger.info("Verifying access token")
|
|
|
|
# Import JWT dependencies
|
|
from jose import jwt, JWTError
|
|
from app.core.config import settings
|
|
|
|
# Decode and verify token
|
|
payload = jwt.decode(
|
|
access_token,
|
|
settings.JWT_SECRET_KEY,
|
|
algorithms=[settings.JWT_ALGORITHM]
|
|
)
|
|
|
|
# Verify this is an access token
|
|
if payload.get("type") != "access":
|
|
logger.warning("Invalid token type for verification")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token type"
|
|
)
|
|
|
|
user_id = payload.get("user_id")
|
|
email = payload.get("email")
|
|
|
|
if not user_id or not email:
|
|
logger.warning("Invalid access token payload")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid access token"
|
|
)
|
|
|
|
logger.info(f"Token verification successful, user_id={user_id}, email={email}")
|
|
|
|
return {
|
|
"valid": True,
|
|
"user_id": user_id,
|
|
"email": email
|
|
}
|
|
|
|
except JWTError as e:
|
|
logger.warning(f"JWT decode error during verification: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid access token"
|
|
)
|
|
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
|
|
|
|
async def revoke_refresh_token(self, refresh_token: str) -> bool:
|
|
"""
|
|
Revoke a refresh token
|
|
|
|
Args:
|
|
refresh_token: Refresh token to revoke
|
|
|
|
Returns:
|
|
True if revocation was successful or token didn't exist
|
|
|
|
Raises:
|
|
Exception: For unexpected errors
|
|
"""
|
|
try:
|
|
logger.info("Revoking refresh token")
|
|
|
|
# Try to decode token to get user_id
|
|
from jose import jwt, JWTError
|
|
from app.core.config import settings
|
|
|
|
user_id = "unknown"
|
|
try:
|
|
payload = jwt.decode(
|
|
refresh_token,
|
|
settings.JWT_SECRET_KEY,
|
|
algorithms=[settings.JWT_ALGORITHM]
|
|
)
|
|
user_id = payload.get("user_id") or "unknown"
|
|
except JWTError as e:
|
|
logger.warning(f"Could not decode refresh token during revocation: {str(e)}")
|
|
|
|
# Revoke the token using SecurityManager
|
|
await SecurityManager.revoke_refresh_token(user_id, refresh_token)
|
|
logger.info(f"Refresh token revoked successfully, user_id={user_id}")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error revoking refresh token: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Token revocation failed: {str(e)}"
|
|
) from e
|
|
|
|
async def change_user_password(self, user_id: str, current_password: str, new_password: str) -> bool:
|
|
"""
|
|
Change user password
|
|
|
|
Args:
|
|
user_id: User ID
|
|
current_password: Current password for verification
|
|
new_password: New password to set
|
|
|
|
Returns:
|
|
True if password was changed successfully
|
|
|
|
Raises:
|
|
HTTPException: 401 for invalid current password, 404 for user not found
|
|
"""
|
|
try:
|
|
logger.info(f"Changing password for user {user_id}")
|
|
|
|
async with self.database_manager.get_session() as session:
|
|
user_repo = UserRepository(User, session)
|
|
|
|
# Get current user
|
|
user = await user_repo.get_by_id(user_id)
|
|
if not user:
|
|
logger.warning(f"User not found for password change, user_id={user_id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Verify current password
|
|
from app.core.security import verify_password
|
|
is_valid = verify_password(current_password, user.hashed_password)
|
|
|
|
if not is_valid:
|
|
logger.warning(f"Invalid current password for user {user_id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Current password is incorrect"
|
|
)
|
|
|
|
# Update password
|
|
await user_repo.update_password(user_id, new_password)
|
|
|
|
logger.info(f"Password change successful, user_id={user_id}, email={user.email}")
|
|
|
|
return True
|
|
|
|
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
|
|
|
|
async def verify_user_email(self, email: str, verification_token: str) -> bool:
|
|
"""
|
|
Verify user email address
|
|
|
|
Args:
|
|
email: User email
|
|
verification_token: Email verification token
|
|
|
|
Returns:
|
|
True if email was verified successfully
|
|
|
|
Raises:
|
|
HTTPException: 404 for user not found
|
|
"""
|
|
try:
|
|
logger.info(f"Verifying email for {email}")
|
|
|
|
async with self.database_manager.get_session() as session:
|
|
user_repo = UserRepository(User, session)
|
|
|
|
# Find user by email
|
|
user = await user_repo.get_by_email(email)
|
|
if not user:
|
|
logger.warning(f"User not found for email verification, email={email}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# TODO: Implement actual email verification token logic
|
|
# For now, just mark as verified
|
|
await user_repo.update_user(user.id, {"is_verified": True})
|
|
|
|
logger.info(f"Email verification successful, user_id={user.id}, email={email}")
|
|
|
|
return True
|
|
|
|
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
|
|
|
|
async def request_password_reset(self, email: str) -> bool:
|
|
"""
|
|
Request a password reset for a user
|
|
|
|
Args:
|
|
email: User's email address
|
|
|
|
Returns:
|
|
True if request was processed (whether user exists or not)
|
|
"""
|
|
try:
|
|
logger.info(f"Processing password reset request for email: {email}")
|
|
|
|
async with self.database_manager.get_session() as session:
|
|
user_repo = UserRepository(User, session)
|
|
|
|
# Find user by email (don't reveal if user exists)
|
|
user = await user_repo.get_by_field("email", email)
|
|
|
|
if not user:
|
|
logger.info(f"Password reset request for non-existent email: {email}")
|
|
return True # Don't reveal if email exists
|
|
|
|
# Generate reset token
|
|
reset_token = SecurityManager.generate_reset_token()
|
|
|
|
# Set expiration (1 hour)
|
|
from datetime import datetime, timedelta, timezone
|
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
|
|
# Store reset token
|
|
token_repo = PasswordResetTokenRepository(session)
|
|
|
|
# Clean up expired tokens
|
|
await token_repo.cleanup_expired_tokens()
|
|
|
|
# Create new token
|
|
await token_repo.create_token(
|
|
user_id=str(user.id),
|
|
token=reset_token,
|
|
expires_at=expires_at
|
|
)
|
|
|
|
# Commit transaction
|
|
await session.commit()
|
|
|
|
logger.info(f"Password reset token created for user: {email}")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Password reset request failed: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
async def reset_password_with_token(self, token: str, new_password: str) -> bool:
|
|
"""
|
|
Reset password using a valid reset token
|
|
|
|
Args:
|
|
token: Password reset token
|
|
new_password: New password to set
|
|
|
|
Returns:
|
|
True if password was successfully reset
|
|
"""
|
|
try:
|
|
logger.info(f"Processing password reset with token: {token[:10]}...")
|
|
|
|
# Validate password strength
|
|
if not SecurityManager.validate_password(new_password):
|
|
errors = SecurityManager.get_password_validation_errors(new_password)
|
|
logger.warning(f"Password validation failed: {errors}")
|
|
raise ValueError(f"Password does not meet requirements: {'; '.join(errors)}")
|
|
|
|
async with self.database_manager.get_session() as session:
|
|
# Find the reset token
|
|
token_repo = PasswordResetTokenRepository(session)
|
|
reset_token_obj = await token_repo.get_token_by_value(token)
|
|
|
|
if not reset_token_obj:
|
|
logger.warning(f"Invalid or expired password reset token: {token[:10]}...")
|
|
raise ValueError("Invalid or expired reset token")
|
|
|
|
# Get the user
|
|
user_repo = UserRepository(User, session)
|
|
user = await user_repo.get_by_id(str(reset_token_obj.user_id))
|
|
|
|
if not user:
|
|
logger.error(f"User not found for reset token: {token[:10]}...")
|
|
raise ValueError("Invalid reset token")
|
|
|
|
# Hash the new password
|
|
hashed_password = SecurityManager.hash_password(new_password)
|
|
|
|
# Update user's password
|
|
await user_repo.update(str(user.id), {
|
|
"hashed_password": hashed_password
|
|
})
|
|
|
|
# Mark token as used
|
|
await token_repo.mark_token_as_used(str(reset_token_obj.id))
|
|
|
|
# Commit transaction
|
|
await session.commit()
|
|
|
|
logger.info(f"Password successfully reset for user: {user.email}")
|
|
|
|
return True
|
|
|
|
except ValueError:
|
|
# Re-raise value errors (validation errors)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Password reset failed: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
# Import database manager for singleton instance
|
|
from app.core.database import database_manager
|
|
|
|
# Import required modules for the password reset functionality
|
|
from app.repositories.password_reset_repository import PasswordResetTokenRepository
|
|
from shared.clients.notification_client import NotificationServiceClient
|
|
|
|
# Singleton instance for dependency injection
|
|
auth_service = AuthService(database_manager=database_manager)
|
|
|
|
|
|
class EnhancedAuthService(AuthService):
|
|
"""
|
|
Enhanced Auth Service with additional functionality for testing and advanced operations
|
|
"""
|
|
|
|
async def _get_service_token(self) -> str:
|
|
"""
|
|
Get a service token for internal service communication
|
|
"""
|
|
from app.core.security import SecurityManager
|
|
return SecurityManager.create_service_token("auth-service") |