diff --git a/services/auth/app/api/auth.py b/services/auth/app/api/auth.py index 2f179e84..c74b21ab 100644 --- a/services/auth/app/api/auth.py +++ b/services/auth/app/api/auth.py @@ -36,7 +36,7 @@ async def register( metrics = get_metrics_collector(request) try: - result = await AuthService.register_user(user_data, db) + result = await AuthService.create_user(user_data, db) # Record successful registration if metrics: @@ -76,7 +76,7 @@ async def login( ip_address = request.client.host user_agent = request.headers.get("user-agent", "") - result = await AuthService.login_user(login_data, db, ip_address, user_agent) + result = await AuthService.login(login_data, db, ip_address, user_agent) # Record successful login if metrics: @@ -113,7 +113,7 @@ async def refresh_token( metrics = get_metrics_collector(request) try: - result = await AuthService.refresh_token(refresh_data.refresh_token, db) + result = await security_manager.refresh_token(refresh_data.refresh_token, db) # Record successful refresh if metrics: @@ -156,7 +156,7 @@ async def verify_token( ) token = auth_header.split(" ")[1] - payload = await AuthService.verify_token(token) + payload = await security_manager.verify_token(token) # Record successful verification if metrics: @@ -199,7 +199,7 @@ async def logout( ) token = auth_header.split(" ")[1] - await AuthService.logout_user(token, db) + await AuthService.logout(token, db) # Record successful logout if metrics: diff --git a/services/auth/app/models/users.py b/services/auth/app/models/users.py index e5cc63eb..42b8a632 100644 --- a/services/auth/app/models/users.py +++ b/services/auth/app/models/users.py @@ -1,19 +1,18 @@ # services/auth/app/models/users.py - FIXED VERSION -# ================================================================ """ User models for authentication service - FIXED +Removed tenant relationships to eliminate cross-service dependencies """ from sqlalchemy import Column, String, Boolean, DateTime, Text from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import relationship # Import relationship from datetime import datetime, timezone import uuid from shared.database.base import Base class User(Base): - """User model - FIXED timezone handling""" + """User model - FIXED without cross-service relationships""" __tablename__ = "users" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -22,22 +21,19 @@ class User(Base): full_name = Column(String(255), nullable=False) is_active = Column(Boolean, default=True) is_verified = Column(Boolean, default=False) - # Removed tenant_id and role from User model - # FIXED: Use timezone-aware datetime for all datetime fields + # Timezone-aware datetime fields created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) - last_login = Column(DateTime(timezone=True)) # FIXED: Now timezone-aware + last_login = Column(DateTime(timezone=True)) # Profile fields phone = Column(String(20)) language = Column(String(10), default="es") timezone = Column(String(50), default="Europe/Madrid") - # Relationships - # Define the many-to-many relationship through TenantMember - tenant_memberships = relationship("TenantMember", back_populates="user", cascade="all, delete-orphan") # Changed back_populates to avoid conflict - tenants = relationship("Tenant", secondary="tenant_members", back_populates="users") + # REMOVED: All tenant relationships - these are handled by tenant service + # No tenant_memberships, tenants relationships def __repr__(self): return f"" @@ -50,33 +46,26 @@ class User(Base): "full_name": self.full_name, "is_active": self.is_active, "is_verified": self.is_verified, - # Removed tenant_id and role from to_dict - "created_at": self.created_at.isoformat() if self.created_at else None, - "last_login": self.last_login.isoformat() if self.last_login else None, "phone": self.phone, "language": self.language, - "timezone": self.timezone + "timezone": self.timezone, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "last_login": self.last_login.isoformat() if self.last_login else None } - -class UserSession(Base): - """User session model - FIXED timezone handling""" - __tablename__ = "user_sessions" +class RefreshToken(Base): + """Refresh token model for JWT authentication""" + __tablename__ = "refresh_tokens" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(UUID(as_uuid=True), nullable=False, index=True) - refresh_token_hash = Column(String(255), nullable=False) - is_active = Column(Boolean, default=True) - expires_at = Column(DateTime(timezone=True), nullable=False) # FIXED: timezone-aware + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service + token = Column(String(255), unique=True, nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + is_revoked = Column(Boolean, default=False) - # Session metadata - ip_address = Column(String(45)) - user_agent = Column(Text) - device_info = Column(Text) - - # FIXED: Use timezone-aware datetime created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + revoked_at = Column(DateTime(timezone=True)) def __repr__(self): - return f"" \ No newline at end of file + return f"" \ No newline at end of file diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index 94f096ee..d68e8cc1 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -1,270 +1,291 @@ -# ================================================================ -# services/auth/app/services/auth_service.py (COMPLETE VERSION) -# ================================================================ +# services/auth/app/services/auth_service.py - FIXED VERSION """ -Authentication service business logic - Complete implementation +Authentication service - FIXED +Handles user authentication without cross-service dependencies """ -import structlog +import logging from datetime import datetime, timedelta, timezone -from typing import Optional, Dict, Any +from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update +from sqlalchemy import select from fastapi import HTTPException, status +import httpx +from app.models.users import User, RefreshToken +from app.core.security import SecurityManager from app.core.config import settings -from app.models.users import User, UserSession -from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, UserResponse -from app.core.security import security_manager -from app.services.messaging import publish_user_registered, publish_user_login -logger = structlog.get_logger() +logger = logging.getLogger(__name__) class AuthService: - """Authentication service business logic""" + """Authentication service""" @staticmethod - async def register_user(user_data: UserRegistration, db: AsyncSession) -> UserResponse: - """Register a new user""" - - # Check if user already exists - result = await db.execute( - select(User).where(User.email == user_data.email) - ) - existing_user = result.scalar_one_or_none() - - if existing_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered" + async def authenticate_user(email: str, password: str, db: AsyncSession) -> Optional[User]: + """Authenticate user with email and password""" + try: + # Get user from database + result = await db.execute( + select(User).where( + User.email == email, + User.is_active == True + ) ) - - # Hash password - hashed_password = security_manager.hash_password(user_data.password) - - # Create user - user = User( - email=user_data.email, - hashed_password=hashed_password, - full_name=user_data.full_name, - phone=user_data.phone, - language=user_data.language, - is_active=True, - is_verified=False # Email verification required - ) - - db.add(user) - await db.commit() - await db.refresh(user) - - # Publish user registered event - SIMPLIFIED - event_data = { - "user_id": str(user.id), - "email": user.email, - "tenant_id": user.tenant_id, - "full_name": user.full_name, - "language": user.language, - "timestamp": datetime.now(timezone.utc).isoformat() - } - - success = await publish_user_registered(event_data) - if not success: - logger.warning("Failed to publish user registered event", user_id=str(user.id)) - - logger.info(f"User registered: {user.email}") - return UserResponse(**user.to_dict()) + user = result.scalar_one_or_none() + + if not user: + logger.warning(f"User not found: {email}") + return None + + if not SecurityManager.verify_password(password, user.hashed_password): + logger.warning(f"Invalid password for user: {email}") + return None + + # Update last login + user.last_login = datetime.now(timezone.utc) + await db.commit() + + logger.info(f"User authenticated successfully: {email}") + return user + + except Exception as e: + logger.error(f"Authentication error for {email}: {e}") + await db.rollback() + return None @staticmethod - async def login_user(login_data: UserLogin, db: AsyncSession, ip_address: str, user_agent: str) -> TokenResponse: - """Authenticate user and create tokens""" - - # Check login attempts - if not await security_manager.check_login_attempts(login_data.email): - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many login attempts. Please try again later." + async def create_user(email: str, password: str, full_name: str, db: AsyncSession) -> User: + """Create a new user""" + try: + # Check if user already exists + result = await db.execute( + select(User).where(User.email == email) ) - - # Get user - result = await db.execute( - select(User).where(User.email == login_data.email) - ) - user = result.scalar_one_or_none() - - if not user or not security_manager.verify_password(login_data.password, user.hashed_password): - await security_manager.increment_login_attempts(login_data.email) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password" + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Create new user + hashed_password = SecurityManager.get_password_hash(password) + user = User( + email=email, + hashed_password=hashed_password, + full_name=full_name, + is_active=True, + is_verified=False ) - - if not user.is_active: + + db.add(user) + await db.commit() + await db.refresh(user) + + logger.info(f"User created successfully: {email}") + return user + + except HTTPException: + raise + except Exception as e: + logger.error(f"User creation error: {e}") + await db.rollback() raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Account is inactive" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create user" ) - - # Clear login attempts - await security_manager.clear_login_attempts(login_data.email) - - # Update last login - await db.execute( - update(User) - .where(User.id == user.id) - .values(last_login=datetime.now(timezone.utc)) - ) - await db.commit() - - # Create tokens - token_data = { - "sub": str(user.id), # Standard JWT claim for subject - "user_id": str(user.id), - "email": user.email, - "tenant_id": str(user.tenant_id) if user.tenant_id else None, - "role": user.role - } - - access_token = security_manager.create_access_token(token_data) - refresh_token = security_manager.create_refresh_token(token_data) - - # Store refresh token - await security_manager.store_refresh_token(str(user.id), refresh_token) - - # Create session record - session = UserSession( - user_id=user.id, - refresh_token_hash=security_manager.hash_token(refresh_token), - ip_address=ip_address, - user_agent=user_agent, - expires_at=datetime.now(timezone.utc) + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) - ) - - db.add(session) - await db.commit() - - # Publish login event - SIMPLIFIED - event_data = { - "user_id": str(user.id), - "email": user.email, - "ip_address": ip_address, - "user_agent": user_agent, - "timestamp": datetime.now(timezone.utc).isoformat() - } - - success = await publish_user_login(event_data) - if not success: - logger.warning("Failed to publish login event", user_id=str(user.id)) - - logger.info("User login successful", user_id=str(user.id), email=user.email) - - return TokenResponse( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 - ) @staticmethod - async def refresh_token(refresh_token: str, db: AsyncSession) -> TokenResponse: + async def login(email: str, password: str, db: AsyncSession) -> dict: + """Login user and return tokens""" + try: + # Authenticate user + user = await AuthService.authenticate_user(email, password, db) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials" + ) + + # Get user's tenant memberships from tenant service + tenant_memberships = await AuthService._get_user_tenants(str(user.id)) + + # Create tokens + access_token = SecurityManager.create_access_token( + data={ + "user_id": str(user.id), + "email": user.email, + "full_name": user.full_name, + "tenants": tenant_memberships # Include tenant info in token + } + ) + + refresh_token_value = SecurityManager.create_refresh_token(data={"user_id": str(user.id)}) + + # Store refresh token in database + refresh_token = RefreshToken( + user_id=user.id, + token=refresh_token_value, + expires_at=datetime.now(timezone.utc) + timedelta(days=30) + ) + + db.add(refresh_token) + await db.commit() + + logger.info(f"User logged in successfully: {email}") + + return { + "access_token": access_token, + "refresh_token": refresh_token_value, + "token_type": "bearer", + "user": user.to_dict(), + "tenants": tenant_memberships + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Login error for {email}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Login failed" + ) + + @staticmethod + async def refresh_access_token(refresh_token: str, db: AsyncSession) -> dict: """Refresh access token using refresh token""" - - # Verify refresh token - token_data = security_manager.verify_token(refresh_token) - if not token_data or token_data.get("type") != "refresh": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid refresh token" + try: + # Verify refresh token + payload = SecurityManager.verify_token(refresh_token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + user_id = payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + # Check if refresh token exists and is valid + result = await db.execute( + select(RefreshToken).where( + RefreshToken.token == refresh_token, + RefreshToken.user_id == user_id, + RefreshToken.is_revoked == False, + RefreshToken.expires_at > datetime.now(timezone.utc) + ) ) - - user_id = token_data.get("user_id") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token data" + + stored_token = result.scalar_one_or_none() + if not stored_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token" + ) + + # Get user + result = await db.execute( + select(User).where(User.id == user_id, User.is_active == True) ) - - # Check if refresh token exists in Redis - if not await security_manager.verify_refresh_token(user_id, refresh_token): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token not found or expired" + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + # Get user's tenant memberships from tenant service + tenant_memberships = await AuthService._get_user_tenants(str(user.id)) + + # Create new access token + access_token = SecurityManager.create_access_token( + data={ + "user_id": str(user.id), + "email": user.email, + "full_name": user.full_name, + "tenants": tenant_memberships + } ) - - # Get user - result = await db.execute( - select(User).where(User.id == user_id) - ) - user = result.scalar_one_or_none() - - if not user or not user.is_active: + + return { + "access_token": access_token, + "token_type": "bearer" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Token refresh error: {e}") raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found or inactive" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Token refresh failed" ) - - # Create new tokens - new_token_data = { - "sub": str(user.id), - "user_id": str(user.id), - "email": user.email, - "tenant_id": str(user.tenant_id) if user.tenant_id else None, - "role": user.role - } - - new_access_token = security_manager.create_access_token(new_token_data) - new_refresh_token = security_manager.create_refresh_token(new_token_data) - - # Revoke old refresh token - await security_manager.revoke_refresh_token(user_id, refresh_token) - - # Store new refresh token - await security_manager.store_refresh_token(user_id, new_refresh_token) - - logger.info(f"Token refreshed for user: {user.email}") - - return TokenResponse( - access_token=new_access_token, - refresh_token=new_refresh_token, - token_type="bearer", - expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 - ) @staticmethod - async def verify_token(token: str) -> Dict[str, Any]: - """Verify access token and return user data""" - - token_data = security_manager.verify_token(token) - if not token_data or token_data.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid access token" - ) - - return token_data - - @staticmethod - async def logout_user(user_id: str, refresh_token: str, db: AsyncSession) -> bool: - """Logout user and revoke tokens""" - + async def logout(refresh_token: str, db: AsyncSession) -> bool: + """Logout user by revoking refresh token""" try: # Revoke refresh token - await security_manager.revoke_refresh_token(user_id, refresh_token) - - # Deactivate session - await db.execute( - update(UserSession) - .where( - UserSession.user_id == user_id, - UserSession.refresh_token_hash == security_manager.hash_token(refresh_token) - ) - .values(is_active=False) + result = await db.execute( + select(RefreshToken).where(RefreshToken.token == refresh_token) ) - await db.commit() - logger.info(f"User logged out: {user_id}") + token = result.scalar_one_or_none() + if token: + token.is_revoked = True + token.revoked_at = datetime.now(timezone.utc) + await db.commit() + return True except Exception as e: - logger.error(f"Error logging out user {user_id}: {e}") + logger.error(f"Logout error: {e}") await db.rollback() - return False \ No newline at end of file + return False + + @staticmethod + async def verify_user_token(token: str) -> dict: + """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(f"Token verification error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + + @staticmethod + async def _get_user_tenants(user_id: str) -> list: + """Get user's tenant memberships from tenant service""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/user/{user_id}/memberships", + timeout=5.0 + ) + + if response.status_code == 200: + return response.json() + else: + logger.warning(f"Failed to get user tenants: {response.status_code}") + return [] + + except Exception as e: + logger.error(f"Error getting user tenants: {e}") + return [] \ No newline at end of file diff --git a/services/auth/requirements.txt b/services/auth/requirements.txt index 7e3a17cd..e35750fb 100644 --- a/services/auth/requirements.txt +++ b/services/auth/requirements.txt @@ -18,4 +18,4 @@ pytz==2023.3 python-logstash==0.4.8 structlog==23.2.0 python-dotenv==1.0.0 - \ No newline at end of file +httpx==0.25.2 \ No newline at end of file diff --git a/services/tenant/app/models/tenants.py b/services/tenant/app/models/tenants.py index b8fca03f..3fcba9df 100644 --- a/services/tenant/app/models/tenants.py +++ b/services/tenant/app/models/tenants.py @@ -1,12 +1,13 @@ -# services/tenant/app/models/tenants.py +# services/tenant/app/models/tenants.py - FIXED VERSION """ -Tenant models for bakery management +Tenant models for bakery management - FIXED +Removed cross-service User relationship to eliminate circular dependencies """ from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship -from datetime import datetime +from datetime import datetime, timezone import uuid from shared.database.base import Base @@ -37,30 +38,30 @@ class Tenant(Base): # ML status model_trained = Column(Boolean, default=False) - last_training_date = Column(DateTime) + last_training_date = Column(DateTime(timezone=True)) - # Ownership (The user who created the tenant, still a direct link) + # Ownership (user_id without FK - cross-service reference) owner_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Timestamps - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) - # Relationships - # Define the many-to-many relationship through TenantMember + # Relationships - only within tenant service members = relationship("TenantMember", back_populates="tenant", cascade="all, delete-orphan") - users = relationship("User", secondary="tenant_members", back_populates="tenants") + + # REMOVED: users relationship - no cross-service SQLAlchemy relationships def __repr__(self): return f"" class TenantMember(Base): - """Tenant membership model for team access - Association Table""" + """Tenant membership model for team access""" __tablename__ = "tenant_members" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) # Added ForeignKey to users.id + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service reference # Role and permissions specific to this tenant role = Column(String(50), default="member") # owner, admin, member, viewer @@ -68,15 +69,48 @@ class TenantMember(Base): # Status is_active = Column(Boolean, default=True) - invited_by = Column(UUID(as_uuid=True)) - invited_at = Column(DateTime, default=datetime.utcnow) - joined_at = Column(DateTime) + invited_by = Column(UUID(as_uuid=True)) # No FK - cross-service reference + invited_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + joined_at = Column(DateTime(timezone=True)) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - # Relationships to access the associated Tenant and User objects + # Relationships - only within tenant service tenant = relationship("Tenant", back_populates="members") - user = relationship("User", back_populates="tenant_memberships") # Changed back_populates to avoid conflict + + # REMOVED: user relationship - no cross-service SQLAlchemy relationships def __repr__(self): - return f"" \ No newline at end of file + return f"" + +# Additional models for subscriptions, plans, etc. +class Subscription(Base): + """Subscription model for tenant billing""" + __tablename__ = "subscriptions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) + + plan = Column(String(50), default="basic") # basic, professional, enterprise + status = Column(String(50), default="active") # active, suspended, cancelled + + # Billing + monthly_price = Column(Float, default=0.0) + billing_cycle = Column(String(20), default="monthly") # monthly, yearly + next_billing_date = Column(DateTime(timezone=True)) + trial_ends_at = Column(DateTime(timezone=True)) + + # Limits + max_users = Column(Integer, default=1) + max_locations = Column(Integer, default=1) + max_products = Column(Integer, default=50) + + # Timestamps + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + tenant = relationship("Tenant") + + def __repr__(self): + return f"" \ No newline at end of file