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

648 lines
30 KiB
Python
Raw Normal View History

2025-07-17 14:34:24 +02:00
"""
2025-08-08 09:08:41 +02:00
Enhanced Authentication Service
Updated to use repository pattern with dependency injection and improved error handling
2025-07-17 14:34:24 +02:00
"""
2025-07-26 23:29:57 +02:00
import uuid
from datetime import datetime, timedelta, timezone
2025-07-22 13:46:05 +02:00
from typing import Dict, Any, Optional
2025-07-26 23:29:57 +02:00
from fastapi import HTTPException, status
2025-07-22 13:46:05 +02:00
import structlog
2025-07-17 14:34:24 +02:00
2025-08-08 09:08:41 +02:00
from app.repositories import UserRepository, TokenRepository
from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, UserResponse
2025-10-01 11:24:06 +02:00
from app.models.users import User
from app.models.tokens import RefreshToken
2025-07-20 08:22:17 +02:00
from app.core.security import SecurityManager
2025-12-05 20:07:01 +01:00
from shared.messaging import UnifiedEventPublisher, EVENT_TYPES
2025-08-08 09:08:41 +02:00
from shared.database.unit_of_work import UnitOfWork
from shared.database.transactions import transactional
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
2025-07-17 14:34:24 +02:00
2025-07-22 13:46:05 +02:00
logger = structlog.get_logger()
2025-07-17 14:34:24 +02:00
2025-07-22 13:46:05 +02:00
2025-08-08 09:08:41 +02:00
# Legacy compatibility alias
AuthService = None # Will be set at the end of the file
class EnhancedAuthService:
"""Enhanced authentication service using repository pattern"""
2025-12-05 20:07:01 +01:00
def __init__(self, database_manager, event_publisher=None):
"""Initialize service with database manager and optional event publisher"""
2025-08-08 09:08:41 +02:00
self.database_manager = database_manager
2025-12-05 20:07:01 +01:00
self.event_publisher = event_publisher
2025-08-08 09:08:41 +02:00
async def register_user(
self,
user_data: UserRegistration
) -> TokenResponse:
"""Register a new user using repository pattern"""
2025-07-20 08:22:17 +02:00
try:
2025-08-08 09:08:41 +02:00
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")
2025-08-08 09:08:41 +02:00
# 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"
2025-08-08 09:08:41 +02:00
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)
2025-10-16 07:28:04 +02:00
# 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
2025-10-07 07:15:07 +02:00
# 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
2025-10-07 07:15:07 +02:00
# 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",
2025-12-18 13:26:32 +01:00
"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()
}
2025-10-07 07:15:07 +02:00
# 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:
2025-10-07 07:15:07 +02:00
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
2025-10-16 07:28:04 +02:00
# Commit transaction (includes user, tokens, consent, and onboarding data)
2025-10-07 07:15:07 +02:00
await uow.commit()
2025-08-08 09:08:41 +02:00
# Publish registration event (non-blocking)
2025-12-05 20:07:01 +01:00
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", error=str(e))
2025-08-08 09:08:41 +02:00
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):
2025-07-22 13:46:05 +02:00
raise
2025-07-20 08:22:17 +02:00
except Exception as e:
2025-08-08 09:08:41 +02:00
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"""
2025-07-20 08:22:17 +02:00
try:
2025-08-08 09:08:41 +02:00
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)
# Create tokens with different payloads
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"
}
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)
2025-12-05 20:07:01 +01:00
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", error=str(e))
2025-08-08 09:08:41 +02:00
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
)
)
2025-07-20 08:22:17 +02:00
except HTTPException:
raise
except Exception as e:
2025-08-08 09:08:41 +02:00
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"""
2025-07-26 23:29:57 +02:00
try:
2025-08-08 09:08:41 +02:00
async with self.database_manager.get_session() as session:
2025-08-08 19:21:23 +02:00
token_repo = TokenRepository(RefreshToken, session)
2025-08-08 09:08:41 +02:00
# 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
2025-07-26 23:29:57 +02:00
except Exception as e:
2025-08-08 09:08:41 +02:00
logger.error("Logout failed using repository pattern",
user_id=user_id,
error=str(e))
2025-07-26 23:29:57 +02:00
return False
2025-08-08 09:08:41 +02:00
async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh access token using repository pattern"""
2025-07-20 08:22:17 +02:00
try:
# Verify refresh token
2025-07-26 23:29:57 +02:00
payload = SecurityManager.decode_token(refresh_token)
2025-07-20 08:22:17 +02:00
user_id = payload.get("user_id")
2025-07-26 23:29:57 +02:00
2025-07-20 08:22:17 +02:00
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
2025-07-26 23:29:57 +02:00
detail="Invalid refresh token"
2025-07-20 08:22:17 +02:00
)
2025-08-08 09:08:41 +02:00
async with self.database_manager.get_session() as session:
2025-08-08 19:21:23 +02:00
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
2025-08-08 09:08:41 +02:00
# 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"
)
# Create new access token
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_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
}
2025-07-20 08:22:17 +02:00
except HTTPException:
raise
except Exception as e:
2025-08-08 09:08:41 +02:00
logger.error("Token refresh failed using repository pattern", error=str(e))
2025-07-17 14:34:24 +02:00
raise HTTPException(
2025-07-26 23:29:57 +02:00
status_code=status.HTTP_401_UNAUTHORIZED,
2025-07-20 08:22:17 +02:00
detail="Token refresh failed"
2025-07-17 14:34:24 +02:00
)
2025-08-08 09:08:41 +02:00
async def verify_user_token(self, token: str) -> Dict[str, Any]:
"""Verify access token and return user info"""
2025-07-20 08:22:17 +02:00
try:
payload = SecurityManager.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return payload
except Exception as e:
2025-08-08 09:08:41 +02:00
logger.error("Token verification error using repository pattern", error=str(e))
2025-07-20 08:22:17 +02:00
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
2025-08-08 09:08:41 +02:00
)
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:
2025-08-08 19:21:23 +02:00
user_repo = UserRepository(User, session)
2025-08-08 09:08:41 +02:00
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:
2025-08-08 19:21:23 +02:00
user_repo = UserRepository(User, session)
2025-08-08 09:08:41 +02:00
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:
2025-08-08 19:21:23 +02:00
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
2025-08-08 09:08:41 +02:00
# 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"
)
2025-08-08 09:08:41 +02:00
# 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:
2025-08-08 19:21:23 +02:00
user_repo = UserRepository(User, session)
2025-08-08 09:08:41 +02:00
# 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:
2025-08-08 19:21:23 +02:00
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
2025-08-08 09:08:41 +02:00
# 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
# Legacy compatibility - alias EnhancedAuthService as AuthService
AuthService = EnhancedAuthService
class EnhancedUserService(EnhancedAuthService):
"""User service alias for backward compatibility"""
pass