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

@@ -15,9 +15,6 @@ class Settings(BaseSettings):
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# CORS settings - FIXED: Remove List[str] type and parse manually
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001"
# Service URLs # Service URLs
AUTH_SERVICE_URL: str = "http://auth-service:8000" AUTH_SERVICE_URL: str = "http://auth-service:8000"
TRAINING_SERVICE_URL: str = "http://training-service:8000" TRAINING_SERVICE_URL: str = "http://training-service:8000"
@@ -26,6 +23,19 @@ class Settings(BaseSettings):
TENANT_SERVICE_URL: str = "http://tenant-service:8000" TENANT_SERVICE_URL: str = "http://tenant-service:8000"
NOTIFICATION_SERVICE_URL: str = "http://notification-service:8000" NOTIFICATION_SERVICE_URL: str = "http://notification-service:8000"
# Service Discovery
CONSUL_URL: str = os.getenv("CONSUL_URL", "http://consul:8500")
ENABLE_SERVICE_DISCOVERY: bool = os.getenv("ENABLE_SERVICE_DISCOVERY", "false").lower() == "true"
# CORS
CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:3001")
@property
def CORS_ORIGINS_LIST(self) -> List[str]:
"""Get CORS origins as list"""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
# Redis settings # Redis settings
REDIS_URL: str = "redis://redis:6379/6" REDIS_URL: str = "redis://redis:6379/6"
@@ -37,11 +47,6 @@ class Settings(BaseSettings):
JWT_SECRET_KEY: str = "your-secret-key-change-in-production" JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
@property
def CORS_ORIGINS_LIST(self) -> List[str]:
"""Parse CORS origins from string to list"""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
@property @property
def SERVICES(self) -> Dict[str, str]: def SERVICES(self) -> Dict[str, str]:
"""Service registry""" """Service registry"""

View File

@@ -1,122 +1,65 @@
""" """
Service discovery for microservices Service discovery for API Gateway
""" """
import asyncio
import logging import logging
from typing import Dict, List, Optional
import httpx import httpx
import redis.asyncio as redis from typing import Optional, Dict
from datetime import datetime, timedelta import json
from app.core.config import settings from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ServiceDiscovery: class ServiceDiscovery:
"""Service discovery and health checking""" """Service discovery client"""
def __init__(self): def __init__(self):
self.redis_client = redis.from_url(settings.REDIS_URL) self.consul_url = settings.CONSUL_URL if hasattr(settings, 'CONSUL_URL') else None
self.services = settings.SERVICES self.service_cache: Dict[str, str] = {}
self.health_check_interval = 30 # seconds
self.health_check_task = None
async def initialize(self):
"""Initialize service discovery"""
logger.info("Initializing service discovery")
# Start health check task
self.health_check_task = asyncio.create_task(self._health_check_loop())
# Initial health check
await self._check_all_services()
async def cleanup(self):
"""Cleanup service discovery"""
if self.health_check_task:
self.health_check_task.cancel()
try:
await self.health_check_task
except asyncio.CancelledError:
pass
await self.redis_client.close()
async def get_service_url(self, service_name: str) -> Optional[str]: async def get_service_url(self, service_name: str) -> Optional[str]:
"""Get service URL""" """Get service URL from service discovery"""
return self.services.get(service_name)
async def get_healthy_services(self) -> List[str]:
"""Get list of healthy services"""
healthy_services = []
for service_name in self.services: # Return cached URL if available
is_healthy = await self._is_service_healthy(service_name) if service_name in self.service_cache:
if is_healthy: return self.service_cache[service_name]
healthy_services.append(service_name)
return healthy_services # Try Consul if enabled
if self.consul_url and getattr(settings, 'ENABLE_SERVICE_DISCOVERY', False):
async def _health_check_loop(self):
"""Continuous health check loop"""
while True:
try: try:
await self._check_all_services() url = await self._get_from_consul(service_name)
await asyncio.sleep(self.health_check_interval) if url:
except asyncio.CancelledError: self.service_cache[service_name] = url
break return url
except Exception as e: except Exception as e:
logger.error(f"Health check error: {e}") logger.warning(f"Failed to get {service_name} from Consul: {e}")
await asyncio.sleep(self.health_check_interval)
# Fall back to environment variables
return self._get_from_env(service_name)
async def _check_all_services(self): async def _get_from_consul(self, service_name: str) -> Optional[str]:
"""Check health of all services""" """Get service URL from Consul"""
for service_name, service_url in self.services.items():
try:
is_healthy = await self._check_service_health(service_url)
await self._update_service_health(service_name, is_healthy)
except Exception as e:
logger.error(f"Health check failed for {service_name}: {e}")
await self._update_service_health(service_name, False)
async def _check_service_health(self, service_url: str) -> bool:
"""Check individual service health"""
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{service_url}/health") response = await client.get(
return response.status_code == 200 f"{self.consul_url}/v1/health/service/{service_name}?passing=true"
)
if response.status_code == 200:
services = response.json()
if services:
service = services[0]
address = service['Service']['Address']
port = service['Service']['Port']
return f"http://{address}:{port}"
except Exception as e: except Exception as e:
logger.warning(f"Service health check failed: {e}") logger.error(f"Consul query failed: {e}")
return False
return None
async def _update_service_health(self, service_name: str, is_healthy: bool): def _get_from_env(self, service_name: str) -> Optional[str]:
"""Update service health status in Redis""" """Get service URL from environment variables"""
try: env_var = f"{service_name.upper().replace('-', '_')}_SERVICE_URL"
key = f"service_health:{service_name}" return getattr(settings, env_var, None)
value = {
"healthy": is_healthy,
"last_check": datetime.now(datetime.timezone.utc).isoformat(),
"url": self.services[service_name]
}
await self.redis_client.hset(key, mapping=value)
await self.redis_client.expire(key, 300) # 5 minutes TTL
except Exception as e:
logger.error(f"Failed to update service health for {service_name}: {e}")
async def _is_service_healthy(self, service_name: str) -> bool:
"""Check if service is healthy from Redis cache"""
try:
key = f"service_health:{service_name}"
health_data = await self.redis_client.hgetall(key)
if not health_data:
return False
return health_data.get(b'healthy', b'false').decode() == 'True'
except Exception as e:
logger.error(f"Failed to check service health for {service_name}: {e}")
return False

