Improve auth process

This commit is contained in:
Urtzi Alfaro
2025-07-20 08:22:17 +02:00
parent 5f56c2fd00
commit 8486d1db7c
5 changed files with 331 additions and 287 deletions

View File

@@ -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:

View File

@@ -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"<User(id={self.id}, email={self.email})>"
@@ -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"<UserSession(id={self.id}, user_id={self.user_id})>"
return f"<RefreshToken(user_id={self.user_id}, expires_at={self.expires_at})>"

View File

@@ -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
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 []

View File

@@ -18,4 +18,4 @@ pytz==2023.3
python-logstash==0.4.8
structlog==23.2.0
python-dotenv==1.0.0
httpx==0.25.2