578 lines
25 KiB
Python
578 lines
25 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.services.messaging import publish_user_registered, publish_user_login
|
|
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):
|
|
"""Initialize service with database manager"""
|
|
self.database_manager = database_manager
|
|
|
|
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)
|
|
|
|
# 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",
|
|
"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, and onboarding data)
|
|
await uow.commit()
|
|
|
|
# Publish registration event (non-blocking)
|
|
try:
|
|
await publish_user_registered({
|
|
"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))
|
|
|
|
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)
|
|
|
|
# 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)
|
|
try:
|
|
await publish_user_login({
|
|
"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))
|
|
|
|
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"
|
|
)
|
|
|
|
# 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
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Token refresh failed using repository pattern", error=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"
|
|
)
|
|
|
|
return payload
|
|
|
|
except Exception as e:
|
|
logger.error("Token verification error using repository pattern", error=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
|
|
|
|
|
|
# Legacy compatibility - alias EnhancedAuthService as AuthService
|
|
AuthService = EnhancedAuthService
|
|
|
|
|
|
class EnhancedUserService(EnhancedAuthService):
|
|
"""User service alias for backward compatibility"""
|
|
pass |