Initial commit

This commit is contained in:
2025-09-05 17:27:39 +02:00
commit 9dd177bddc
36 changed files with 3755 additions and 0 deletions

172
middleware/auth.py Normal file
View File

@@ -0,0 +1,172 @@
# middleware/auth.py
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from models.database_models import User
import os
import logging
logger = logging.getLogger(__name__)
# Password context for bcrypt hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Security scheme
security = HTTPBearer()
class AuthManager:
"""JWT-based authentication manager with bcrypt password hashing"""
def __init__(self):
self.secret_key = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production-please")
self.algorithm = "HS256"
self.token_expire_minutes = int(os.getenv("JWT_EXPIRE_MINUTES", "30"))
def hash_password(self, password: str) -> str:
"""Hash password using bcrypt"""
return pwd_context.hash(password)
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash"""
return pwd_context.verify(plain_password, hashed_password)
def authenticate_user(self, db: Session, username: str, password: str) -> Optional[User]:
"""Authenticate user and return user object if valid"""
user = db.query(User).filter(
(User.username == username) | (User.email == username)
).first()
if not user:
return None
if not user.is_active:
return None
if not self.verify_password(password, user.hashed_password):
return None
# Update last login
user.last_login = datetime.utcnow()
db.commit()
db.refresh(user)
return user
def create_access_token(self, user: User) -> Dict[str, Any]:
"""Create JWT access token for user"""
expires_delta = timedelta(minutes=self.token_expire_minutes)
expire = datetime.utcnow() + expires_delta
payload = {
"sub": str(user.id),
"username": user.username,
"email": user.email,
"role": user.role,
"exp": expire,
"iat": datetime.utcnow()
}
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": self.token_expire_minutes * 60 # Return in seconds
}
def verify_token(self, token: str) -> Dict[str, Any]:
"""Verify JWT token and return user data"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
# Check if token has expired
exp = payload.get("exp")
if exp is None:
raise HTTPException(status_code=401, detail="Token missing expiration")
if datetime.utcnow() > datetime.fromtimestamp(exp):
raise HTTPException(status_code=401, detail="Token has expired")
# Extract user data
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Token missing user identifier")
return {
"user_id": int(user_id),
"username": payload.get("username"),
"email": payload.get("email"),
"role": payload.get("role", "user")
}
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.JWTError as e:
logger.error(f"JWT decode error: {e}")
raise HTTPException(status_code=401, detail="Could not validate credentials")
except Exception as e:
logger.error(f"Token verification error: {e}")
raise HTTPException(status_code=401, detail="Authentication failed")
def get_current_user(self, db: Session, credentials: HTTPAuthorizationCredentials = Depends(security)) -> User:
"""Get current authenticated user from database"""
user_data = self.verify_token(credentials.credentials)
user = db.query(User).filter(User.id == user_data["user_id"]).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=401, detail="User account is inactive")
return user
def require_role(self, required_role: str):
"""Decorator to require specific role"""
def decorator(func):
def wrapper(current_user: User, *args, **kwargs):
if current_user.role != required_role:
raise HTTPException(
status_code=403,
detail=f"Required role '{required_role}' not found. Current role: '{current_user.role}'"
)
return func(current_user, *args, **kwargs)
return wrapper
return decorator
def require_admin(self, current_user: User):
"""Require admin role"""
if current_user.role != "admin":
raise HTTPException(
status_code=403,
detail="Admin privileges required"
)
return current_user
def create_default_admin_user(self, db: Session):
"""Create default admin user if it doesn't exist"""
admin_user = db.query(User).filter(User.username == "admin").first()
if not admin_user:
hashed_password = self.hash_password("admin123")
admin_user = User(
email="admin@example.com",
username="admin",
hashed_password=hashed_password,
role="admin",
is_active=True
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
logger.info("Default admin user created: username='admin', password='admin123'")
return admin_user

View File

@@ -0,0 +1,55 @@
# middleware/error_handler.py
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
import logging
logger = logging.getLogger(__name__)
async def custom_http_exception_handler(request: Request, exc: HTTPException):
"""Custom HTTP exception handler"""
logger.error(f"HTTP {exc.status_code}: {exc.detail} - {request.method} {request.url}")
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.status_code,
"message": exc.detail,
"type": "http_exception"
}
}
)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle Pydantic validation errors"""
logger.error(f"Validation error: {exc.errors()} - {request.method} {request.url}")
return JSONResponse(
status_code=422,
content={
"error": {
"code": 422,
"message": "Validation error",
"type": "validation_error",
"details": exc.errors()
}
}
)
async def general_exception_handler(request: Request, exc: Exception):
"""Handle unexpected exceptions"""
logger.error(f"Unexpected error: {str(exc)} - {request.method} {request.url}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": {
"code": 500,
"message": "Internal server error",
"type": "server_error"
}
}
)

View File

@@ -0,0 +1,46 @@
# middleware/logging_middleware.py
import logging
import time
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Callable
logger = logging.getLogger(__name__)
class LoggingMiddleware(BaseHTTPMiddleware):
"""Middleware for request/response logging and performance monitoring"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Start timing
start_time = time.time()
# Log request
client_ip = request.client.host if request.client else "unknown"
logger.info(f"Request: {request.method} {request.url.path} from {client_ip}")
# Process request
try:
response = await call_next(request)
# Calculate duration
duration = time.time() - start_time
# Log response
logger.info(
f"Response: {response.status_code} for {request.method} {request.url.path} "
f"({duration:.3f}s)"
)
# Add performance headers
response.headers["X-Process-Time"] = str(duration)
return response
except Exception as e:
duration = time.time() - start_time
logger.error(
f"Error: {str(e)} for {request.method} {request.url.path} "
f"({duration:.3f}s)"
)
raise

