Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
375 lines
13 KiB
Python
375 lines
13 KiB
Python
"""
|
|
FastAPI Authentication Middleware pentru ROA2WEB
|
|
|
|
Acest modul oferă middleware pentru autentificarea automată în aplicațiile FastAPI,
|
|
incluzând extragerea token-urilor, validarea și injectarea datelor utilizatorului
|
|
în contextul request-ului.
|
|
|
|
Funcționalități:
|
|
- Extragere automată token JWT din header Authorization
|
|
- Validare token și user data injection
|
|
- Rate limiting pentru endpoint-urile de autentificare
|
|
- Logging pentru securitate și monitoring
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Optional, Callable, Dict, Any, List, Set
|
|
from collections import defaultdict, deque
|
|
from datetime import datetime, timedelta
|
|
|
|
from fastapi import Request, Response, HTTPException, status
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.responses import JSONResponse
|
|
|
|
from .jwt_handler import jwt_handler, TokenData
|
|
from .auth_service import auth_service
|
|
from .models import CurrentUser, AuthError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RateLimiter:
|
|
"""
|
|
Rate limiter pentru protejarea endpoint-urilor de autentificare
|
|
"""
|
|
|
|
def __init__(self, max_requests: int = 5, time_window: int = 300):
|
|
"""
|
|
Inițializează rate limiter
|
|
|
|
Args:
|
|
max_requests: Numărul maxim de request-uri permise
|
|
time_window: Fereastra de timp în secunde
|
|
"""
|
|
self.max_requests = max_requests
|
|
self.time_window = time_window
|
|
self.requests: Dict[str, deque] = defaultdict(deque)
|
|
|
|
def is_allowed(self, client_ip: str) -> bool:
|
|
"""
|
|
Verifică dacă request-ul este permis pentru acest IP
|
|
|
|
Args:
|
|
client_ip: Adresa IP a clientului
|
|
|
|
Returns:
|
|
True dacă request-ul este permis
|
|
"""
|
|
now = time.time()
|
|
client_requests = self.requests[client_ip]
|
|
|
|
# Șterge request-urile vechi
|
|
while client_requests and client_requests[0] < now - self.time_window:
|
|
client_requests.popleft()
|
|
|
|
# Verifică dacă putem accepta încă un request
|
|
if len(client_requests) >= self.max_requests:
|
|
return False
|
|
|
|
# Adaugă request-ul curent
|
|
client_requests.append(now)
|
|
return True
|
|
|
|
def get_reset_time(self, client_ip: str) -> int:
|
|
"""
|
|
Returnează timpul când rate limiting se resetează pentru acest IP
|
|
|
|
Args:
|
|
client_ip: Adresa IP a clientului
|
|
|
|
Returns:
|
|
Timestamp când se resetează
|
|
"""
|
|
client_requests = self.requests[client_ip]
|
|
if not client_requests:
|
|
return int(time.time())
|
|
|
|
return int(client_requests[0] + self.time_window)
|
|
|
|
|
|
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Middleware pentru autentificarea automată în FastAPI
|
|
|
|
Acest middleware:
|
|
- Extrage token-ul JWT din header-ul Authorization
|
|
- Validează token-ul și obține datele utilizatorului
|
|
- Injectează utilizatorul curent în request.state
|
|
- Aplică rate limiting pentru endpoint-urile sensibile
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
app,
|
|
excluded_paths: Optional[List[str]] = None,
|
|
rate_limit_paths: Optional[List[str]] = None,
|
|
rate_limiter: Optional[RateLimiter] = None
|
|
):
|
|
"""
|
|
Inițializează middleware-ul
|
|
|
|
Args:
|
|
app: Aplicația FastAPI
|
|
excluded_paths: Căile care nu necesită autentificare
|
|
rate_limit_paths: Căile cu rate limiting
|
|
rate_limiter: Instance de rate limiter personalizat
|
|
"""
|
|
super().__init__(app)
|
|
|
|
self.excluded_paths = excluded_paths or [
|
|
"/docs", "/redoc", "/openapi.json", "/health", "/",
|
|
"/auth/login", "/auth/register"
|
|
]
|
|
|
|
self.rate_limit_paths = rate_limit_paths or [
|
|
"/auth/login", "/auth/register", "/auth/forgot-password"
|
|
]
|
|
|
|
self.rate_limiter = rate_limiter or RateLimiter(max_requests=5, time_window=300)
|
|
|
|
logger.info(f"Authentication middleware initialized with {len(self.excluded_paths)} excluded paths")
|
|
|
|
def _get_client_ip(self, request: Request) -> str:
|
|
"""Obține adresa IP a clientului"""
|
|
# Verifică header-ele proxy
|
|
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
if forwarded_for:
|
|
return forwarded_for.split(",")[0].strip()
|
|
|
|
real_ip = request.headers.get("X-Real-IP")
|
|
if real_ip:
|
|
return real_ip
|
|
|
|
# Fallback la client IP direct
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
def _should_exclude_path(self, path: str) -> bool:
|
|
"""Verifică dacă path-ul trebuie exclus de la autentificare"""
|
|
# Special case for root path to avoid excluding all paths that start with "/"
|
|
if "/" in self.excluded_paths and path == "/":
|
|
return True
|
|
# Check other excluded paths (excluding "/" to avoid matching all paths)
|
|
excluded_paths_no_root = [p for p in self.excluded_paths if p != "/"]
|
|
return any(path.startswith(excluded) for excluded in excluded_paths_no_root)
|
|
|
|
def _should_rate_limit_path(self, path: str) -> bool:
|
|
"""Verifică dacă path-ul necesită rate limiting"""
|
|
return any(path.startswith(limited) for limited in self.rate_limit_paths)
|
|
|
|
def _extract_token_from_header(self, request: Request) -> Optional[str]:
|
|
"""
|
|
Extrage token-ul JWT în header-ul Authorization
|
|
|
|
Args:
|
|
request: Request-ul HTTP
|
|
|
|
Returns:
|
|
Token-ul JWT sau None
|
|
"""
|
|
authorization = request.headers.get("Authorization")
|
|
if not authorization:
|
|
return None
|
|
|
|
if not authorization.startswith("Bearer "):
|
|
return None
|
|
|
|
return authorization[7:] # Elimină "Bearer "
|
|
|
|
async def _create_current_user(self, token_data: TokenData) -> CurrentUser:
|
|
"""
|
|
Creează obiectul CurrentUser din token data
|
|
|
|
Args:
|
|
token_data: Datele din token
|
|
|
|
Returns:
|
|
Obiectul CurrentUser
|
|
"""
|
|
return CurrentUser(
|
|
username=token_data.username,
|
|
user_id=token_data.user_id,
|
|
companies=token_data.companies,
|
|
permissions=token_data.permissions,
|
|
last_login=datetime.now()
|
|
)
|
|
|
|
async def _handle_rate_limiting(self, request: Request, path: str) -> Optional[Response]:
|
|
"""
|
|
Gestionează rate limiting pentru căile sensibile
|
|
|
|
Args:
|
|
request: Request-ul HTTP
|
|
path: Calea request-ului
|
|
|
|
Returns:
|
|
Response cu eroare dacă este rate limited, None altfel
|
|
"""
|
|
if not self._should_rate_limit_path(path):
|
|
return None
|
|
|
|
client_ip = self._get_client_ip(request)
|
|
|
|
if not self.rate_limiter.is_allowed(client_ip):
|
|
reset_time = self.rate_limiter.get_reset_time(client_ip)
|
|
|
|
logger.warning(f"Rate limit exceeded for IP {client_ip} on path {path}")
|
|
|
|
error = AuthError(
|
|
error="rate_limit_exceeded",
|
|
error_description="Too many requests. Please try again later.",
|
|
error_code="RATE_LIMIT_001"
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
content=error.dict(),
|
|
headers={
|
|
"X-RateLimit-Limit": str(self.rate_limiter.max_requests),
|
|
"X-RateLimit-Remaining": "0",
|
|
"X-RateLimit-Reset": str(reset_time),
|
|
"Retry-After": str(reset_time - int(time.time()))
|
|
}
|
|
)
|
|
|
|
return None
|
|
|
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
"""
|
|
Procesează request-ul prin middleware
|
|
|
|
Args:
|
|
request: Request-ul HTTP
|
|
call_next: Următorul handler din pipeline
|
|
|
|
Returns:
|
|
Response-ul HTTP
|
|
"""
|
|
start_time = time.time()
|
|
path = request.url.path
|
|
|
|
# IMPORTANT: Allow OPTIONS requests (CORS preflight) to pass through
|
|
if request.method == "OPTIONS":
|
|
response = await call_next(request)
|
|
return response
|
|
|
|
# Rate limiting pentru căile sensibile
|
|
rate_limit_response = await self._handle_rate_limiting(request, path)
|
|
if rate_limit_response:
|
|
return rate_limit_response
|
|
|
|
# Skip autentificare pentru căile excluse
|
|
if self._should_exclude_path(path):
|
|
request.state.user = None
|
|
request.state.is_authenticated = False
|
|
response = await call_next(request)
|
|
return response
|
|
|
|
# Extrage token-ul
|
|
token = self._extract_token_from_header(request)
|
|
|
|
if not token:
|
|
# Nu există token - pentru endpoint-urile protejate returnează 401
|
|
logger.warning(f"No token provided for protected path {path}")
|
|
|
|
error = AuthError(
|
|
error="authentication_required",
|
|
error_description="Authentication required",
|
|
error_code="AUTH_003"
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
content=error.dict(),
|
|
headers={"WWW-Authenticate": "Bearer"}
|
|
)
|
|
|
|
# Validează token-ul
|
|
token_data = jwt_handler.verify_token(token)
|
|
|
|
if not token_data:
|
|
# Token invalid
|
|
logger.warning(f"Invalid token used for path {path}")
|
|
|
|
error = AuthError(
|
|
error="invalid_token",
|
|
error_description="The provided token is invalid or expired.",
|
|
error_code="AUTH_001"
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
content=error.dict(),
|
|
headers={"WWW-Authenticate": "Bearer"}
|
|
)
|
|
|
|
# Token valid - creează utilizatorul curent
|
|
try:
|
|
current_user = await self._create_current_user(token_data)
|
|
request.state.user = current_user
|
|
request.state.is_authenticated = True
|
|
request.state.token_data = token_data
|
|
# Extrage server_id din token pentru a fi folosit în query-uri Oracle
|
|
request.state.server_id = token_data.server_id
|
|
|
|
logger.debug(f"User {current_user.username} authenticated successfully for path {path} (server: {token_data.server_id or 'default'})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating current user: {str(e)}")
|
|
|
|
error = AuthError(
|
|
error="authentication_error",
|
|
error_description="Authentication processing error.",
|
|
error_code="AUTH_002"
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
content=error.dict()
|
|
)
|
|
|
|
# Procesează request-ul
|
|
response = await call_next(request)
|
|
|
|
# Adaugă header-e de securitate
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
|
|
# Log timpul de procesare
|
|
process_time = time.time() - start_time
|
|
response.headers["X-Process-Time"] = str(process_time)
|
|
|
|
return response
|
|
|
|
|
|
class HTTPBearerOptional(HTTPBearer):
|
|
"""
|
|
Versiune opțională pentru autentificare care nu aruncă excepții
|
|
dacă token-ul lipsește - utile pentru endpoint-urile care
|
|
pot funcționa atât cu cât și fără autentificare
|
|
"""
|
|
|
|
async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]:
|
|
"""
|
|
Extrage credențialele de autentificare fără să arunce excepții
|
|
|
|
Args:
|
|
request: Request-ul HTTP
|
|
|
|
Returns:
|
|
Credențialele sau None
|
|
"""
|
|
try:
|
|
return await super().__call__(request)
|
|
except HTTPException:
|
|
return None
|
|
|
|
|
|
# Instance predefinite pentru folosire rapidă
|
|
security_optional = HTTPBearerOptional(auto_error=False)
|
|
security_required = HTTPBearer()
|
|
|
|
# Rate limiter default
|
|
default_rate_limiter = RateLimiter(max_requests=5, time_window=300) |