Adds auth module

This commit is contained in:
Urtzi Alfaro
2025-07-17 21:25:27 +02:00
parent 654d1c2fe8
commit efca9a125a
19 changed files with 1169 additions and 406 deletions

View File

@@ -1,5 +1,8 @@
# ================================================================
# services/auth/app/api/auth.py (ENHANCED VERSION)
# ================================================================
"""
Authentication API routes
Authentication API routes - Enhanced with proper error handling and logging
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
@@ -7,12 +10,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
import logging
from app.core.database import get_db
from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest, UserResponse
from app.schemas.auth import (
UserRegistration, UserLogin, TokenResponse,
RefreshTokenRequest, UserResponse
)
from app.services.auth_service import AuthService
from app.core.security import security_manager
from shared.monitoring.metrics import MetricsCollector
logger = logging.getLogger(__name__)
router = APIRouter()
metrics = MetricsCollector("auth_service")
@router.post("/register", response_model=UserResponse)
async def register(
@@ -21,11 +29,14 @@ async def register(
):
"""Register a new user"""
try:
return await AuthService.register_user(user_data, db)
metrics.increment_counter("auth_registration_total")
result = await AuthService.register_user(user_data, db)
logger.info(f"User registration successful: {user_data.email}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration error: {e}")
logger.error(f"Registration error for {user_data.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration failed"
@@ -42,11 +53,16 @@ async def login(
ip_address = request.client.host
user_agent = request.headers.get("user-agent", "")
return await AuthService.login_user(login_data, db, ip_address, user_agent)
except HTTPException:
result = await AuthService.login_user(login_data, db, ip_address, user_agent)
metrics.increment_counter("auth_login_success_total")
return result
except HTTPException as e:
metrics.increment_counter("auth_login_failure_total")
logger.warning(f"Login failed for {login_data.email}: {e.detail}")
raise
except Exception as e:
logger.error(f"Login error: {e}")
metrics.increment_counter("auth_login_failure_total")
logger.error(f"Login error for {login_data.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed"
@@ -84,7 +100,15 @@ async def verify_token(
)
token = auth_header.split(" ")[1]
return await AuthService.verify_token(token, db)
token_data = await AuthService.verify_token(token)
return {
"valid": True,
"user_id": token_data.get("user_id"),
"email": token_data.get("email"),
"role": token_data.get("role"),
"tenant_id": token_data.get("tenant_id")
}
except HTTPException:
raise
except Exception as e:
@@ -96,29 +120,27 @@ async def verify_token(
@router.post("/logout")
async def logout(
refresh_data: RefreshTokenRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""User logout"""
"""Logout user"""
try:
# Get user from token
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required"
)
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ")[1]
token_data = await AuthService.verify_token(token)
user_id = token_data.get("user_id")
if user_id:
success = await AuthService.logout_user(user_id, refresh_data.refresh_token, db)
return {"success": success}
token = auth_header.split(" ")[1]
user_data = await AuthService.verify_token(token, db)
await AuthService.logout_user(user_data["user_id"], db)
return {"message": "Logged out successfully"}
except HTTPException:
raise
return {"success": False, "message": "Invalid token"}
except Exception as e:
logger.error(f"Logout error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Logout failed"
)
)

View File

@@ -1,12 +1,51 @@
# ================================================================
# services/auth/app/core/database.py (ENHANCED VERSION)
# ================================================================
"""
Database configuration for auth service
Database configuration for authentication service
"""
from shared.database.base import DatabaseManager
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.core.config import settings
from shared.database.base import Base
# Initialize database manager
database_manager = DatabaseManager(settings.DATABASE_URL)
logger = logging.getLogger(__name__)
# Alias for convenience
get_db = database_manager.get_db
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
poolclass=NullPool,
echo=settings.DEBUG,
future=True
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False
)
async def get_db() -> AsyncSession:
"""Database dependency"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception as e:
await session.rollback()
logger.error(f"Database session error: {e}")
raise
finally:
await session.close()
async def create_tables():
"""Create database tables"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created successfully")

