Files
bakery-ia/services/auth/app/services/auth_service.py

1139 lines
47 KiB
Python
Raw Normal View History

2025-07-17 14:34:24 +02:00
"""
2026-01-15 20:45:49 +01:00
Refactored Auth Service with proper separation of concerns
Focuses only on user management, delegates payment to tenant service
2025-07-17 14:34:24 +02:00
"""
2026-01-15 20:45:49 +01:00
import logging
2025-07-22 13:46:05 +02:00
from typing import Dict, Any, Optional
2026-01-15 20:45:49 +01:00
from datetime import datetime
from uuid import UUID
2025-07-26 23:29:57 +02:00
from fastapi import HTTPException, status
2026-01-15 20:45:49 +01:00
from shared.clients.tenant_client import TenantServiceClient
from app.core.config import settings
from shared.exceptions.auth_exceptions import (
UserCreationError,
RegistrationError,
PaymentOrchestrationError
)
2025-07-20 08:22:17 +02:00
from app.core.security import SecurityManager
2026-01-15 20:45:49 +01:00
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
2025-07-17 14:34:24 +02:00
2026-01-15 20:45:49 +01:00
# Configure logging
logger = logging.getLogger(__name__)
2025-07-22 13:46:05 +02:00
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
class AuthService:
"""
Auth Service focused solely on user management
Delegates all payment operations to Tenant Service
"""
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
def __init__(self, database_manager=None):
"""Initialize auth service"""
2025-08-08 09:08:41 +02:00
self.database_manager = database_manager
2026-01-15 20:45:49 +01:00
self.tenant_client = TenantServiceClient(settings)
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
async def register_user(self, user_data: UserRegistration) -> User:
"""
Atomic: Create user record (ONLY after payment verification)
2025-10-16 07:28:04 +02:00
2026-01-15 20:45:49 +01:00
Args:
user_data: User registration data
2025-10-16 07:28:04 +02:00
2026-01-15 20:45:49 +01:00
Returns:
Created user
2025-10-16 07:28:04 +02:00
2026-01-15 20:45:49 +01:00
Raises:
UserCreationError: If user creation fails
"""
try:
logger.info(f"Creating user record, email={user_data.email}")
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
# Create user in database
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
2026-01-15 20:45:49 +01:00
# Hash the password before storing
from app.core.security import SecurityManager
hashed_password = SecurityManager.hash_password(user_data.password)
2026-01-15 20:45:49 +01:00
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"
})
2026-01-15 20:45:49 +01:00
logger.info(f"User created successfully, user_id={user.id}, email={user.email}")
2025-10-07 07:15:07 +02:00
2026-01-15 20:45:49 +01:00
return user
2025-07-20 08:22:17 +02:00
except Exception as e:
2026-01-15 20:45:49 +01:00
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
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
# 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
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
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
2025-07-26 23:29:57 +02:00
2026-01-15 20:45:49 +01:00
Returns:
Payment setup result from tenant service
2025-07-20 08:22:17 +02:00
2026-01-15 20:45:49 +01:00
Raises:
PaymentOrchestrationError: If payment setup fails
"""
2025-07-20 08:22:17 +02:00
try:
2026-01-15 20:45:49 +01:00
logger.info(f"Initiating registration payment setup via tenant service, email={user_data.email}, plan={user_data.subscription_plan}")
2025-07-20 08:22:17 +02:00
2026-01-15 20:45:49 +01:00
# 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')}")
2026-01-12 14:24:14 +01:00
2026-01-15 20:45:49 +01:00
return result
2025-07-20 08:22:17 +02:00
except Exception as e:
2026-01-15 20:45:49 +01:00
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
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
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]:
"""
2026-01-15 22:06:36 +01:00
Complete registration after successful payment verification.
2026-01-16 16:09:32 +01:00
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.
2026-01-15 20:45:49 +01:00
Args:
2026-01-16 16:09:32 +01:00
setup_intent_id: Verified SetupIntent ID (must not be None)
2026-01-15 20:45:49 +01:00
user_data: User registration data
2026-01-15 22:06:36 +01:00
payment_setup_result: Optional payment setup result with customer_id etc.
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
- status: Subscription status
- 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
RegistrationError: If registration completion fails (e.g., missing setup_intent_id)
2026-01-15 20:45:49 +01:00
"""
2025-08-08 09:08:41 +02:00
try:
2026-01-15 22:06:36 +01:00
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
}
)
2026-01-15 20:45:49 +01:00
# Use a single database session for both user creation and onboarding progress
# to ensure proper transaction handling and avoid foreign key constraint violations
2025-08-08 09:08:41 +02:00
async with self.database_manager.get_session() as session:
2026-01-15 20:45:49 +01:00
# Step 2: Create user (ONLY after successful payment verification)
2025-08-08 19:21:23 +02:00
user_repo = UserRepository(User, session)
2026-01-15 20:45:49 +01:00
# 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
2025-08-08 09:08:41 +02:00
)
2026-01-15 20:45:49 +01:00
# 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'
}
2025-08-08 09:08:41 +02:00
except Exception as e:
2026-01-15 20:45:49 +01:00
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
2026-01-15 20:45:49 +01:00
async def start_secure_registration_flow(
self,
user_data: UserRegistration
) -> Dict[str, Any]:
"""
2026-01-15 20:45:49 +01:00
Start secure registration flow with SetupIntent-first approach
2026-01-16 16:09:32 +01:00
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
2026-01-12 14:24:14 +01:00
Args:
2026-01-15 20:45:49 +01:00
user_data: User registration data
2026-01-12 14:24:14 +01:00
Returns:
2026-01-16 16:09:32 +01:00
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
2026-01-15 20:45:49 +01:00
Raises:
RegistrationError: If registration flow fails
"""
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}")
2026-01-15 20:45:49 +01:00
# 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):
2026-01-15 22:06:36 +01:00
logger.info(f"Registration requires SetupIntent confirmation (3DS), email={user_data.email}, setup_intent_id={payment_setup_result.get('setup_intent_id')}")
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
# Return SetupIntent for frontend to handle 3DS
2026-01-15 22:06:36 +01:00
# Note: NO subscription exists yet - subscription is created after verification
2026-01-15 20:45:49 +01:00
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'),
2026-01-15 22:06:36 +01:00
'trial_period_days': payment_setup_result.get('trial_period_days', 0),
'coupon_code': payment_setup_result.get('coupon_code'),
2026-01-15 20:45:49 +01:00
'email': payment_setup_result.get('email'),
2026-01-15 22:06:36 +01:00
'message': 'Payment verification required. Frontend must confirm SetupIntent.'
2026-01-15 20:45:49 +01:00
}
else:
2026-01-15 22:06:36 +01:00
# 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')}")
2026-01-15 20:45:49 +01:00
2026-01-16 16:09:32 +01:00
# Note: NO subscription created yet - frontend must call complete-registration
# This ensures consistent flow whether 3DS is required or not
2026-01-15 20:45:49 +01:00
return {
'requires_action': False,
2026-01-16 16:09:32 +01:00
'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.'
2026-01-15 20:45:49 +01:00
}
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(
2026-01-13 22:22:38 +01:00
self,
2026-01-15 20:45:49 +01:00
setup_intent_id: str,
user_data: UserRegistration
) -> Dict[str, Any]:
2026-01-13 22:22:38 +01:00
"""
2026-01-16 16:09:32 +01:00
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
2026-01-13 22:22:38 +01:00
Args:
2026-01-16 16:09:32 +01:00
setup_intent_id: SetupIntent ID that was confirmed (either by Stripe or frontend)
2026-01-15 20:45:49 +01:00
user_data: User registration data
2026-01-13 22:22:38 +01:00
Returns:
2026-01-16 16:09:32 +01:00
Complete registration result with user data, tokens, and subscription info
2026-01-15 20:45:49 +01:00
Raises:
RegistrationError: If completion fails
2026-01-13 22:22:38 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
logger.info(f"Completing registration after SetupIntent verification, email={user_data.email}, setup_intent_id={setup_intent_id}")
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
# Complete registration after payment verification
result = await self.complete_registration_after_payment_verification(
setup_intent_id,
user_data
2026-01-13 22:22:38 +01: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['subscription_id']}")
return {
'success': True,
'user': result['user'],
'subscription_id': result['subscription_id'],
'payment_customer_id': result['payment_customer_id'],
'status': result['status'],
2026-01-16 09:55:54 +01:00
'access_token': result.get('access_token'),
'refresh_token': result.get('refresh_token'),
2026-01-15 20:45:49 +01:00
'message': 'Registration completed successfully after 3DS verification'
}
2026-01-13 22:22:38 +01:00
except Exception as e:
2026-01-15 20:45:49 +01:00
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
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
async def login_user(self, login_data: UserLogin) -> Dict[str, Any]:
2026-01-14 13:15:48 +01:00
"""
2026-01-15 20:45:49 +01:00
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
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
Dictionary with user information, tokens, and subscription details
Raises:
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"Processing login request, email={login_data.email}")
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Validate user credentials using repository
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Authenticate user
user = await user_repo.authenticate_user(login_data.email, login_data.password)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
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"
)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# 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"
)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Validate subscription (if required)
subscription_valid = await self._validate_user_subscription(user)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
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"
)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Update last login timestamp
await user_repo.update_last_login(str(user.id))
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Get subscription details
subscription_details = await self._get_user_subscription_details(user)
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={user.id}")
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Generate authentication tokens
tokens = await self._generate_auth_tokens(user)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
return {
"user": user,
"tokens": tokens,
"subscription": subscription_details
}
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
except HTTPException:
# Re-raise HTTP exceptions
2026-01-14 13:15:48 +01:00
raise
2026-01-15 20:45:49 +01:00
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:
2026-01-13 22:22:38 +01:00
"""
2026-01-15 20:45:49 +01:00
Validate user subscription status
2026-01-13 22:22:38 +01:00
Args:
2026-01-15 20:45:49 +01:00
user: User object
2026-01-13 22:22:38 +01:00
Returns:
2026-01-15 20:45:49 +01:00
True if subscription is valid, False otherwise
2026-01-13 22:22:38 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
logger.info(f"Validating user subscription, user_id={user.id}, email={user.email}")
2026-01-16 15:19:34 +01:00
# 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}")
2026-01-15 20:45:49 +01:00
# Call tenant service to validate subscription status
2026-01-16 15:19:34 +01:00
subscription_status = await self.tenant_client.get_subscription_status(tenant_id)
2026-01-15 20:45:49 +01:00
if subscription_status:
status = subscription_status.get('status', 'unknown')
2026-01-13 22:22:38 +01:00
2026-01-16 15:19:34 +01:00
logger.info(f"Subscription status retrieved from tenant service, user_id={user.id}, tenant_id={tenant_id}, status={status}")
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
# Consider subscription valid if it's active, trialing, or in grace period
valid_statuses = ['active', 'trialing', 'grace_period']
2026-01-16 15:19:34 +01:00
return status in valid_statuses
2026-01-15 20:45:49 +01:00
else:
2026-01-16 15:19:34 +01:00
logger.warning(f"No subscription status returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
2026-01-15 20:45:49 +01:00
return False
else:
2026-01-16 15:19:34 +01:00
# 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}")
2026-01-15 20:45:49 +01:00
return True
2026-01-13 22:22:38 +01:00
except Exception as e:
2026-01-15 20:45:49 +01:00
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]:
2026-01-14 13:15:48 +01:00
"""
2026-01-15 20:45:49 +01:00
Get user subscription details
2026-01-14 13:15:48 +01:00
Args:
2026-01-15 20:45:49 +01:00
user: User object
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Dictionary with subscription details
2026-01-14 13:15:48 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
logger.info(f"Getting subscription details, user_id={user.id}")
2026-01-16 15:19:34 +01:00
# 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')
2026-01-15 20:45:49 +01:00
# Call tenant service to get subscription details
2026-01-16 15:19:34 +01:00
subscription = await self.tenant_client.get_subscription_details(tenant_id)
2026-01-15 20:45:49 +01:00
if subscription:
2026-01-16 15:19:34 +01:00
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
2026-01-15 20:45:49 +01:00
return subscription
else:
2026-01-16 15:19:34 +01:00
logger.warning(f"No subscription details returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
2026-01-15 20:45:49 +01:00
return {
"status": "no_subscription",
"plan": None,
"billing_cycle": None,
"current_period_end": None,
2026-01-16 15:19:34 +01:00
"trial_period_days": 0,
"tenant_id": tenant_id
2026-01-15 20:45:49 +01:00
}
else:
2026-01-16 15:19:34 +01:00
logger.info(f"User without primary tenant - no subscription details available, user_id={user.id}")
2026-01-15 20:45:49 +01:00
return {
"status": "no_tenant",
"plan": None,
"billing_cycle": None,
"current_period_end": None,
"trial_period_days": 0
}
2026-01-14 13:15:48 +01:00
except Exception as e:
2026-01-15 20:45:49 +01:00
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
}
2026-01-16 15:19:34 +01:00
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
2026-01-15 20:45:49 +01:00
async def _generate_auth_tokens(self, user: User) -> Dict[str, Any]:
2026-01-14 13:15:48 +01:00
"""
2026-01-15 20:45:49 +01:00
Generate authentication tokens for user
2026-01-14 13:15:48 +01:00
Args:
2026-01-15 20:45:49 +01:00
user: User object
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Dictionary with access and refresh tokens
2026-01-14 13:15:48 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
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)
2026-01-16 15:19:34 +01:00
# ✅ CRITICAL FIX: Store refresh token in Redis for later validation
await SecurityManager.store_refresh_token(str(user.id), refresh_token)
2026-01-15 20:45:49 +01:00
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"
}
2026-01-14 13:15:48 +01:00
except Exception as e:
2026-01-15 20:45:49 +01:00
logger.error(f"Token generation failed, error={str(e)}, user_id={user.id}",
2026-01-14 13:15:48 +01:00
exc_info=True)
2026-01-15 20:45:49 +01:00
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token generation failed: {str(e)}"
) from e
2026-01-13 22:22:38 +01:00
2026-01-16 15:19:34 +01:00
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
2026-01-16 09:55:54 +01:00
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
2026-01-15 20:45:49 +01:00
# Import database manager for singleton instance
from app.core.database import database_manager
2026-01-13 22:22:38 +01:00
2026-01-16 09:55:54 +01:00
# Import required modules for the password reset functionality
from app.repositories.password_reset_repository import PasswordResetTokenRepository
from shared.clients.notification_client import NotificationServiceClient
2026-01-15 20:45:49 +01:00
# Singleton instance for dependency injection
auth_service = AuthService(database_manager=database_manager)
2026-01-13 22:22:38 +01:00
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
class EnhancedAuthService(AuthService):
"""
Enhanced Auth Service with additional functionality for testing and advanced operations
"""
2025-08-08 09:08:41 +02:00
2026-01-15 20:45:49 +01:00
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")