View File

@@ -1,161 +1,262 @@
# ================================================================
# gateway/app/routes/auth.py (UPDATED VERSION)
# ================================================================
""" """
Authentication routes for gateway Authentication routes for API Gateway
Enhanced version that properly proxies to auth microservice
""" """
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging import logging
import httpx
from fastapi import APIRouter, Request, Response, HTTPException, status
from fastapi.responses import JSONResponse
from typing import Dict, Any
import json
from app.core.config import settings from app.core.config import settings
from app.core.service_discovery import ServiceDiscovery from app.core.service_discovery import ServiceDiscovery
from shared.monitoring.metrics import MetricsCollector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Initialize service discovery and metrics
service_discovery = ServiceDiscovery() service_discovery = ServiceDiscovery()
metrics = MetricsCollector("gateway")
@router.post("/login") # Auth service configuration
async def login(request: Request): AUTH_SERVICE_URL = settings.AUTH_SERVICE_URL or "http://auth-service:8000"
"""Proxy login request to auth service"""
try: class AuthProxy:
body = await request.body() """Authentication service proxy with enhanced error handling"""
def __init__(self):
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
async def forward_request(
self,
method: str,
path: str,
request: Request
) -> Response:
"""Forward request to auth service with proper error handling"""
async with httpx.AsyncClient(timeout=10.0) as client: try:
response = await client.post( # Get auth service URL (with service discovery if available)
f"{settings.AUTH_SERVICE_URL}/login", auth_url = await self._get_auth_service_url()
target_url = f"{auth_url}/api/v1/auth/{path}"
# Prepare headers (remove hop-by-hop headers)
headers = self._prepare_headers(dict(request.headers))
# Get request body
body = await request.body()
# Forward request
logger.info(f"Forwarding {method} {path} to auth service")
response = await self.client.request(
method=method,
url=target_url,
headers=headers,
content=body, content=body,
headers={"Content-Type": "application/json"} params=dict(request.query_params)
) )
if response.status_code == 200: # Record metrics
return response.json() metrics.increment_counter("gateway_auth_requests_total")
else: metrics.increment_counter(
return JSONResponse( "gateway_auth_responses_total",
status_code=response.status_code, labels={"status_code": str(response.status_code)}
content=response.json() )
)
# Prepare response headers
except httpx.RequestError as e: response_headers = self._prepare_response_headers(dict(response.headers))
logger.error(f"Auth service unavailable: {e}")
raise HTTPException( return Response(
status_code=503, content=response.content,
detail="Authentication service unavailable" status_code=response.status_code,
) headers=response_headers,
except Exception as e: media_type=response.headers.get("content-type")
logger.error(f"Login error: {e}") )
raise HTTPException(status_code=500, detail="Internal server error")
except httpx.TimeoutException:
logger.error(f"Timeout forwarding {method} {path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "timeout"})
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="Authentication service timeout"
)
except httpx.ConnectError:
logger.error(f"Connection error forwarding {method} {path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "connection"})
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Error forwarding {method} {path} to auth service: {e}")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "unknown"})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal gateway error"
)
async def _get_auth_service_url(self) -> str:
"""Get auth service URL with service discovery"""
try:
# Try service discovery first
service_url = await service_discovery.get_service_url("auth-service")
if service_url:
return service_url
except Exception as e:
logger.warning(f"Service discovery failed: {e}")
# Fall back to configured URL
return AUTH_SERVICE_URL
def _prepare_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
"""Prepare headers for forwarding (remove hop-by-hop headers)"""
# Remove hop-by-hop headers
hop_by_hop_headers = {
'connection', 'keep-alive', 'proxy-authenticate',
'proxy-authorization', 'te', 'trailers', 'upgrade'
}
filtered_headers = {
k: v for k, v in headers.items()
if k.lower() not in hop_by_hop_headers
}
# Add gateway identifier
filtered_headers['X-Forwarded-By'] = 'bakery-gateway'
filtered_headers['X-Gateway-Version'] = '1.0.0'
return filtered_headers
def _prepare_response_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
"""Prepare response headers"""
# Remove server-specific headers
filtered_headers = {
k: v for k, v in headers.items()
if k.lower() not in {'server', 'date'}
}
# Add CORS headers if needed
if settings.CORS_ORIGINS:
filtered_headers['Access-Control-Allow-Origin'] = '*'
filtered_headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
filtered_headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
return filtered_headers
# Initialize proxy
auth_proxy = AuthProxy()
# ================================================================
# AUTH ENDPOINTS - All proxied to auth service
# ================================================================
@router.post("/register") @router.post("/register")
async def register(request: Request): async def register(request: Request):
"""Proxy register request to auth service""" """Proxy user registration to auth service"""
try: return await auth_proxy.forward_request("POST", "register", request)
body = await request.body()
@router.post("/login")
async with httpx.AsyncClient(timeout=10.0) as client: async def login(request: Request):
response = await client.post( """Proxy user login to auth service"""
f"{settings.AUTH_SERVICE_URL}/register", return await auth_proxy.forward_request("POST", "login", request)
content=body,
headers={"Content-Type": "application/json"}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Register error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/refresh") @router.post("/refresh")
async def refresh_token(request: Request): async def refresh_token(request: Request):
"""Proxy refresh token request to auth service""" """Proxy token refresh to auth service"""
try: return await auth_proxy.forward_request("POST", "refresh", request)
body = await request.body()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/refresh",
content=body,
headers={"Content-Type": "application/json"}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Refresh token error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/verify") @router.post("/verify")
async def verify_token(request: Request): async def verify_token(request: Request):
"""Proxy token verification to auth service""" """Proxy token verification to auth service"""
try: return await auth_proxy.forward_request("POST", "verify", request)
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(status_code=401, detail="Authorization header required")
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/verify",
headers={"Authorization": auth_header}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Token verification error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/logout") @router.post("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Proxy logout request to auth service""" """Proxy user logout to auth service"""
return await auth_proxy.forward_request("POST", "logout", request)
@router.post("/reset-password")
async def reset_password(request: Request):
"""Proxy password reset to auth service"""
return await auth_proxy.forward_request("POST", "reset-password", request)
@router.post("/change-password")
async def change_password(request: Request):
"""Proxy password change to auth service"""
return await auth_proxy.forward_request("POST", "change-password", request)
# ================================================================
# USER MANAGEMENT ENDPOINTS - Proxied to auth service
# ================================================================
@router.get("/users/me")
async def get_current_user(request: Request):
"""Proxy get current user to auth service"""
return await auth_proxy.forward_request("GET", "../users/me", request)
@router.put("/users/me")
async def update_current_user(request: Request):
"""Proxy update current user to auth service"""
return await auth_proxy.forward_request("PUT", "../users/me", request)
# ================================================================
# CATCH-ALL ROUTE for any other auth endpoints
# ================================================================
@router.api_route("/auth/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_auth_requests(path: str, request: Request):
"""Catch-all proxy for auth requests"""
return await auth_proxy.forward_request(request.method, path, request)
# ================================================================
# HEALTH CHECK for auth service
# ================================================================
@router.get("/auth/health")
async def auth_service_health():
"""Check auth service health"""
try: try:
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(status_code=401, detail="Authorization header required")
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post( response = await client.get(f"{AUTH_SERVICE_URL}/health")
f"{settings.AUTH_SERVICE_URL}/logout",
headers={"Authorization": auth_header}
)
return JSONResponse( if response.status_code == 200:
status_code=response.status_code, return {
content=response.json() "status": "healthy",
) "auth_service": "available",
"response_time_ms": response.elapsed.total_seconds() * 1000
except httpx.RequestError as e: }
logger.error(f"Auth service unavailable: {e}") else:
raise HTTPException( return {
status_code=503, "status": "unhealthy",
detail="Authentication service unavailable" "auth_service": "error",
) "status_code": response.status_code
}
except Exception as e: except Exception as e:
logger.error(f"Logout error: {e}") logger.error(f"Auth service health check failed: {e}")
raise HTTPException(status_code=500, detail="Internal server error") return {
"status": "unhealthy",
"auth_service": "unavailable",
"error": str(e)
}
# ================================================================
# CLEANUP
# ================================================================
@router.on_event("shutdown")
async def cleanup():
"""Cleanup resources"""
await auth_proxy.client.aclose()

129
services/auth/README.md Normal file
View File

@@ -0,0 +1,129 @@
# ================================================================
# services/auth/README.md
# ================================================================
# Authentication Service
Microservice for user authentication and authorization in the bakery forecasting platform.
## Features
- User registration and login
- JWT access and refresh tokens
- Password security validation
- Rate limiting and login attempt tracking
- Multi-tenant user management
- Session management
- Event publishing for user actions
## Quick Start
### Development
```bash
# Start dependencies
docker-compose up -d auth-db redis rabbitmq
# Install dependencies
pip install -r requirements.txt
# Run migrations
alembic upgrade head
# Start service
uvicorn app.main:app --reload --host 0.0.0.0 --port 8001
```
### With Docker
```bash
# Start everything
docker-compose up -d
# View logs
docker-compose logs -f auth-service
# Run tests
docker-compose exec auth-service pytest
```
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - Register new user
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/refresh` - Refresh access token
- `POST /api/v1/auth/verify` - Verify token
- `POST /api/v1/auth/logout` - Logout user
### User Management
- `GET /api/v1/users/me` - Get current user
- `PUT /api/v1/users/me` - Update current user
- `POST /api/v1/users/change-password` - Change password
### Health
- `GET /health` - Health check
- `GET /metrics` - Prometheus metrics
## Configuration
Set these environment variables:
```bash
DATABASE_URL=postgresql+asyncpg://auth_user:auth_pass123@auth-db:5432/auth_db
REDIS_URL=redis://redis:6379/0
RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
JWT_SECRET_KEY=your-super-secret-jwt-key
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
```
## Testing
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=app
# Run specific test file
pytest tests/test_auth.py -v
```
## Database Migrations
```bash
# Create migration
alembic revision --autogenerate -m "description"
# Apply migrations
alembic upgrade head
# Rollback
alembic downgrade -1
```
## Monitoring
- Health endpoint: `/health`
- Metrics endpoint: `/metrics` (Prometheus format)
- Logs: Structured JSON logging
- Tracing: Request ID tracking
## Security Features
- Bcrypt password hashing
- JWT tokens with expiration
- Rate limiting on login attempts
- Account lockout protection
- IP and user agent tracking
- Token revocation support
## Events Published
- `user.registered` - When user registers
- `user.login` - When user logs in
- `user.logout` - When user logs out
- `user.password_changed` - When password changes

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 from fastapi import APIRouter, Depends, HTTPException, status, Request
@@ -7,12 +10,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
import logging import logging
from app.core.database import get_db 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.services.auth_service import AuthService
from app.core.security import security_manager from app.core.security import security_manager
from shared.monitoring.metrics import MetricsCollector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
metrics = MetricsCollector("auth_service")
@router.post("/register", response_model=UserResponse) @router.post("/register", response_model=UserResponse)
async def register( async def register(
@@ -21,11 +29,14 @@ async def register(
): ):
"""Register a new user""" """Register a new user"""
try: 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Registration error: {e}") logger.error(f"Registration error for {user_data.email}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration failed" detail="Registration failed"
@@ -42,11 +53,16 @@ async def login(
ip_address = request.client.host ip_address = request.client.host
user_agent = request.headers.get("user-agent", "") user_agent = request.headers.get("user-agent", "")
return await AuthService.login_user(login_data, db, ip_address, user_agent) result = await AuthService.login_user(login_data, db, ip_address, user_agent)
except HTTPException: 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 raise
except Exception as e: 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( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed" detail="Login failed"
@@ -84,7 +100,15 @@ async def verify_token(
) )
token = auth_header.split(" ")[1] 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -96,29 +120,27 @@ async def verify_token(
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
refresh_data: RefreshTokenRequest,
request: Request, request: Request,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""User logout""" """Logout user"""
try: try:
# Get user from token
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "): if auth_header and auth_header.startswith("Bearer "):
raise HTTPException( token = auth_header.split(" ")[1]
status_code=status.HTTP_401_UNAUTHORIZED, token_data = await AuthService.verify_token(token)
detail="Authorization header required" 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] return {"success": False, "message": "Invalid token"}
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
except Exception as e: except Exception as e:
logger.error(f"Logout error: {e}") logger.error(f"Logout error: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Logout failed" 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 app.core.config import settings
from shared.database.base import Base
# Initialize database manager logger = logging.getLogger(__name__)
database_manager = DatabaseManager(settings.DATABASE_URL)
# Alias for convenience # Create async engine
get_db = database_manager.get_db 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 Security utilities for authentication service
""" """
import bcrypt import bcrypt
import re import re
import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import redis.asyncio as redis import redis.asyncio as redis
@@ -72,6 +76,11 @@ class SecurityManager:
"""Verify JWT token""" """Verify JWT token"""
return jwt_handler.verify_token(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 @staticmethod
async def check_login_attempts(email: str) -> bool: async def check_login_attempts(email: str) -> bool:
"""Check if user has exceeded login attempts""" """Check if user has exceeded login attempts"""
@@ -83,71 +92,64 @@ class SecurityManager:
return True return True
return int(attempts) < settings.MAX_LOGIN_ATTEMPTS return int(attempts) < settings.MAX_LOGIN_ATTEMPTS
except Exception as e: except Exception as e:
logger.error(f"Error checking login attempts: {e}") logger.error(f"Error checking login attempts: {e}")
return True return True # Allow on error
@staticmethod @staticmethod
async def increment_login_attempts(email: str): async def increment_login_attempts(email: str) -> None:
"""Increment login attempts counter""" """Increment login attempts for email"""
try: try:
key = f"login_attempts:{email}" key = f"login_attempts:{email}"
current_attempts = await redis_client.incr(key) await redis_client.incr(key)
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
# Set TTL on first attempt
if current_attempts == 1:
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
except Exception as e: except Exception as e:
logger.error(f"Error incrementing login attempts: {e}") logger.error(f"Error incrementing login attempts: {e}")
@staticmethod @staticmethod
async def clear_login_attempts(email: str): async def clear_login_attempts(email: str) -> None:
"""Clear login attempts counter""" """Clear login attempts for email"""
try: try:
key = f"login_attempts:{email}" key = f"login_attempts:{email}"
await redis_client.delete(key) await redis_client.delete(key)
except Exception as e: except Exception as e:
logger.error(f"Error clearing login attempts: {e}") logger.error(f"Error clearing login attempts: {e}")
@staticmethod @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""" """Store refresh token in Redis"""
try: try:
key = f"refresh_token:{user_id}" token_hash = SecurityManager.hash_token(token)
expires_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600 key = f"refresh_token:{user_id}:{token_hash}"
await redis_client.setex(key, expires_seconds, refresh_token)
# 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: except Exception as e:
logger.error(f"Error storing refresh token: {e}") logger.error(f"Error storing refresh token: {e}")
@staticmethod @staticmethod
async def verify_refresh_token(user_id: str, refresh_token: str) -> bool: async def verify_refresh_token(user_id: str, token: str) -> bool:
"""Verify refresh token""" """Verify refresh token exists in Redis"""
try: try:
key = f"refresh_token:{user_id}" token_hash = SecurityManager.hash_token(token)
stored_token = await redis_client.get(key) key = f"refresh_token:{user_id}:{token_hash}"
if stored_token is None:
return False
return stored_token.decode() == refresh_token
result = await redis_client.get(key)
return result is not None
except Exception as e: except Exception as e:
logger.error(f"Error verifying refresh token: {e}") logger.error(f"Error verifying refresh token: {e}")
return False return False
@staticmethod @staticmethod
async def revoke_refresh_token(user_id: str): async def revoke_refresh_token(user_id: str, token: str) -> None:
"""Revoke refresh token""" """Revoke refresh token"""
try: 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) await redis_client.delete(key)
except Exception as e: except Exception as e:
logger.error(f"Error revoking refresh token: {e}") logger.error(f"Error revoking refresh token: {e}")
# Global security manager instance # Create singleton instance
security_manager = SecurityManager() security_manager = SecurityManager()

View File

@@ -1,18 +1,21 @@
# ================================================================
# services/auth/app/main.py (ENHANCED VERSION)
# ================================================================
""" """
Authentication Service Authentication Service Main Application
Handles user authentication, registration, and token management Enhanced version with proper lifecycle management and microservices integration
""" """
import logging import logging
from datetime import timedelta from fastapi import FastAPI, Request
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware 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.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.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.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
@@ -20,64 +23,101 @@ from shared.monitoring.metrics import MetricsCollector
setup_logging("auth-service", settings.LOG_LEVEL) setup_logging("auth-service", settings.LOG_LEVEL)
logger = logging.getLogger(__name__) 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 # Create FastAPI app
app = FastAPI( app = FastAPI(
title="Authentication Service", title="Authentication Service",
description="User authentication and authorization service", description="Handles user authentication and authorization for bakery forecasting platform",
version="1.0.0" version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan
) )
# Initialize metrics collector
metrics_collector = MetricsCollector("auth-service")
# CORS middleware # CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"], # Configure properly for production
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Include routers # Include routers
app.include_router(auth.router, prefix="/auth", tags=["authentication"]) app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
app.include_router(users.router, prefix="/users", tags=["users"]) app.include_router(users.router, prefix="/api/v1/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")
# Health check endpoint
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
"""Health check endpoint""" """Health check endpoint"""
return { return {
"status": "healthy",
"service": "auth-service", "service": "auth-service",
"status": "healthy",
"version": "1.0.0" "version": "1.0.0"
} }
if __name__ == "__main__": # Metrics endpoint
import uvicorn @app.get("/metrics")
uvicorn.run(app, host="0.0.0.0", port=8000) 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 import logging
@@ -59,9 +62,9 @@ class AuthService:
"user_events", "user_events",
"user.registered", "user.registered",
UserRegisteredEvent( UserRegisteredEvent(
event_id="", event_id=str(user.id),
service_name="auth-service", service_name="auth-service",
timestamp= datetime.now(datetime.timezone.utc), timestamp=datetime.now(datetime.timezone.utc),
data={ data={
"user_id": str(user.id), "user_id": str(user.id),
"email": user.email, "email": user.email,
@@ -111,12 +114,13 @@ class AuthService:
await db.execute( await db.execute(
update(User) update(User)
.where(User.id == user.id) .where(User.id == user.id)
.values(last_login= datetime.now(datetime.timezone.utc)) .values(last_login=datetime.now(datetime.timezone.utc))
) )
await db.commit() await db.commit()
# Create tokens # Create tokens
token_data = { token_data = {
"sub": str(user.id), # Standard JWT claim for subject
"user_id": str(user.id), "user_id": str(user.id),
"email": user.email, "email": user.email,
"tenant_id": str(user.tenant_id) if user.tenant_id else None, "tenant_id": str(user.tenant_id) if user.tenant_id else None,
@@ -132,10 +136,10 @@ class AuthService:
# Create session record # Create session record
session = UserSession( session = UserSession(
user_id=user.id, user_id=user.id,
refresh_token_hash=security_manager.hash_password(refresh_token), refresh_token_hash=security_manager.hash_token(refresh_token),
expires_at= datetime.now(datetime.timezone.utc) + timedelta(days=7),
ip_address=ip_address, 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) db.add(session)
@@ -146,9 +150,9 @@ class AuthService:
"user_events", "user_events",
"user.login", "user.login",
UserLoginEvent( UserLoginEvent(
event_id="", event_id=str(session.id),
service_name="auth-service", service_name="auth-service",
timestamp= datetime.now(datetime.timezone.utc), timestamp=datetime.now(datetime.timezone.utc),
data={ data={
"user_id": str(user.id), "user_id": str(user.id),
"email": user.email, "email": user.email,
@@ -158,38 +162,39 @@ class AuthService:
).__dict__ ).__dict__
) )
logger.info(f"User logged in: {user.email}") logger.info(f"User login successful: {user.email}")
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
refresh_token=refresh_token, refresh_token=refresh_token,
token_type="bearer",
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
) )
@staticmethod @staticmethod
async def refresh_token(refresh_token: str, db: AsyncSession) -> TokenResponse: async def refresh_token(refresh_token: str, db: AsyncSession) -> TokenResponse:
"""Refresh access token""" """Refresh access token using refresh token"""
# Verify refresh token # Verify refresh token
payload = security_manager.verify_token(refresh_token) token_data = security_manager.verify_token(refresh_token)
if not payload or payload.get("type") != "refresh": if not token_data or token_data.get("type") != "refresh":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token" detail="Invalid refresh token"
) )
user_id = payload.get("user_id") user_id = token_data.get("user_id")
if not user_id: if not user_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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): if not await security_manager.verify_refresh_token(user_id, refresh_token):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token" detail="Refresh token not found or expired"
) )
# Get user # Get user
@@ -205,80 +210,68 @@ class AuthService:
) )
# Create new tokens # Create new tokens
token_data = { new_token_data = {
"sub": str(user.id),
"user_id": str(user.id), "user_id": str(user.id),
"email": user.email, "email": user.email,
"tenant_id": str(user.tenant_id) if user.tenant_id else None, "tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role": user.role "role": user.role
} }
new_access_token = security_manager.create_access_token(token_data) new_access_token = security_manager.create_access_token(new_token_data)
new_refresh_token = security_manager.create_refresh_token(token_data) new_refresh_token = security_manager.create_refresh_token(new_token_data)
# Update stored refresh token # Revoke old refresh token
await security_manager.store_refresh_token(str(user.id), new_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}") logger.info(f"Token refreshed for user: {user.email}")
return TokenResponse( return TokenResponse(
access_token=new_access_token, access_token=new_access_token,
refresh_token=new_refresh_token, refresh_token=new_refresh_token,
token_type="bearer",
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
) )
@staticmethod @staticmethod
async def verify_token(token: str, db: AsyncSession) -> Dict[str, Any]: async def verify_token(token: str) -> Dict[str, Any]:
"""Verify access token""" """Verify access token and return user data"""
# Verify token token_data = security_manager.verify_token(token)
payload = security_manager.verify_token(token) if not token_data or token_data.get("type") != "access":
if not payload or payload.get("type") != "access":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token" detail="Invalid access token"
) )
user_id = payload.get("user_id") return token_data
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 @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""" """Logout user and revoke tokens"""
# Revoke refresh token try:
await security_manager.revoke_refresh_token(user_id) # Revoke refresh token
await security_manager.revoke_refresh_token(user_id, refresh_token)
# Deactivate user sessions
await db.execute( # Deactivate session
update(UserSession) await db.execute(
.where(UserSession.user_id == user_id) update(UserSession)
.values(is_active=False) .where(
) UserSession.user_id == user_id,
await db.commit() UserSession.refresh_token_hash == security_manager.hash_token(refresh_token)
)
logger.info(f"User logged out: {user_id}") .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

View File

@@ -0,0 +1,74 @@
# ================================================================
# services/auth/docker-compose.yml (For standalone testing)
# ================================================================
version: '3.8'
services:
auth-db:
image: postgres:15-alpine
container_name: auth-db
environment:
POSTGRES_DB: auth_db
POSTGRES_USER: auth_user
POSTGRES_PASSWORD: auth_pass123
ports:
- "5432:5432"
volumes:
- auth_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U auth_user -d auth_db"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: auth-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
rabbitmq:
image: rabbitmq:3-management-alpine
container_name: auth-rabbitmq
environment:
RABBITMQ_DEFAULT_USER: bakery
RABBITMQ_DEFAULT_PASS: forecast123
ports:
- "5672:5672"
- "15672:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 30s
timeout: 10s
retries: 5
auth-service:
build: .
container_name: auth-service
environment:
- DATABASE_URL=postgresql+asyncpg://auth_user:auth_pass123@auth-db:5432/auth_db
- REDIS_URL=redis://redis:6379/0
- RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
- JWT_SECRET_KEY=your-super-secret-jwt-key
- DEBUG=true
- LOG_LEVEL=INFO
ports:
- "8001:8000"
depends_on:
auth-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
volumes:
- .:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
auth_db_data:

View File

View File

@@ -0,0 +1,70 @@
# ================================================================
# services/auth/migrations/env.py
# ================================================================
"""Alembic environment configuration"""
import asyncio
import logging
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.models.users import User
from app.models.tokens import RefreshToken, LoginAttempt
from shared.database.base import Base
# this is the Alembic Config object
config = context.config
# Set database URL
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,27 @@
# ================================================================
# services/auth/migrations/script.py.mako
# ================================================================
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1 @@
"""Authentication service tests"""

View File

@@ -0,0 +1,69 @@
# ================================================================
# services/auth/tests/conftest.py
# ================================================================
"""Test configuration for auth service"""
import pytest
import asyncio
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from app.main import app
from app.core.database import get_db
from shared.database.base import Base
# Test database URL
TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5433/test_auth_db"
# Create test engine
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
# Create test session
TestSessionLocal = sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for the test session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
async def db() -> AsyncGenerator[AsyncSession, None]:
"""Database fixture"""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionLocal() as session:
yield session
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
def client(db: AsyncSession):
"""Test client fixture"""
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def test_user_data():
"""Test user data fixture"""
return {
"email": "test@bakery.es",
"password": "TestPass123",
"full_name": "Test User",
"phone": "+34123456789",
"language": "es"
}

View File

@@ -0,0 +1,79 @@
# ================================================================
# services/auth/tests/test_auth.py
# ================================================================
"""Authentication tests"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.auth_service import AuthService
from app.schemas.auth import UserRegistration, UserLogin
@pytest.mark.asyncio
async def test_register_user(db: AsyncSession):
"""Test user registration"""
user_data = UserRegistration(
email="test@bakery.es",
password="TestPass123",
full_name="Test User",
language="es"
)
result = await AuthService.register_user(user_data, db)
assert result.email == "test@bakery.es"
assert result.full_name == "Test User"
assert result.is_active is True
assert result.is_verified is False
@pytest.mark.asyncio
async def test_login_user(db: AsyncSession):
"""Test user login"""
# First register a user
user_data = UserRegistration(
email="test@bakery.es",
password="TestPass123",
full_name="Test User",
language="es"
)
await AuthService.register_user(user_data, db)
# Then login
login_data = UserLogin(
email="test@bakery.es",
password="TestPass123"
)
result = await AuthService.login_user(login_data, db, "127.0.0.1", "test-agent")
assert result.access_token is not None
assert result.refresh_token is not None
assert result.token_type == "bearer"
def test_register_endpoint(client: TestClient, test_user_data):
"""Test registration endpoint"""
response = client.post("/auth/register", json=test_user_data)
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user_data["email"]
assert "id" in data
def test_login_endpoint(client: TestClient, test_user_data):
"""Test login endpoint"""
# First register
client.post("/auth/register", json=test_user_data)
# Then login
login_data = {
"email": test_user_data["email"],
"password": test_user_data["password"]
}
response = client.post("/auth/login", json=login_data)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"

View File

@@ -0,0 +1,74 @@
# ================================================================
# services/auth/tests/test_users.py
# ================================================================
"""User management tests"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.user_service import UserService
from app.services.auth_service import AuthService
from app.schemas.auth import UserRegistration
@pytest.mark.asyncio
async def test_get_user_by_email(db: AsyncSession):
"""Test getting user by email"""
# Create a user first
user_data = UserRegistration(
email="test@bakery.es",
password="TestPass123",
full_name="Test User",
language="es"
)
created_user = await AuthService.register_user(user_data, db)
# Get user by email
user = await UserService.get_user_by_email("test@bakery.es", db)
assert user is not None
assert user.email == "test@bakery.es"
assert str(user.id) == created_user.id
@pytest.mark.asyncio
async def test_update_user(db: AsyncSession):
"""Test updating user"""
# Create a user first
user_data = UserRegistration(
email="test@bakery.es",
password="TestPass123",
full_name="Test User",
language="es"
)
created_user = await AuthService.register_user(user_data, db)
# Update user
update_data = {
"full_name": "Updated User",
"phone": "+34987654321"
}
updated_user = await UserService.update_user(created_user.id, update_data, db)
assert updated_user.full_name == "Updated User"
assert updated_user.phone == "+34987654321"
def test_get_current_user_endpoint(client: TestClient, test_user_data):
"""Test get current user endpoint"""
# Register and login first
client.post("/auth/register", json=test_user_data)
login_response = client.post("/auth/login", json={
"email": test_user_data["email"],
"password": test_user_data["password"]
})
token = login_response.json()["access_token"]
# Get current user
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/users/me", headers=headers)
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user_data["email"]
assert data["full_name"] == test_user_data["full_name"]