View File

@@ -1,9 +1,13 @@
# ================================================================
# services/auth/app/core/security.py (COMPLETE VERSION)
# ================================================================
"""
Security utilities for authentication service
"""
import bcrypt
import re
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import redis.asyncio as redis
@@ -72,6 +76,11 @@ class SecurityManager:
"""Verify JWT token"""
return jwt_handler.verify_token(token)
@staticmethod
def hash_token(token: str) -> str:
"""Hash token for storage"""
return hashlib.sha256(token.encode()).hexdigest()
@staticmethod
async def check_login_attempts(email: str) -> bool:
"""Check if user has exceeded login attempts"""
@@ -83,71 +92,64 @@ class SecurityManager:
return True
return int(attempts) < settings.MAX_LOGIN_ATTEMPTS
except Exception as e:
logger.error(f"Error checking login attempts: {e}")
return True
return True # Allow on error
@staticmethod
async def increment_login_attempts(email: str):
"""Increment login attempts counter"""
async def increment_login_attempts(email: str) -> None:
"""Increment login attempts for email"""
try:
key = f"login_attempts:{email}"
current_attempts = await redis_client.incr(key)
# Set TTL on first attempt
if current_attempts == 1:
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
await redis_client.incr(key)
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
except Exception as e:
logger.error(f"Error incrementing login attempts: {e}")
@staticmethod
async def clear_login_attempts(email: str):
"""Clear login attempts counter"""
async def clear_login_attempts(email: str) -> None:
"""Clear login attempts for email"""
try:
key = f"login_attempts:{email}"
await redis_client.delete(key)
except Exception as e:
logger.error(f"Error clearing login attempts: {e}")
@staticmethod
async def store_refresh_token(user_id: str, refresh_token: str):
async def store_refresh_token(user_id: str, token: str) -> None:
"""Store refresh token in Redis"""
try:
key = f"refresh_token:{user_id}"
expires_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600
await redis_client.setex(key, expires_seconds, refresh_token)
token_hash = SecurityManager.hash_token(token)
key = f"refresh_token:{user_id}:{token_hash}"
# Store for the duration of the refresh token
expire_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
await redis_client.setex(key, expire_seconds, "valid")
except Exception as e:
logger.error(f"Error storing refresh token: {e}")
@staticmethod
async def verify_refresh_token(user_id: str, refresh_token: str) -> bool:
"""Verify refresh token"""
async def verify_refresh_token(user_id: str, token: str) -> bool:
"""Verify refresh token exists in Redis"""
try:
key = f"refresh_token:{user_id}"
stored_token = await redis_client.get(key)
if stored_token is None:
return False
return stored_token.decode() == refresh_token
token_hash = SecurityManager.hash_token(token)
key = f"refresh_token:{user_id}:{token_hash}"
result = await redis_client.get(key)
return result is not None
except Exception as e:
logger.error(f"Error verifying refresh token: {e}")
return False
@staticmethod
async def revoke_refresh_token(user_id: str):
async def revoke_refresh_token(user_id: str, token: str) -> None:
"""Revoke refresh token"""
try:
key = f"refresh_token:{user_id}"
token_hash = SecurityManager.hash_token(token)
key = f"refresh_token:{user_id}:{token_hash}"
await redis_client.delete(key)
except Exception as e:
logger.error(f"Error revoking refresh token: {e}")
# Global security manager instance
security_manager = SecurityManager()
# Create singleton instance
security_manager = SecurityManager()

View File

