Adds auth module
This commit is contained in:
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
|
||||
@@ -7,12 +10,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import logging
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest, UserResponse
|
||||
from app.schemas.auth import (
|
||||
UserRegistration, UserLogin, TokenResponse,
|
||||
RefreshTokenRequest, UserResponse
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
from app.core.security import security_manager
|
||||
from shared.monitoring.metrics import MetricsCollector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
metrics = MetricsCollector("auth_service")
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
async def register(
|
||||
@@ -21,11 +29,14 @@ async def register(
|
||||
):
|
||||
"""Register a new user"""
|
||||
try:
|
||||
return await AuthService.register_user(user_data, db)
|
||||
metrics.increment_counter("auth_registration_total")
|
||||
result = await AuthService.register_user(user_data, db)
|
||||
logger.info(f"User registration successful: {user_data.email}")
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Registration error: {e}")
|
||||
logger.error(f"Registration error for {user_data.email}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Registration failed"
|
||||
@@ -42,11 +53,16 @@ async def login(
|
||||
ip_address = request.client.host
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
return await AuthService.login_user(login_data, db, ip_address, user_agent)
|
||||
except HTTPException:
|
||||
result = await AuthService.login_user(login_data, db, ip_address, user_agent)
|
||||
metrics.increment_counter("auth_login_success_total")
|
||||
return result
|
||||
except HTTPException as e:
|
||||
metrics.increment_counter("auth_login_failure_total")
|
||||
logger.warning(f"Login failed for {login_data.email}: {e.detail}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {e}")
|
||||
metrics.increment_counter("auth_login_failure_total")
|
||||
logger.error(f"Login error for {login_data.email}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Login failed"
|
||||
@@ -84,7 +100,15 @@ async def verify_token(
|
||||
)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
return await AuthService.verify_token(token, db)
|
||||
token_data = await AuthService.verify_token(token)
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"user_id": token_data.get("user_id"),
|
||||
"email": token_data.get("email"),
|
||||
"role": token_data.get("role"),
|
||||
"tenant_id": token_data.get("tenant_id")
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -96,29 +120,27 @@ async def verify_token(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
refresh_data: RefreshTokenRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""User logout"""
|
||||
"""Logout user"""
|
||||
try:
|
||||
# Get user from token
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header required"
|
||||
)
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header.split(" ")[1]
|
||||
token_data = await AuthService.verify_token(token)
|
||||
user_id = token_data.get("user_id")
|
||||
|
||||
if user_id:
|
||||
success = await AuthService.logout_user(user_id, refresh_data.refresh_token, db)
|
||||
return {"success": success}
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
user_data = await AuthService.verify_token(token, db)
|
||||
|
||||
await AuthService.logout_user(user_data["user_id"], db)
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
except HTTPException:
|
||||
raise
|
||||
return {"success": False, "message": "Invalid token"}
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Logout failed"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# ================================================================
|
||||
# services/auth/app/core/security.py (COMPLETE VERSION)
|
||||
# ================================================================
|
||||
"""
|
||||
Security utilities for authentication service
|
||||
"""
|
||||
|
||||
import bcrypt
|
||||
import re
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import redis.asyncio as redis
|
||||
@@ -72,6 +76,11 @@ class SecurityManager:
|
||||
"""Verify JWT token"""
|
||||
return jwt_handler.verify_token(token)
|
||||
|
||||
@staticmethod
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash token for storage"""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
async def check_login_attempts(email: str) -> bool:
|
||||
"""Check if user has exceeded login attempts"""
|
||||
@@ -83,71 +92,64 @@ class SecurityManager:
|
||||
return True
|
||||
|
||||
return int(attempts) < settings.MAX_LOGIN_ATTEMPTS
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking login attempts: {e}")
|
||||
return True
|
||||
return True # Allow on error
|
||||
|
||||
@staticmethod
|
||||
async def increment_login_attempts(email: str):
|
||||
"""Increment login attempts counter"""
|
||||
async def increment_login_attempts(email: str) -> None:
|
||||
"""Increment login attempts for email"""
|
||||
try:
|
||||
key = f"login_attempts:{email}"
|
||||
current_attempts = await redis_client.incr(key)
|
||||
|
||||
# Set TTL on first attempt
|
||||
if current_attempts == 1:
|
||||
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
|
||||
|
||||
await redis_client.incr(key)
|
||||
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
|
||||
except Exception as e:
|
||||
logger.error(f"Error incrementing login attempts: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def clear_login_attempts(email: str):
|
||||
"""Clear login attempts counter"""
|
||||
async def clear_login_attempts(email: str) -> None:
|
||||
"""Clear login attempts for email"""
|
||||
try:
|
||||
key = f"login_attempts:{email}"
|
||||
await redis_client.delete(key)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing login attempts: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def store_refresh_token(user_id: str, refresh_token: str):
|
||||
async def store_refresh_token(user_id: str, token: str) -> None:
|
||||
"""Store refresh token in Redis"""
|
||||
try:
|
||||
key = f"refresh_token:{user_id}"
|
||||
expires_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600
|
||||
await redis_client.setex(key, expires_seconds, refresh_token)
|
||||
token_hash = SecurityManager.hash_token(token)
|
||||
key = f"refresh_token:{user_id}:{token_hash}"
|
||||
|
||||
# Store for the duration of the refresh token
|
||||
expire_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
||||
await redis_client.setex(key, expire_seconds, "valid")
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing refresh token: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def verify_refresh_token(user_id: str, refresh_token: str) -> bool:
|
||||
"""Verify refresh token"""
|
||||
async def verify_refresh_token(user_id: str, token: str) -> bool:
|
||||
"""Verify refresh token exists in Redis"""
|
||||
try:
|
||||
key = f"refresh_token:{user_id}"
|
||||
stored_token = await redis_client.get(key)
|
||||
|
||||
if stored_token is None:
|
||||
return False
|
||||
|
||||
return stored_token.decode() == refresh_token
|
||||
token_hash = SecurityManager.hash_token(token)
|
||||
key = f"refresh_token:{user_id}:{token_hash}"
|
||||
|
||||
result = await redis_client.get(key)
|
||||
return result is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying refresh token: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def revoke_refresh_token(user_id: str):
|
||||
async def revoke_refresh_token(user_id: str, token: str) -> None:
|
||||
"""Revoke refresh token"""
|
||||
try:
|
||||
key = f"refresh_token:{user_id}"
|
||||
token_hash = SecurityManager.hash_token(token)
|
||||
key = f"refresh_token:{user_id}:{token_hash}"
|
||||
await redis_client.delete(key)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error revoking refresh token: {e}")
|
||||
|
||||
# Global security manager instance
|
||||
security_manager = SecurityManager()
|
||||
# Create singleton instance
|
||||
security_manager = SecurityManager()
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
@@ -59,9 +62,9 @@ class AuthService:
|
||||
"user_events",
|
||||
"user.registered",
|
||||
UserRegisteredEvent(
|
||||
event_id="",
|
||||
event_id=str(user.id),
|
||||
service_name="auth-service",
|
||||
timestamp= datetime.now(datetime.timezone.utc),
|
||||
timestamp=datetime.now(datetime.timezone.utc),
|
||||
data={
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
@@ -111,12 +114,13 @@ class AuthService:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(last_login= datetime.now(datetime.timezone.utc))
|
||||
.values(last_login=datetime.now(datetime.timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Create tokens
|
||||
token_data = {
|
||||
"sub": str(user.id), # Standard JWT claim for subject
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
@@ -132,10 +136,10 @@ class AuthService:
|
||||
# Create session record
|
||||
session = UserSession(
|
||||
user_id=user.id,
|
||||
refresh_token_hash=security_manager.hash_password(refresh_token),
|
||||
expires_at= datetime.now(datetime.timezone.utc) + timedelta(days=7),
|
||||
refresh_token_hash=security_manager.hash_token(refresh_token),
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
user_agent=user_agent,
|
||||
expires_at=datetime.now(datetime.timezone.utc) + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
)
|
||||
|
||||
db.add(session)
|
||||
@@ -146,9 +150,9 @@ class AuthService:
|
||||
"user_events",
|
||||
"user.login",
|
||||
UserLoginEvent(
|
||||
event_id="",
|
||||
event_id=str(session.id),
|
||||
service_name="auth-service",
|
||||
timestamp= datetime.now(datetime.timezone.utc),
|
||||
timestamp=datetime.now(datetime.timezone.utc),
|
||||
data={
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
@@ -158,38 +162,39 @@ class AuthService:
|
||||
).__dict__
|
||||
)
|
||||
|
||||
logger.info(f"User logged in: {user.email}")
|
||||
logger.info(f"User login successful: {user.email}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def refresh_token(refresh_token: str, db: AsyncSession) -> TokenResponse:
|
||||
"""Refresh access token"""
|
||||
"""Refresh access token using refresh token"""
|
||||
|
||||
# Verify refresh token
|
||||
payload = security_manager.verify_token(refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
token_data = security_manager.verify_token(refresh_token)
|
||||
if not token_data or token_data.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
user_id = token_data.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
detail="Invalid token data"
|
||||
)
|
||||
|
||||
# Verify refresh token is stored
|
||||
# Check if refresh token exists in Redis
|
||||
if not await security_manager.verify_refresh_token(user_id, refresh_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
detail="Refresh token not found or expired"
|
||||
)
|
||||
|
||||
# Get user
|
||||
@@ -205,80 +210,68 @@ class AuthService:
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
token_data = {
|
||||
new_token_data = {
|
||||
"sub": str(user.id),
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role": user.role
|
||||
}
|
||||
|
||||
new_access_token = security_manager.create_access_token(token_data)
|
||||
new_refresh_token = security_manager.create_refresh_token(token_data)
|
||||
new_access_token = security_manager.create_access_token(new_token_data)
|
||||
new_refresh_token = security_manager.create_refresh_token(new_token_data)
|
||||
|
||||
# Update stored refresh token
|
||||
await security_manager.store_refresh_token(str(user.id), new_refresh_token)
|
||||
# Revoke old refresh token
|
||||
await security_manager.revoke_refresh_token(user_id, refresh_token)
|
||||
|
||||
# Store new refresh token
|
||||
await security_manager.store_refresh_token(user_id, new_refresh_token)
|
||||
|
||||
logger.info(f"Token refreshed for user: {user.email}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def verify_token(token: str, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""Verify access token"""
|
||||
async def verify_token(token: str) -> Dict[str, Any]:
|
||||
"""Verify access token and return user data"""
|
||||
|
||||
# Verify token
|
||||
payload = security_manager.verify_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
token_data = security_manager.verify_token(token)
|
||||
if not token_data or token_data.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token"
|
||||
detail="Invalid access token"
|
||||
)
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Get user
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive"
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role": user.role,
|
||||
"full_name": user.full_name,
|
||||
"is_verified": user.is_verified
|
||||
}
|
||||
return token_data
|
||||
|
||||
@staticmethod
|
||||
async def logout_user(user_id: str, db: AsyncSession):
|
||||
async def logout_user(user_id: str, refresh_token: str, db: AsyncSession) -> bool:
|
||||
"""Logout user and revoke tokens"""
|
||||
|
||||
# Revoke refresh token
|
||||
await security_manager.revoke_refresh_token(user_id)
|
||||
|
||||
# Deactivate user sessions
|
||||
await db.execute(
|
||||
update(UserSession)
|
||||
.where(UserSession.user_id == user_id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"User logged out: {user_id}")
|
||||
try:
|
||||
# Revoke refresh token
|
||||
await security_manager.revoke_refresh_token(user_id, refresh_token)
|
||||
|
||||
# Deactivate session
|
||||
await db.execute(
|
||||
update(UserSession)
|
||||
.where(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.refresh_token_hash == security_manager.hash_token(refresh_token)
|
||||
)
|
||||
.values(is_active=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"User logged out: {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error logging out user {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
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