Files
bakery-ia/services/auth/app/services/auth_service.py
2026-01-12 14:24:14 +01:00

739 lines
35 KiB
Python

"""
Enhanced Authentication Service
Updated to use repository pattern with dependency injection and improved error handling
"""
import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from fastapi import HTTPException, status
import structlog
from app.repositories import UserRepository, TokenRepository
from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, UserResponse
from app.models.users import User
from app.models.tokens import RefreshToken
from app.core.security import SecurityManager
from app.utils.subscription_fetcher import SubscriptionFetcher
from shared.messaging import UnifiedEventPublisher, EVENT_TYPES
from shared.database.unit_of_work import UnitOfWork
from shared.database.transactions import transactional
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
# Legacy compatibility alias
AuthService = None # Will be set at the end of the file
class EnhancedAuthService:
"""Enhanced authentication service using repository pattern"""
def __init__(self, database_manager, event_publisher=None):
"""Initialize service with database manager and optional event publisher"""
self.database_manager = database_manager
self.event_publisher = event_publisher
async def register_user(
self,
user_data: UserRegistration
) -> TokenResponse:
"""Register a new user using repository pattern"""
try:
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
# Register repositories
user_repo = uow.register_repository("users", UserRepository, User)
token_repo = uow.register_repository("tokens", TokenRepository, RefreshToken)
# Check if user already exists
existing_user = await user_repo.get_by_email(user_data.email)
if existing_user:
raise DuplicateRecordError("User with this email already exists")
# Validate password strength
if not SecurityManager.validate_password(user_data.password):
raise ValueError("Password does not meet security requirements")
# Create user data
# Default to admin role for first-time registrations during onboarding flow
# Users creating their own bakery should have admin privileges
user_role = user_data.role if user_data.role else "admin"
hashed_password = SecurityManager.hash_password(user_data.password)
create_data = {
"email": user_data.email,
"full_name": user_data.full_name,
"hashed_password": hashed_password,
"is_active": True,
"is_verified": False,
"role": user_role
}
# Create user using repository
new_user = await user_repo.create_user(create_data)
logger.debug("User created with repository pattern",
user_id=new_user.id,
email=user_data.email,
role=user_role)
# Create tokens with different payloads
access_token_data = {
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"is_verified": new_user.is_verified,
"is_active": new_user.is_active,
"role": new_user.role,
"type": "access"
}
refresh_token_data = {
"user_id": str(new_user.id),
"email": new_user.email,
"type": "refresh"
}
# Generate tokens
access_token = SecurityManager.create_access_token(user_data=access_token_data)
refresh_token_value = SecurityManager.create_refresh_token(user_data=refresh_token_data)
# Store refresh token using repository
token_data = {
"user_id": str(new_user.id),
"token": refresh_token_value,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
"is_revoked": False
}
await token_repo.create_token(token_data)
# Record GDPR consent if provided
if (user_data.terms_accepted or user_data.privacy_accepted or
user_data.marketing_consent or user_data.analytics_consent):
try:
from app.models.consent import UserConsent, ConsentHistory
ip_address = None # Would need to pass from request context
user_agent = None # Would need to pass from request context
consent = UserConsent(
user_id=new_user.id,
terms_accepted=user_data.terms_accepted if user_data.terms_accepted is not None else True,
privacy_accepted=user_data.privacy_accepted if user_data.privacy_accepted is not None else True,
marketing_consent=user_data.marketing_consent if user_data.marketing_consent is not None else False,
analytics_consent=user_data.analytics_consent if user_data.analytics_consent is not None else False,
consent_version="1.0",
consent_method="registration",
ip_address=ip_address,
user_agent=user_agent,
consented_at=datetime.now(timezone.utc)
)
db_session.add(consent)
await db_session.flush()
# Create consent history entry
history = ConsentHistory(
user_id=new_user.id,
consent_id=consent.id,
action="granted",
consent_snapshot={
"terms_accepted": consent.terms_accepted,
"privacy_accepted": consent.privacy_accepted,
"marketing_consent": consent.marketing_consent,
"analytics_consent": consent.analytics_consent,
"consent_version": "1.0",
"consent_method": "registration"
},
ip_address=ip_address,
user_agent=user_agent,
consent_method="registration",
created_at=datetime.now(timezone.utc)
)
db_session.add(history)
logger.info("User consent recorded during registration",
user_id=new_user.id,
terms_accepted=consent.terms_accepted,
privacy_accepted=consent.privacy_accepted,
marketing_consent=consent.marketing_consent,
analytics_consent=consent.analytics_consent)
except Exception as e:
logger.error("Failed to record user consent during registration",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if consent can't be recorded
raise
# Store subscription plan selection in onboarding progress BEFORE committing
# This ensures it's part of the same transaction
if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id:
try:
from app.repositories.onboarding_repository import OnboardingRepository
from app.models.onboarding import UserOnboardingProgress
# Use upsert_user_step instead of save_step_data to avoid double commits
onboarding_repo = OnboardingRepository(db_session)
plan_data = {
"subscription_plan": user_data.subscription_plan or "starter",
"subscription_tier": user_data.subscription_plan or "starter", # Store tier for enterprise onboarding logic
"use_trial": user_data.use_trial or False,
"payment_method_id": user_data.payment_method_id,
"saved_at": datetime.now(timezone.utc).isoformat()
}
# Create the onboarding step record with plan data
# Note: We use completed=True to mark user_registered as complete
# auto_commit=False to let UnitOfWork handle the commit
await onboarding_repo.upsert_user_step(
user_id=str(new_user.id),
step_name="user_registered",
completed=True,
step_data=plan_data,
auto_commit=False
)
logger.info("Subscription plan saved to onboarding progress",
user_id=new_user.id,
plan=user_data.subscription_plan)
except Exception as e:
logger.error("Failed to save subscription plan to onboarding progress",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if onboarding data can't be saved
raise
# Commit transaction (includes user, tokens, consent, and onboarding data)
await uow.commit()
# Publish registration event (non-blocking)
if self.event_publisher:
try:
await self.event_publisher.publish_business_event(
event_type="auth.user.registered",
tenant_id="system", # User registration is system-wide initially
data={
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"role": new_user.role,
"registered_at": datetime.now(timezone.utc).isoformat(),
"subscription_plan": user_data.subscription_plan or "starter"
}
)
except Exception as e:
logger.warning("Failed to publish registration event: %s", str(e))
logger.info("User registered successfully using repository pattern",
user_id=new_user.id,
email=user_data.email)
from app.schemas.auth import UserData
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
token_type="bearer",
expires_in=1800,
user=UserData(
id=str(new_user.id),
email=new_user.email,
full_name=new_user.full_name,
is_active=new_user.is_active,
is_verified=new_user.is_verified,
created_at=new_user.created_at.isoformat() if new_user.created_at else datetime.now(timezone.utc).isoformat(),
role=new_user.role
)
)
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Registration failed using repository pattern",
email=user_data.email,
error=str(e))
raise DatabaseError(f"Registration failed: {str(e)}")
async def login_user(
self,
login_data: UserLogin
) -> TokenResponse:
"""Login user using repository pattern"""
try:
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
# Register repositories
user_repo = uow.register_repository("users", UserRepository, User)
token_repo = uow.register_repository("tokens", TokenRepository, RefreshToken)
# Authenticate user using repository
user = await user_repo.authenticate_user(login_data.email, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated"
)
# Revoke existing refresh tokens using repository
await token_repo.revoke_all_user_tokens(str(user.id))
logger.debug("Existing tokens revoked using repository pattern",
user_id=user.id)
# NEW: Fetch subscription data for JWT enrichment
# This happens ONCE at login, not per-request
from app.core.config import settings
subscription_fetcher = SubscriptionFetcher(
tenant_service_url=settings.TENANT_SERVICE_URL # Now properly configurable
)
# Get service token for inter-service communication
service_token = await self._get_service_token()
subscription_context = await subscription_fetcher.get_user_subscription_context(
user_id=str(user.id),
service_token=service_token
)
logger.debug("Fetched subscription context for JWT enrichment",
user_id=user.id,
subscription_tier=subscription_context.get("subscription", {}).get("tier"))
# Create tokens with different payloads
subscription_data = subscription_context.get("subscription") or {}
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,
"type": "access",
# NEW: Add subscription data to JWT payload
"tenant_id": subscription_context.get("tenant_id"),
"tenant_role": subscription_context.get("tenant_role"),
"subscription": subscription_data,
"subscription_tier": subscription_data.get("tier", "starter"), # Add direct field for gateway
"subscription_from_jwt": True, # Flag for gateway to use JWT data
"tenant_access": subscription_context.get("tenant_access")
}
refresh_token_data = {
"user_id": str(user.id),
"email": user.email,
"type": "refresh",
"jti": str(uuid.uuid4())
}
# Generate tokens
access_token = SecurityManager.create_access_token(user_data=access_token_data)
refresh_token_value = SecurityManager.create_refresh_token(user_data=refresh_token_data)
# Store refresh token using repository
token_data = {
"user_id": str(user.id),
"token": refresh_token_value,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
"is_revoked": False
}
await token_repo.create_token(token_data)
# Update last login using repository
await user_repo.update_last_login(str(user.id))
# Commit transaction
await uow.commit()
# Publish login event (non-blocking)
if self.event_publisher:
try:
await self.event_publisher.publish_business_event(
event_type="auth.user.login",
tenant_id="system",
data={
"user_id": str(user.id),
"email": user.email,
"login_at": datetime.now(timezone.utc).isoformat()
}
)
except Exception as e:
logger.warning("Failed to publish login event: %s", str(e))
logger.info("User logged in successfully using repository pattern",
user_id=user.id,
email=login_data.email)
from app.schemas.auth import UserData
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
token_type="bearer",
expires_in=1800,
user=UserData(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_verified=user.is_verified,
created_at=user.created_at.isoformat() if user.created_at else datetime.now(timezone.utc).isoformat(),
role=user.role
)
)
except HTTPException:
raise
except Exception as e:
logger.error("Login failed using repository pattern",
email=login_data.email,
error=str(e))
raise DatabaseError(f"Login failed: {str(e)}")
async def logout_user(self, user_id: str, refresh_token: str) -> bool:
"""Logout user using repository pattern"""
try:
async with self.database_manager.get_session() as session:
token_repo = TokenRepository(RefreshToken, session)
# Revoke specific refresh token using repository
success = await token_repo.revoke_token(user_id, refresh_token)
if success:
logger.info("User logged out successfully using repository pattern",
user_id=user_id)
return True
return False
except Exception as e:
logger.error("Logout failed using repository pattern",
user_id=user_id,
error=str(e))
return False
async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh access token using repository pattern"""
try:
# Verify refresh token
payload = SecurityManager.decode_token(refresh_token)
user_id = payload.get("user_id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
# Validate refresh token using repository
is_valid = await token_repo.validate_refresh_token(refresh_token, user_id)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token"
)
# Get user using repository
user = await user_repo.get_by_id(user_id)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# NEW: Fetch FRESH subscription data for token refresh
# This ensures subscription changes propagate within token expiry period
subscription_fetcher = SubscriptionFetcher(
tenant_service_url=settings.TENANT_SERVICE_URL # Now properly configurable
)
service_token = await self._get_service_token()
subscription_context = await subscription_fetcher.get_user_subscription_context(
user_id=str(user.id),
service_token=service_token
)
logger.debug("Fetched fresh subscription context for token refresh",
user_id=user.id,
subscription_tier=subscription_context.get("subscription", {}).get("tier"))
# Create new access token with updated subscription data
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,
"type": "access",
# NEW: Add fresh subscription data to JWT payload
"tenant_id": subscription_context.get("tenant_id"),
"tenant_role": subscription_context.get("tenant_role"),
"subscription": subscription_context.get("subscription"),
"tenant_access": subscription_context.get("tenant_access")
}
new_access_token = SecurityManager.create_access_token(user_data=access_token_data)
logger.debug("Access token refreshed successfully using repository pattern",
user_id=user_id)
return {
"access_token": new_access_token,
"token_type": "bearer",
"expires_in": 1800
}
except HTTPException:
raise
except Exception as e:
logger.error("Token refresh failed using repository pattern: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token refresh failed"
)
async def verify_user_token(self, token: str) -> Dict[str, Any]:
"""Verify access token and return user info"""
try:
payload = SecurityManager.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
# Handle service tokens (used for inter-service communication)
if payload.get("type") == "service":
logger.debug("Service token verified successfully",
service=payload.get("service"),
tenant_id=payload.get("tenant_id"))
return {
"valid": True,
"user_id": payload.get("user_id", f"{payload.get('service')}-service"),
"email": payload.get("email", f"{payload.get('service')}-service@internal"),
"role": payload.get("role", "admin"),
"exp": payload.get("exp"),
"service": payload.get("service"),
"tenant_id": payload.get("tenant_id")
}
# Handle regular user tokens
return payload
except Exception as e:
logger.error("Token verification error using repository pattern: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
async def get_user_profile(self, user_id: str) -> Optional[UserResponse]:
"""Get user profile using repository pattern"""
try:
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:
return None
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_verified=user.is_verified,
created_at=user.created_at,
role=user.role
)
except Exception as e:
logger.error("Failed to get user profile using repository pattern",
user_id=user_id,
error=str(e))
return None
async def update_user_profile(
self,
user_id: str,
update_data: Dict[str, Any]
) -> Optional[UserResponse]:
"""Update user profile using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
updated_user = await user_repo.update(user_id, update_data)
if not updated_user:
return None
logger.info("User profile updated using repository pattern",
user_id=user_id,
updated_fields=list(update_data.keys()))
return UserResponse(
id=str(updated_user.id),
email=updated_user.email,
full_name=updated_user.full_name,
is_active=updated_user.is_active,
is_verified=updated_user.is_verified,
created_at=updated_user.created_at,
role=updated_user.role
)
except Exception as e:
logger.error("Failed to update user profile using repository pattern",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to update profile: {str(e)}")
async def change_password(
self,
user_id: str,
old_password: str,
new_password: str
) -> bool:
"""Change user password using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
# Get user and verify old password
user = await user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not SecurityManager.verify_password(old_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid old password"
)
# Validate new password strength
if not SecurityManager.validate_password(new_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password does not meet security requirements"
)
# Hash new password and update
new_hashed_password = SecurityManager.hash_password(new_password)
await user_repo.update(user_id, {"hashed_password": new_hashed_password})
# Revoke all existing tokens for security
await token_repo.revoke_all_user_tokens(user_id)
logger.info("Password changed successfully using repository pattern",
user_id=user_id)
return True
except HTTPException:
raise
except Exception as e:
logger.error("Failed to change password using repository pattern",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to change password: {str(e)}")
async def verify_user_email(self, user_id: str, verification_token: str) -> bool:
"""Verify user email using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
# In a real implementation, you'd verify the verification_token
# For now, just mark user as verified
updated_user = await user_repo.update(user_id, {"is_verified": True})
if updated_user:
logger.info("User email verified using repository pattern",
user_id=user_id)
return True
return False
except Exception as e:
logger.error("Failed to verify email using repository pattern",
user_id=user_id,
error=str(e))
return False
async def deactivate_user(self, user_id: str, admin_user_id: str) -> bool:
"""Deactivate user account using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
# Update user status
updated_user = await user_repo.update(user_id, {"is_active": False})
if not updated_user:
return False
# Revoke all tokens
await token_repo.revoke_all_user_tokens(user_id)
logger.info("User deactivated using repository pattern",
user_id=user_id,
admin_user_id=admin_user_id)
return True
except Exception as e:
logger.error("Failed to deactivate user using repository pattern",
user_id=user_id,
error=str(e))
return False
async def _get_service_token(self, tenant_id: Optional[str] = None) -> str:
"""
Get service token for inter-service communication.
This is used to fetch subscription data from tenant service.
Args:
tenant_id: Optional tenant ID for tenant-scoped service operations
Returns:
JWT service token
"""
try:
# Create a proper service token with JWT using SecurityManager
service_token = SecurityManager.create_service_token("auth-service", tenant_id)
logger.debug("Generated service token for tenant service communication", tenant_id=tenant_id)
return service_token
except Exception as e:
logger.error(f"Failed to get service token: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to authenticate with tenant service"
)
# Legacy compatibility - alias EnhancedAuthService as AuthService
AuthService = EnhancedAuthService
class EnhancedUserService(EnhancedAuthService):
"""User service alias for backward compatibility"""
pass