@@ -1,18 +1,21 @@
# ================================================================
# services/auth/app/main.py (ENHANCED VERSION)
# ================================================================
"""
Authentication Service
Handles user authentication, registration, and token management
Authentication Service Main Application
Enhanced version with proper lifecycle management and microservices integration
"""
import logging
from datetime import timedelta
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from app.core.config import settings
from app.core.database import database_manager
from app.core.database import engine, create_tables
from app.api import auth, users
from app.services.messaging import message_publisher
from app.services.messaging import setup_messaging, cleanup_messaging
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
@@ -20,64 +23,101 @@ from shared.monitoring.metrics import MetricsCollector
setup_logging("auth-service", settings.LOG_LEVEL)
logger = logging.getLogger(__name__)
# Initialize metrics
metrics = MetricsCollector("auth_service")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events"""
# Startup
logger.info("Starting Authentication Service...")
# Create database tables
await create_tables()
logger.info("Database tables created")
# Setup messaging
await setup_messaging()
logger.info("Messaging setup complete")
# Register metrics
metrics.register_counter("auth_requests_total", "Total authentication requests")
metrics.register_counter("auth_login_success_total", "Successful logins")
metrics.register_counter("auth_login_failure_total", "Failed logins")
metrics.register_counter("auth_registration_total", "User registrations")
metrics.register_histogram("auth_request_duration_seconds", "Request duration")
logger.info("Authentication Service started successfully")
yield
# Shutdown
logger.info("Shutting down Authentication Service...")
await cleanup_messaging()
await engine.dispose()
logger.info("Authentication Service shutdown complete")
# Create FastAPI app
app = FastAPI(
title="Authentication Service",
description="User authentication and authorization service",
version="1.0.0"
description="Handles user authentication and authorization for bakery forecasting platform",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan
)
# Initialize metrics collector
metrics_collector = MetricsCollector("auth-service")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=["*"], # Configure properly for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/auth", tags=["authentication"])
app.include_router(users.router, prefix="/users", tags=["users"])
@app.on_event("startup")
async def startup_event():
"""Application startup"""
logger.info("Starting Authentication Service")
# Create database tables
await database_manager.create_tables()
# Initialize message publisher
await message_publisher.connect()
# Start metrics server
metrics_collector.start_metrics_server(8080)
logger.info("Authentication Service started successfully")
@app.on_event("shutdown")
async def shutdown_event():
"""Application shutdown"""
logger.info("Shutting down Authentication Service")
# Cleanup message publisher
await message_publisher.disconnect()
logger.info("Authentication Service shutdown complete")
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
# Health check endpoint
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "auth-service",
"status": "healthy",
"version": "1.0.0"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
# Metrics endpoint
@app.get("/metrics")
async def get_metrics():
"""Prometheus metrics endpoint"""
return metrics.get_metrics()
# Exception handlers
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Global exception handler"""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"}
)
# Request middleware for metrics
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
"""Middleware to collect metrics"""
import time
start_time = time.time()
response = await call_next(request)
# Record metrics
duration = time.time() - start_time
metrics.record_histogram("auth_request_duration_seconds", duration)
metrics.increment_counter("auth_requests_total")
return response

View File