View File

@@ -0,0 +1,82 @@
# middleware/rate_limiter.py
from typing import Dict, Tuple
from datetime import datetime, timedelta
import logging
from collections import defaultdict, deque
logger = logging.getLogger(__name__)
class RateLimiter:
"""In-memory rate limiter using sliding window"""
def __init__(self):
# Dictionary to store request timestamps for each client
self.clients: Dict[str, deque] = defaultdict(lambda: deque())
self.cleanup_interval = 3600 # Clean up old entries every hour
self.last_cleanup = datetime.utcnow()
def allow_request(self, client_id: str, max_requests: int, window_seconds: int) -> bool:
"""
Check if client is allowed to make a request
Uses sliding window algorithm
"""
now = datetime.utcnow()
window_start = now - timedelta(seconds=window_seconds)
# Clean up old entries periodically
if (now - self.last_cleanup).seconds > self.cleanup_interval:
self._cleanup_old_entries()
self.last_cleanup = now
# Get client's request history
client_requests = self.clients[client_id]
# Remove requests outside the window
while client_requests and client_requests[0] < window_start:
client_requests.popleft()
# Check if under rate limit
if len(client_requests) < max_requests:
client_requests.append(now)
return True
logger.warning(f"Rate limit exceeded for client {client_id}: {len(client_requests)}/{max_requests}")
return False
def _cleanup_old_entries(self):
"""Clean up old entries to prevent memory leaks"""
cutoff_time = datetime.utcnow() - timedelta(hours=24)
clients_to_remove = []
for client_id, requests in self.clients.items():
# Remove old requests
while requests and requests[0] < cutoff_time:
requests.popleft()
# Mark empty clients for removal
if not requests:
clients_to_remove.append(client_id)
# Remove empty clients
for client_id in clients_to_remove:
del self.clients[client_id]
logger.info(f"Rate limiter cleanup completed. Removed {len(clients_to_remove)} inactive clients")
def get_client_stats(self, client_id: str) -> Dict[str, int]:
"""Get statistics for a specific client"""
client_requests = self.clients.get(client_id, deque())
now = datetime.utcnow()
hour_ago = now - timedelta(hours=1)
day_ago = now - timedelta(days=1)
requests_last_hour = sum(1 for req_time in client_requests if req_time > hour_ago)
requests_last_day = sum(1 for req_time in client_requests if req_time > day_ago)
return {
"requests_last_hour": requests_last_hour,
"requests_last_day": requests_last_day,
"total_tracked_requests": len(client_requests)
}