diff --git a/gateway/app/core/config.py b/gateway/app/core/config.py index ca61a1f6..f4d587d0 100644 --- a/gateway/app/core/config.py +++ b/gateway/app/core/config.py @@ -15,9 +15,6 @@ class Settings(BaseSettings): DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" 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 AUTH_SERVICE_URL: str = "http://auth-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" 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_URL: str = "redis://redis:6379/6" @@ -37,11 +47,6 @@ class Settings(BaseSettings): JWT_SECRET_KEY: str = "your-secret-key-change-in-production" 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 def SERVICES(self) -> Dict[str, str]: """Service registry""" diff --git a/gateway/app/core/service_discovery.py b/gateway/app/core/service_discovery.py index 78b58337..a0e2a86c 100644 --- a/gateway/app/core/service_discovery.py +++ b/gateway/app/core/service_discovery.py @@ -1,122 +1,65 @@ """ -Service discovery for microservices +Service discovery for API Gateway """ -import asyncio import logging -from typing import Dict, List, Optional import httpx -import redis.asyncio as redis -from datetime import datetime, timedelta +from typing import Optional, Dict +import json from app.core.config import settings logger = logging.getLogger(__name__) class ServiceDiscovery: - """Service discovery and health checking""" + """Service discovery client""" def __init__(self): - self.redis_client = redis.from_url(settings.REDIS_URL) - self.services = settings.SERVICES - self.health_check_interval = 30 # seconds - self.health_check_task = None - - async def initialize(self): - """Initialize service discovery""" - logger.info("Initializing service discovery") + self.consul_url = settings.CONSUL_URL if hasattr(settings, 'CONSUL_URL') else None + self.service_cache: Dict[str, str] = {} - # 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]: - """Get service URL""" - return self.services.get(service_name) - - async def get_healthy_services(self) -> List[str]: - """Get list of healthy services""" - healthy_services = [] + """Get service URL from service discovery""" - for service_name in self.services: - is_healthy = await self._is_service_healthy(service_name) - if is_healthy: - healthy_services.append(service_name) + # Return cached URL if available + if service_name in self.service_cache: + return self.service_cache[service_name] - return healthy_services - - async def _health_check_loop(self): - """Continuous health check loop""" - while True: + # Try Consul if enabled + if self.consul_url and getattr(settings, 'ENABLE_SERVICE_DISCOVERY', False): try: - await self._check_all_services() - await asyncio.sleep(self.health_check_interval) - except asyncio.CancelledError: - break + url = await self._get_from_consul(service_name) + if url: + self.service_cache[service_name] = url + return url except Exception as e: - logger.error(f"Health check error: {e}") - await asyncio.sleep(self.health_check_interval) + logger.warning(f"Failed to get {service_name} from Consul: {e}") + + # Fall back to environment variables + return self._get_from_env(service_name) - async def _check_all_services(self): - """Check health of all services""" - 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""" + async def _get_from_consul(self, service_name: str) -> Optional[str]: + """Get service URL from Consul""" try: async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(f"{service_url}/health") - return response.status_code == 200 + response = await client.get( + 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: - logger.warning(f"Service health check failed: {e}") - return False + logger.error(f"Consul query failed: {e}") + + return None - async def _update_service_health(self, service_name: str, is_healthy: bool): - """Update service health status in Redis""" - try: - key = f"service_health:{service_name}" - 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 \ No newline at end of file + def _get_from_env(self, service_name: str) -> Optional[str]: + """Get service URL from environment variables""" + env_var = f"{service_name.upper().replace('-', '_')}_SERVICE_URL" + return getattr(settings, env_var, None) \ No newline at end of file diff --git a/gateway/app/routes/auth.py b/gateway/app/routes/auth.py index 3a0972ca..cf33249f 100644 --- a/gateway/app/routes/auth.py +++ b/gateway/app/routes/auth.py @@ -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 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.service_discovery import ServiceDiscovery +from shared.monitoring.metrics import MetricsCollector logger = logging.getLogger(__name__) router = APIRouter() +# Initialize service discovery and metrics service_discovery = ServiceDiscovery() +metrics = MetricsCollector("gateway") -@router.post("/login") -async def login(request: Request): - """Proxy login request to auth service""" - try: - body = await request.body() +# Auth service configuration +AUTH_SERVICE_URL = settings.AUTH_SERVICE_URL or "http://auth-service:8000" + +class AuthProxy: + """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: - response = await client.post( - f"{settings.AUTH_SERVICE_URL}/login", + try: + # Get auth service URL (with service discovery if available) + 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, - headers={"Content-Type": "application/json"} + params=dict(request.query_params) ) - if response.status_code == 200: - return response.json() - else: - 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"Login error: {e}") - raise HTTPException(status_code=500, detail="Internal server error") + # Record metrics + metrics.increment_counter("gateway_auth_requests_total") + metrics.increment_counter( + "gateway_auth_responses_total", + labels={"status_code": str(response.status_code)} + ) + + # Prepare response headers + response_headers = self._prepare_response_headers(dict(response.headers)) + + return Response( + content=response.content, + status_code=response.status_code, + headers=response_headers, + media_type=response.headers.get("content-type") + ) + + 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") async def register(request: Request): - """Proxy register request to auth service""" - try: - body = await request.body() - - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - f"{settings.AUTH_SERVICE_URL}/register", - 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") + """Proxy user registration to auth service""" + return await auth_proxy.forward_request("POST", "register", request) + +@router.post("/login") +async def login(request: Request): + """Proxy user login to auth service""" + return await auth_proxy.forward_request("POST", "login", request) @router.post("/refresh") async def refresh_token(request: Request): - """Proxy refresh token request to auth service""" - try: - 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") + """Proxy token refresh to auth service""" + return await auth_proxy.forward_request("POST", "refresh", request) @router.post("/verify") async def verify_token(request: Request): """Proxy token verification to auth service""" - 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: - 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") + return await auth_proxy.forward_request("POST", "verify", request) @router.post("/logout") 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: - 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}/logout", - headers={"Authorization": auth_header} - ) + response = await client.get(f"{AUTH_SERVICE_URL}/health") - 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" - ) + if response.status_code == 200: + return { + "status": "healthy", + "auth_service": "available", + "response_time_ms": response.elapsed.total_seconds() * 1000 + } + else: + return { + "status": "unhealthy", + "auth_service": "error", + "status_code": response.status_code + } + except Exception as e: - logger.error(f"Logout error: {e}") - raise HTTPException(status_code=500, detail="Internal server error") \ No newline at end of file + logger.error(f"Auth service health check failed: {e}") + return { + "status": "unhealthy", + "auth_service": "unavailable", + "error": str(e) + } + +# ================================================================ +# CLEANUP +# ================================================================ + +@router.on_event("shutdown") +async def cleanup(): + """Cleanup resources""" + await auth_proxy.client.aclose() diff --git a/services/auth/README.md b/services/auth/README.md new file mode 100644 index 00000000..bfbd3101 --- /dev/null +++ b/services/auth/README.md @@ -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 \ No newline at end of file diff --git a/services/auth/app/api/auth.py b/services/auth/app/api/auth.py index a143d8dd..2cca1206 100644 --- a/services/auth/app/api/auth.py +++ b/services/auth/app/api/auth.py @@ -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" - ) \ No newline at end of file + ) diff --git a/services/auth/app/core/database.py b/services/auth/app/core/database.py index c463b652..991a78b2 100644 --- a/services/auth/app/core/database.py +++ b/services/auth/app/core/database.py @@ -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 \ No newline at end of file +# 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") diff --git a/services/auth/app/core/security.py b/services/auth/app/core/security.py index 3ab157e7..4dcde665 100644 --- a/services/auth/app/core/security.py +++ b/services/auth/app/core/security.py @@ -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() \ No newline at end of file +# Create singleton instance +security_manager = SecurityManager() diff --git a/services/auth/app/main.py b/services/auth/app/main.py index 56e0eabf..c0de34e7 100644 --- a/services/auth/app/main.py +++ b/services/auth/app/main.py @@ -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) \ No newline at end of file +# 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 diff --git a/services/auth/app/models/tokens.py b/services/auth/app/models/tokens.py new file mode 100644 index 00000000..358f505b --- /dev/null +++ b/services/auth/app/models/tokens.py @@ -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"" + +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"" \ No newline at end of file diff --git a/services/auth/app/schemas/users.py b/services/auth/app/schemas/users.py new file mode 100644 index 00000000..f854ff0b --- /dev/null +++ b/services/auth/app/schemas/users.py @@ -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 diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index bb74b2fa..7884be3c 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -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}") \ No newline at end of file + 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 \ No newline at end of file diff --git a/services/auth/docker-compose.yml b/services/auth/docker-compose.yml new file mode 100644 index 00000000..92bcfc29 --- /dev/null +++ b/services/auth/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/services/auth/migrations/alembic.ini b/services/auth/migrations/alembic.ini new file mode 100644 index 00000000..e69de29b diff --git a/services/auth/migrations/env.py b/services/auth/migrations/env.py new file mode 100644 index 00000000..9c1f81e2 --- /dev/null +++ b/services/auth/migrations/env.py @@ -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() diff --git a/services/auth/migrations/script.py.mako b/services/auth/migrations/script.py.mako new file mode 100644 index 00000000..8c07cc74 --- /dev/null +++ b/services/auth/migrations/script.py.mako @@ -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"} \ No newline at end of file diff --git a/services/auth/tests/__init__.py b/services/auth/tests/__init__.py new file mode 100644 index 00000000..831e11f7 --- /dev/null +++ b/services/auth/tests/__init__.py @@ -0,0 +1 @@ +"""Authentication service tests""" \ No newline at end of file diff --git a/services/auth/tests/conftest.py b/services/auth/tests/conftest.py new file mode 100644 index 00000000..31c35b5a --- /dev/null +++ b/services/auth/tests/conftest.py @@ -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" + } diff --git a/services/auth/tests/test_auth.py b/services/auth/tests/test_auth.py new file mode 100644 index 00000000..11e090a7 --- /dev/null +++ b/services/auth/tests/test_auth.py @@ -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" diff --git a/services/auth/tests/test_users.py b/services/auth/tests/test_users.py new file mode 100644 index 00000000..cdc29ad2 --- /dev/null +++ b/services/auth/tests/test_users.py @@ -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"] \ No newline at end of file