@@ -0,0 +1,51 @@
# ================================================================
# services/auth/app/models/tokens.py
# ================================================================
"""
Token models for authentication service
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from shared.database.base import Base
class RefreshToken(Base):
"""Refresh token model"""
__tablename__ = "refresh_tokens"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash = Column(String(255), nullable=False, unique=True)
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)
revoked_at = Column(DateTime)
def __repr__(self):
return f"<RefreshToken(id={self.id}, user_id={self.user_id})>"
class LoginAttempt(Base):
"""Login attempt tracking model"""
__tablename__ = "login_attempts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=False)
user_agent = Column(Text)
success = Column(Boolean, default=False)
failure_reason = Column(String(255))
created_at = Column(DateTime, default=datetime.utcnow)
def __repr__(self):
return f"<LoginAttempt(id={self.id}, email={self.email}, success={self.success})>"

View File

@@ -0,0 +1,44 @@
# ================================================================
# services/auth/app/schemas/users.py
# ================================================================
"""
User schemas
"""
from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional
from datetime import datetime
from shared.utils.validation import validate_spanish_phone
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, pattern="^(es|en)$")
timezone: Optional[str] = None
@validator('phone')
def validate_phone(cls, v):
"""Validate phone number"""
if v and not validate_spanish_phone(v):
raise ValueError('Invalid Spanish phone number')
return v
class UserProfile(BaseModel):
"""User profile schema"""
id: str
email: str
full_name: str
phone: Optional[str]
language: str
timezone: str
is_active: bool
is_verified: bool
tenant_id: Optional[str]
role: str
created_at: datetime
last_login: Optional[datetime]
class Config:
from_attributes = True

View File

@@ -1,5 +1,8 @@
# ================================================================
# services/auth/app/services/auth_service.py (COMPLETE VERSION)
# ================================================================
"""
Authentication service business logic
Authentication service business logic - Complete implementation
"""
import logging
@@ -59,9 +62,9 @@ class AuthService:
"user_events",
"user.registered",
UserRegisteredEvent(
event_id="",
event_id=str(user.id),
service_name="auth-service",
timestamp= datetime.now(datetime.timezone.utc),
timestamp=datetime.now(datetime.timezone.utc),
data={
"user_id": str(user.id),
"email": user.email,
@@ -111,12 +114,13 @@ class AuthService:
await db.execute(
update(User)
.where(User.id == user.id)
.values(last_login= datetime.now(datetime.timezone.utc))
.values(last_login=datetime.now(datetime.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,
@@ -132,10 +136,10 @@ class AuthService:
# Create session record
session = UserSession(
user_id=user.id,
refresh_token_hash=security_manager.hash_password(refresh_token),
expires_at= datetime.now(datetime.timezone.utc) + timedelta(days=7),
refresh_token_hash=security_manager.hash_token(refresh_token),
ip_address=ip_address,
user_agent=user_agent
user_agent=user_agent,
expires_at=datetime.now(datetime.timezone.utc) + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
)
db.add(session)
@@ -146,9 +150,9 @@ class AuthService:
"user_events",
"user.login",
UserLoginEvent(
event_id="",
event_id=str(session.id),
service_name="auth-service",
timestamp= datetime.now(datetime.timezone.utc),
timestamp=datetime.now(datetime.timezone.utc),
data={
"user_id": str(user.id),
"email": user.email,
@@ -158,38 +162,39 @@ class AuthService:
).__dict__
)
logger.info(f"User logged in: {user.email}")
logger.info(f"User login successful: {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:
"""Refresh access token"""
"""Refresh access token using refresh token"""
# Verify refresh token
payload = security_manager.verify_token(refresh_token)
if not payload or payload.get("type") != "refresh":
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"
)
user_id = payload.get("user_id")
user_id = token_data.get("user_id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
detail="Invalid token data"
)
# Verify refresh token is stored
# 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="Invalid refresh token"
detail="Refresh token not found or expired"
)
# Get user
@@ -205,80 +210,68 @@ class AuthService:
)
# Create new tokens
token_data = {
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(token_data)
new_refresh_token = security_manager.create_refresh_token(token_data)
new_access_token = security_manager.create_access_token(new_token_data)
new_refresh_token = security_manager.create_refresh_token(new_token_data)
# Update stored refresh token
await security_manager.store_refresh_token(str(user.id), new_refresh_token)
# 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, db: AsyncSession) -> Dict[str, Any]:
"""Verify access token"""
async def verify_token(token: str) -> Dict[str, Any]:
"""Verify access token and return user data"""
# Verify token
payload = security_manager.verify_token(token)
if not payload or payload.get("type") != "access":
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 or expired token"
detail="Invalid access 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
}
return token_data
@staticmethod
async def logout_user(user_id: str, db: AsyncSession):
async def logout_user(user_id: str, refresh_token: str, db: AsyncSession) -> bool:
"""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}")
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)
)
await db.commit()
logger.info(f"User logged out: {user_id}")
return True
except Exception as e:
logger.error(f"Error logging out user {user_id}: {e}")
await db.rollback()
return False