Few fixes

This commit is contained in:
Urtzi Alfaro
2025-07-17 14:34:24 +02:00
parent 5bb3e93da4
commit cb80a93c4b
36 changed files with 1512 additions and 141 deletions

View File

@@ -15,7 +15,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy shared libraries
COPY --from=shared /shared /app/shared
COPY shared/ /app/shared/
# Copy application code
COPY . .

View File

@@ -3,7 +3,7 @@ Authentication service configuration
"""
import os
from pydantic import BaseSettings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings"""

View File

View File

@@ -0,0 +1,74 @@
"""
User models for authentication service
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
import uuid
from shared.database.base import Base
class User(Base):
"""User model"""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
tenant_id = Column(UUID(as_uuid=True), nullable=True)
role = Column(String(50), default="user") # user, admin, super_admin
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
last_login = Column(DateTime)
# Profile fields
phone = Column(String(20))
language = Column(String(10), default="es")
timezone = Column(String(50), default="Europe/Madrid")
def __repr__(self):
return f"<User(id={self.id}, email={self.email})>"
def to_dict(self):
"""Convert user to dictionary"""
return {
"id": str(self.id),
"email": self.email,
"full_name": self.full_name,
"is_active": self.is_active,
"is_verified": self.is_verified,
"tenant_id": str(self.tenant_id) if self.tenant_id else None,
"role": self.role,
"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
}
class UserSession(Base):
"""User session model"""
__tablename__ = "user_sessions"
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, nullable=False)
# Session metadata
ip_address = Column(String(45))
user_agent = Column(Text)
device_info = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<UserSession(id={self.id}, user_id={self.user_id})>"

View File

@@ -15,7 +15,7 @@ class UserRegistration(BaseModel):
password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
full_name: str = Field(..., min_length=2, max_length=100)
phone: Optional[str] = None
language: str = Field(default="es", regex="^(es|en)$")
language: str = Field(default="es", pattern="^(es|en)$")
@validator('password')
def validate_password(cls, v):
@@ -97,7 +97,7 @@ class UserUpdate(BaseModel):
"""User update schema"""
full_name: Optional[str] = Field(None, min_length=2, max_length=100)
phone: Optional[str] = None
language: Optional[str] = Field(None, regex="^(es|en)$")
language: Optional[str] = Field(None, pattern="^(es|en)$")
timezone: Optional[str] = None
@validator('phone')

View File

@@ -0,0 +1,284 @@
"""
Authentication service business logic
"""
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from fastapi import HTTPException, status
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 message_publisher
from shared.messaging.events import UserRegisteredEvent, UserLoginEvent
logger = logging.getLogger(__name__)
class AuthService:
"""Authentication service business logic"""
@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"
)
# 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
await message_publisher.publish_event(
"user_events",
"user.registered",
UserRegisteredEvent(
event_id="",
service_name="auth-service",
timestamp=datetime.utcnow(),
data={
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"language": user.language
}
).__dict__
)
logger.info(f"User registered: {user.email}")
return UserResponse(**user.to_dict())
@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."
)
# 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"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is inactive"
)
# 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.utcnow())
)
await db.commit()
# Create tokens
token_data = {
"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_password(refresh_token),
expires_at=datetime.utcnow() + timedelta(days=7),
ip_address=ip_address,
user_agent=user_agent
)
db.add(session)
await db.commit()
# Publish login event
await message_publisher.publish_event(
"user_events",
"user.login",
UserLoginEvent(
event_id="",
service_name="auth-service",
timestamp=datetime.utcnow(),
data={
"user_id": str(user.id),
"email": user.email,
"ip_address": ip_address,
"user_agent": user_agent
}
).__dict__
)
logger.info(f"User logged in: {user.email}")
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@staticmethod
async def refresh_token(refresh_token: str, db: AsyncSession) -> TokenResponse:
"""Refresh access token"""
# Verify refresh token
payload = security_manager.verify_token(refresh_token)
if not payload or payload.get("type") != "refresh":
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"
)
# Verify refresh token is stored
if not await security_manager.verify_refresh_token(user_id, refresh_token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 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:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# Create new tokens
token_data = {
"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(token_data)
new_refresh_token = security_manager.create_refresh_token(token_data)
# Update stored refresh token
await security_manager.store_refresh_token(str(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,
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@staticmethod
async def verify_token(token: str, db: AsyncSession) -> Dict[str, Any]:
"""Verify access token"""
# Verify token
payload = security_manager.verify_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
user_id = payload.get("user_id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
# 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:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
return {
"user_id": str(user.id),
"email": user.email,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role": user.role,
"full_name": user.full_name,
"is_verified": user.is_verified
}
@staticmethod
async def logout_user(user_id: str, db: AsyncSession):
"""Logout user and revoke tokens"""
# Revoke refresh token
await security_manager.revoke_refresh_token(user_id)
# Deactivate user sessions
await db.execute(
update(UserSession)
.where(UserSession.user_id == user_id)
.values(is_active=False)
)
await db.commit()
logger.info(f"User logged out: {user_id}")

View File

@@ -3,7 +3,7 @@ Shared JWT Authentication Handler
Used across all microservices for consistent authentication
"""
import jwt
from jose import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import logging
@@ -53,6 +53,6 @@ class JWTHandler:
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
return None
except jwt.InvalidTokenError:
except jwt.JWTError:
logger.warning("Invalid token")
return None