Adds auth module

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

View File

@@ -15,9 +15,6 @@ class Settings(BaseSettings):
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
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"""

View File

@@ -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")
# 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()
self.consul_url = settings.CONSUL_URL if hasattr(settings, 'CONSUL_URL') else None
self.service_cache: Dict[str, str] = {}
async def get_service_url(self, service_name: str) -> Optional[str]:
"""Get service URL"""
return self.services.get(service_name)
"""Get service URL from service discovery"""
async def get_healthy_services(self) -> List[str]:
"""Get list of healthy services"""
healthy_services = []
# Return cached URL if available
if service_name in self.service_cache:
return self.service_cache[service_name]
for service_name in self.services:
is_healthy = await self._is_service_healthy(service_name)
if is_healthy:
healthy_services.append(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}")
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)
# Fall back to environment variables
return self._get_from_env(service_name)
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
except Exception as e:
logger.warning(f"Service health check failed: {e}")
return False
response = await client.get(
f"{self.consul_url}/v1/health/service/{service_name}?passing=true"
)
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
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.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:
"""Check if service is healthy from Redis cache"""
try:
key = f"service_health:{service_name}"
health_data = await self.redis_client.hgetall(key)
return None
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
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)

View File

@@ -1,161 +1,262 @@
# ================================================================
# gateway/app/routes/auth.py (UPDATED VERSION)
# ================================================================
"""
Authentication routes for gateway
Authentication routes for API Gateway
Enhanced version that properly proxies to auth microservice
"""
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging
import 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")
# 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:
# 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()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/login",
# 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(
# 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,
content=response.json()
headers=response_headers,
media_type=response.headers.get("content-type")
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
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=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"
)
except Exception as e:
logger.error(f"Login error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
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()
"""Proxy user registration to auth service"""
return await auth_proxy.forward_request("POST", "register", request)
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")
@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()
)
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 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"Logout error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
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()

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

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

View File

@@ -1,5 +1,8 @@
# ================================================================
# services/auth/app/api/auth.py (ENHANCED VERSION)
# ================================================================
"""
Authentication API routes
Authentication API routes - Enhanced with proper error handling and logging
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
@@ -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,26 +120,24 @@ 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]
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"}
except HTTPException:
raise
return {"success": False, "message": "Invalid token"}
except Exception as e:
logger.error(f"Logout error: {e}")
raise HTTPException(

View File

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

View File

@@ -1,9 +1,13 @@
# ================================================================
# services/auth/app/core/security.py (COMPLETE VERSION)
# ================================================================
"""
Security utilities for authentication service
"""
import bcrypt
import re
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import redis.asyncio as redis
@@ -72,6 +76,11 @@ class SecurityManager:
"""Verify JWT token"""
return jwt_handler.verify_token(token)
@staticmethod
def hash_token(token: str) -> str:
"""Hash token for storage"""
return hashlib.sha256(token.encode()).hexdigest()
@staticmethod
async def check_login_attempts(email: str) -> bool:
"""Check if user has exceeded login attempts"""
@@ -83,71 +92,64 @@ class SecurityManager:
return True
return int(attempts) < settings.MAX_LOGIN_ATTEMPTS
except Exception as e:
logger.error(f"Error checking login attempts: {e}")
return True
return True # Allow on error
@staticmethod
async def increment_login_attempts(email: str):
"""Increment login attempts counter"""
async def increment_login_attempts(email: str) -> None:
"""Increment login attempts for email"""
try:
key = f"login_attempts:{email}"
current_attempts = await redis_client.incr(key)
# Set TTL on first attempt
if current_attempts == 1:
await redis_client.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
# Create singleton instance
security_manager = SecurityManager()

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
# ================================================================
# services/auth/app/services/auth_service.py (COMPLETE VERSION)
# ================================================================
"""
Authentication service business logic
Authentication service business logic - Complete implementation
"""
import logging
@@ -59,7 +62,7 @@ class AuthService:
"user_events",
"user.registered",
UserRegisteredEvent(
event_id="",
event_id=str(user.id),
service_name="auth-service",
timestamp=datetime.now(datetime.timezone.utc),
data={
@@ -117,6 +120,7 @@ class AuthService:
# 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,7 +150,7 @@ class AuthService:
"user_events",
"user.login",
UserLoginEvent(
event_id="",
event_id=str(session.id),
service_name="auth-service",
timestamp=datetime.now(datetime.timezone.utc),
data={
@@ -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"""
try:
# 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(
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)
)
await db.commit()
await db.commit()
logger.info(f"User logged out: {user_id}")
return True
except Exception as e:
logger.error(f"Error logging out user {user_id}: {e}")
await db.rollback()
return False

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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