Adds auth module
This commit is contained in:
@@ -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"""
|
||||||
|
|||||||
@@ -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]:
|
# Return cached URL if available
|
||||||
"""Get list of healthy services"""
|
if service_name in self.service_cache:
|
||||||
healthy_services = []
|
return self.service_cache[service_name]
|
||||||
|
|
||||||
for service_name in self.services:
|
# Try Consul if enabled
|
||||||
is_healthy = await self._is_service_healthy(service_name)
|
if self.consul_url and getattr(settings, 'ENABLE_SERVICE_DISCOVERY', False):
|
||||||
if is_healthy:
|
|
||||||
healthy_services.append(service_name)
|
|
||||||
|
|
||||||
return healthy_services
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
async def _check_all_services(self):
|
# Fall back to environment variables
|
||||||
"""Check health of all services"""
|
return self._get_from_env(service_name)
|
||||||
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:
|
async def _get_from_consul(self, service_name: str) -> Optional[str]:
|
||||||
"""Check individual service health"""
|
"""Get service URL from Consul"""
|
||||||
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"
|
||||||
except Exception as e:
|
)
|
||||||
logger.warning(f"Service health check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _update_service_health(self, service_name: str, is_healthy: bool):
|
if response.status_code == 200:
|
||||||
"""Update service health status in Redis"""
|
services = response.json()
|
||||||
try:
|
if services:
|
||||||
key = f"service_health:{service_name}"
|
service = services[0]
|
||||||
value = {
|
address = service['Service']['Address']
|
||||||
"healthy": is_healthy,
|
port = service['Service']['Port']
|
||||||
"last_check": datetime.now(datetime.timezone.utc).isoformat(),
|
return f"http://{address}:{port}"
|
||||||
"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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update service health for {service_name}: {e}")
|
logger.error(f"Consul query failed: {e}")
|
||||||
|
|
||||||
async def _is_service_healthy(self, service_name: str) -> bool:
|
return None
|
||||||
"""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:
|
def _get_from_env(self, service_name: str) -> Optional[str]:
|
||||||
return False
|
"""Get service URL from environment variables"""
|
||||||
|
env_var = f"{service_name.upper().replace('-', '_')}_SERVICE_URL"
|
||||||
return health_data.get(b'healthy', b'false').decode() == 'True'
|
return getattr(settings, env_var, None)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to check service health for {service_name}: {e}")
|
|
||||||
return False
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
async def login(request: Request):
|
|
||||||
"""Proxy login request to auth service"""
|
|
||||||
try:
|
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()
|
body = await request.body()
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
# Forward request
|
||||||
response = await client.post(
|
logger.info(f"Forwarding {method} {path} to auth service")
|
||||||
f"{settings.AUTH_SERVICE_URL}/login",
|
|
||||||
|
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",
|
||||||
|
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,
|
status_code=response.status_code,
|
||||||
content=response.json()
|
headers=response_headers,
|
||||||
|
media_type=response.headers.get("content-type")
|
||||||
)
|
)
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
except httpx.TimeoutException:
|
||||||
logger.error(f"Auth service unavailable: {e}")
|
logger.error(f"Timeout forwarding {method} {path} to auth service")
|
||||||
|
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "timeout"})
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
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"
|
detail="Authentication service unavailable"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Login error: {e}")
|
logger.error(f"Error forwarding {method} {path} to auth service: {e}")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
@router.post("/login")
|
||||||
response = await client.post(
|
async def login(request: Request):
|
||||||
f"{settings.AUTH_SERVICE_URL}/register",
|
"""Proxy user login to auth service"""
|
||||||
content=body,
|
return await auth_proxy.forward_request("POST", "login", request)
|
||||||
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
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"auth_service": "error",
|
||||||
|
"status_code": response.status_code
|
||||||
|
}
|
||||||
|
|
||||||
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:
|
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
129
services/auth/README.md
Normal 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
|
||||||
@@ -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,26 +120,24 @@ 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(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Authorization header required"
|
|
||||||
)
|
|
||||||
|
|
||||||
token = auth_header.split(" ")[1]
|
token = auth_header.split(" ")[1]
|
||||||
user_data = await AuthService.verify_token(token, db)
|
token_data = await AuthService.verify_token(token)
|
||||||
|
user_id = token_data.get("user_id")
|
||||||
|
|
||||||
await AuthService.logout_user(user_data["user_id"], db)
|
if user_id:
|
||||||
|
success = await AuthService.logout_user(user_id, refresh_data.refresh_token, db)
|
||||||
|
return {"success": success}
|
||||||
|
|
||||||
return {"message": "Logged out successfully"}
|
return {"success": False, "message": "Invalid token"}
|
||||||
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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# Set TTL on first attempt
|
|
||||||
if current_attempts == 1:
|
|
||||||
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
|
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()
|
||||||
@@ -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
|
||||||
|
|||||||
51
services/auth/app/models/tokens.py
Normal file
51
services/auth/app/models/tokens.py
Normal 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})>"
|
||||||
44
services/auth/app/schemas/users.py
Normal file
44
services/auth/app/schemas/users.py
Normal 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
|
||||||
@@ -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"""
|
||||||
|
|
||||||
|
try:
|
||||||
# Revoke refresh token
|
# Revoke refresh token
|
||||||
await security_manager.revoke_refresh_token(user_id)
|
await security_manager.revoke_refresh_token(user_id, refresh_token)
|
||||||
|
|
||||||
# Deactivate user sessions
|
# Deactivate session
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(UserSession)
|
update(UserSession)
|
||||||
.where(UserSession.user_id == user_id)
|
.where(
|
||||||
|
UserSession.user_id == user_id,
|
||||||
|
UserSession.refresh_token_hash == security_manager.hash_token(refresh_token)
|
||||||
|
)
|
||||||
.values(is_active=False)
|
.values(is_active=False)
|
||||||
)
|
)
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
logger.info(f"User logged out: {user_id}")
|
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
|
||||||
74
services/auth/docker-compose.yml
Normal file
74
services/auth/docker-compose.yml
Normal 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:
|
||||||
0
services/auth/migrations/alembic.ini
Normal file
0
services/auth/migrations/alembic.ini
Normal file
70
services/auth/migrations/env.py
Normal file
70
services/auth/migrations/env.py
Normal 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()
|
||||||
27
services/auth/migrations/script.py.mako
Normal file
27
services/auth/migrations/script.py.mako
Normal 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"}
|
||||||
1
services/auth/tests/__init__.py
Normal file
1
services/auth/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Authentication service tests"""
|
||||||
69
services/auth/tests/conftest.py
Normal file
69
services/auth/tests/conftest.py
Normal 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"
|
||||||
|
}
|
||||||
79
services/auth/tests/test_auth.py
Normal file
79
services/auth/tests/test_auth.py
Normal 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"
|
||||||
74
services/auth/tests/test_users.py
Normal file
74
services/auth/tests/test_users.py
Normal 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"]
|
||||||
Reference in New Issue
Block a user