feat: multi-Oracle server support with runtime switching
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>
This commit is contained in:
@@ -23,15 +23,16 @@ from fastapi.security import HTTPAuthorizationCredentials
|
||||
from .models import (
|
||||
LoginRequest, TokenResponse, RefreshTokenRequest, LogoutRequest,
|
||||
CurrentUser, UserCompany, CompanyAccessRequest, CompanyAccessResponse,
|
||||
AuthError, AuthStats
|
||||
AuthError, AuthStats, CheckEmailRequest, CheckEmailResponse, ServerInfo,
|
||||
CheckIdentityRequest, CheckIdentityResponse
|
||||
)
|
||||
from .auth_service import auth_service, AuthenticationError
|
||||
from .jwt_handler import jwt_handler
|
||||
from .dependencies import (
|
||||
get_current_user, get_optional_user,
|
||||
get_current_user, get_optional_user,
|
||||
security_required, security_optional
|
||||
)
|
||||
from .middleware import default_rate_limiter
|
||||
from .middleware import default_rate_limiter, RateLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,7 +54,175 @@ def create_auth_router(
|
||||
Router-ul FastAPI configurat
|
||||
"""
|
||||
router = APIRouter(prefix=prefix, tags=tags or ["authentication"])
|
||||
|
||||
|
||||
# Rate limiter pentru check-identity/check-email: 5 requests per minut per IP
|
||||
check_identity_rate_limiter = RateLimiter(max_requests=5, time_window=60)
|
||||
|
||||
@router.post("/check-identity", response_model=CheckIdentityResponse, status_code=status.HTTP_200_OK)
|
||||
async def check_identity(
|
||||
check_data: CheckIdentityRequest,
|
||||
request: Request
|
||||
) -> CheckIdentityResponse:
|
||||
"""
|
||||
Verifică dacă un email sau username există în sistem și pe câte servere Oracle (US-013).
|
||||
|
||||
Acest endpoint suportă dual login:
|
||||
- Input cu '@': tratează ca email și caută în EmailServerCache
|
||||
- Input fără '@': tratează ca username și caută direct în Oracle
|
||||
|
||||
SECURITATE:
|
||||
- Rate limited: max 5 requests/minut per IP
|
||||
- NU expune serverele disponibile pentru identități invalide
|
||||
- Identități invalide returnează {exists: false, servers: []}
|
||||
|
||||
Args:
|
||||
check_data: Identitatea de verificat (email sau username)
|
||||
request: Request-ul HTTP (pentru rate limiting)
|
||||
|
||||
Returns:
|
||||
CheckIdentityResponse cu exists, servers[] și identity_type
|
||||
|
||||
Raises:
|
||||
HTTPException 429: Rate limit exceeded
|
||||
"""
|
||||
# Rate limiting - 5 req/min per IP
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
if not check_identity_rate_limiter.is_allowed(client_ip):
|
||||
reset_time = check_identity_rate_limiter.get_reset_time(client_ip)
|
||||
logger.warning(f"Rate limit exceeded for check-identity from IP {client_ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many requests. Please try again later.",
|
||||
headers={
|
||||
"X-RateLimit-Limit": "5",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_time),
|
||||
"Retry-After": str(max(1, reset_time - int(__import__('time').time())))
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
from .email_server_cache import email_server_cache
|
||||
from backend.config import settings
|
||||
|
||||
identity = check_data.identity # Already normalized by validator
|
||||
is_email = '@' in identity
|
||||
|
||||
identity_type = "email" if is_email else "username"
|
||||
logger.info(f"Check-identity request for '{identity}' (type: {identity_type}) from IP {client_ip}")
|
||||
|
||||
# Get server IDs based on identity type
|
||||
if is_email:
|
||||
# Email lookup from cache
|
||||
server_ids = email_server_cache.get_servers_for_email(identity)
|
||||
else:
|
||||
# Username lookup directly from Oracle (async)
|
||||
server_ids = await email_server_cache.get_servers_for_username(identity)
|
||||
|
||||
if not server_ids:
|
||||
# Identity not found - return empty response (don't expose available servers!)
|
||||
logger.info(f"Identity '{identity}' not found in any server")
|
||||
return CheckIdentityResponse(exists=False, servers=[], identity_type=identity_type)
|
||||
|
||||
# Build server info list with human-readable names
|
||||
servers: List[ServerInfo] = []
|
||||
for server_id in server_ids:
|
||||
server_config = settings.get_oracle_server(server_id)
|
||||
if server_config:
|
||||
servers.append(ServerInfo(
|
||||
id=server_config.id,
|
||||
name=server_config.name
|
||||
))
|
||||
else:
|
||||
# Fallback if server config not found (shouldn't happen)
|
||||
logger.warning(f"Server '{server_id}' not found in config")
|
||||
servers.append(ServerInfo(id=server_id, name=server_id))
|
||||
|
||||
logger.info(f"Identity '{identity}' found on {len(servers)} server(s): {[s.id for s in servers]}")
|
||||
return CheckIdentityResponse(exists=True, servers=servers, identity_type=identity_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking identity '{check_data.identity}': {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error checking identity"
|
||||
)
|
||||
|
||||
@router.post("/check-email", response_model=CheckEmailResponse, status_code=status.HTTP_200_OK)
|
||||
async def check_email(
|
||||
check_data: CheckEmailRequest,
|
||||
request: Request
|
||||
) -> CheckEmailResponse:
|
||||
"""
|
||||
Verifică dacă un email există în sistem și pe câte servere Oracle.
|
||||
|
||||
DEPRECATED: Folosește /check-identity pentru suport dual email/username.
|
||||
Păstrat pentru backward compatibility.
|
||||
|
||||
Args:
|
||||
check_data: Email-ul de verificat
|
||||
request: Request-ul HTTP (pentru rate limiting)
|
||||
|
||||
Returns:
|
||||
CheckEmailResponse cu exists și servers[]
|
||||
"""
|
||||
# Rate limiting - shared with check-identity
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
if not check_identity_rate_limiter.is_allowed(client_ip):
|
||||
reset_time = check_identity_rate_limiter.get_reset_time(client_ip)
|
||||
logger.warning(f"Rate limit exceeded for check-email from IP {client_ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many requests. Please try again later.",
|
||||
headers={
|
||||
"X-RateLimit-Limit": "5",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_time),
|
||||
"Retry-After": str(max(1, reset_time - int(__import__('time').time())))
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
from .email_server_cache import email_server_cache
|
||||
from backend.config import settings
|
||||
|
||||
email = check_data.email.lower().strip()
|
||||
logger.info(f"Check-email request for '{email}' from IP {client_ip}")
|
||||
|
||||
# Get server IDs from cache
|
||||
server_ids = email_server_cache.get_servers_for_email(email)
|
||||
|
||||
if not server_ids:
|
||||
# Email not found - return empty response (don't expose available servers!)
|
||||
logger.info(f"Email '{email}' not found in any server")
|
||||
return CheckEmailResponse(exists=False, servers=[])
|
||||
|
||||
# Build server info list with human-readable names
|
||||
servers: List[ServerInfo] = []
|
||||
for server_id in server_ids:
|
||||
server_config = settings.get_oracle_server(server_id)
|
||||
if server_config:
|
||||
servers.append(ServerInfo(
|
||||
id=server_config.id,
|
||||
name=server_config.name
|
||||
))
|
||||
else:
|
||||
# Fallback if server config not found (shouldn't happen)
|
||||
logger.warning(f"Server '{server_id}' not found in config")
|
||||
servers.append(ServerInfo(id=server_id, name=server_id))
|
||||
|
||||
logger.info(f"Email '{email}' found on {len(servers)} server(s): {[s.id for s in servers]}")
|
||||
return CheckEmailResponse(exists=True, servers=servers)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking email '{check_data.email}': {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error checking email"
|
||||
)
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
@@ -62,58 +231,77 @@ def create_auth_router(
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Autentifică un utilizator și returnează token-urile JWT
|
||||
|
||||
|
||||
Acest endpoint:
|
||||
- Validează credențialele utilizatorului în Oracle
|
||||
- Obține firmele la care utilizatorul are acces
|
||||
- Generează access și refresh token-uri JWT
|
||||
- Aplică rate limiting pentru securitate
|
||||
|
||||
- Suportă modul multi-server (server_id opțional)
|
||||
|
||||
Args:
|
||||
login_data: Datele de autentificare (username, password)
|
||||
login_data: Datele de autentificare (username, password, server_id opțional)
|
||||
request: Request-ul HTTP (pentru rate limiting)
|
||||
response: Response-ul HTTP (pentru header-e)
|
||||
|
||||
|
||||
Returns:
|
||||
Token-urile JWT și informațiile utilizatorului
|
||||
|
||||
|
||||
Raises:
|
||||
HTTPException: Pentru credențiale invalide sau erori de sistem
|
||||
HTTPException 400: Pentru server_id invalid
|
||||
HTTPException 401: Pentru credențiale invalide
|
||||
HTTPException 500: Pentru erori de sistem
|
||||
"""
|
||||
try:
|
||||
# Log tentativa de autentificare
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
logger.info(f"Login attempt for user {login_data.username} from IP {client_ip}")
|
||||
|
||||
server_info = f" on server {login_data.server_id}" if login_data.server_id else ""
|
||||
logger.info(f"Login attempt for user {login_data.username}{server_info} from IP {client_ip}")
|
||||
|
||||
# Validare server_id dacă specificat (multi-server mode)
|
||||
if login_data.server_id:
|
||||
from backend.config import settings
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
# Verifică dacă serverul există în configurație
|
||||
server_config = settings.get_oracle_server(login_data.server_id)
|
||||
if not server_config:
|
||||
logger.warning(f"Invalid server_id '{login_data.server_id}' in login request")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid server_id: '{login_data.server_id}'. Server not found in configuration."
|
||||
)
|
||||
|
||||
# Verifică dacă serverul este înregistrat în pool
|
||||
if not oracle_pool.is_server_registered(login_data.server_id):
|
||||
logger.warning(f"Server '{login_data.server_id}' not registered in pool")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Server '{login_data.server_id}' is not available."
|
||||
)
|
||||
|
||||
# Autentifică și creează token-urile
|
||||
success, token_response, error_message = await auth_service.authenticate_and_create_tokens(
|
||||
login_data.username,
|
||||
login_data.password
|
||||
login_data.password,
|
||||
login_data.server_id
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Failed login attempt for user {login_data.username}: {error_message}")
|
||||
logger.warning(f"Failed login attempt for user {login_data.username}{server_info}: {error_message}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=error_message or "Authentication failed"
|
||||
)
|
||||
|
||||
# Adaugă informațiile utilizatorului în răspuns
|
||||
companies = await auth_service.get_user_companies(login_data.username)
|
||||
current_user = CurrentUser(
|
||||
username=login_data.username,
|
||||
companies=companies,
|
||||
permissions=["read", "reports"], # Permisiuni de bază
|
||||
last_login=datetime.now()
|
||||
)
|
||||
|
||||
token_response.user = current_user
|
||||
|
||||
|
||||
# token_response.user este deja populat corect de auth_service.authenticate_and_create_tokens
|
||||
# cu username-ul Oracle rezolvat (nu email-ul) și lista de firme
|
||||
|
||||
# Header-e de securitate
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
logger.info(f"Successful login for user {login_data.username}")
|
||||
|
||||
logger.info(f"Successful login for user {login_data.username}{server_info}")
|
||||
return token_response
|
||||
|
||||
except HTTPException:
|
||||
@@ -344,6 +532,63 @@ def create_auth_router(
|
||||
detail="Error checking company access"
|
||||
)
|
||||
|
||||
@router.get("/my-servers", response_model=dict)
|
||||
async def get_my_servers(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Returnează lista serverelor la care utilizatorul autentificat are acces (US-006).
|
||||
|
||||
Acest endpoint este folosit de frontend pentru a popula dropdown-ul de server switch.
|
||||
Lookup-ul se face pe baza email-ului sau username-ului utilizatorului curent.
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Dict cu lista de servere: {servers: [{id: string, name: string}, ...]}
|
||||
"""
|
||||
try:
|
||||
from .email_server_cache import email_server_cache
|
||||
from backend.config import settings
|
||||
|
||||
logger.info(f"Get my-servers request for user '{current_user.username}'")
|
||||
|
||||
# Try email lookup first (faster, from cache)
|
||||
server_ids: List[str] = []
|
||||
if current_user.email:
|
||||
server_ids = email_server_cache.get_servers_for_email(current_user.email)
|
||||
logger.debug(f"Email lookup for '{current_user.email}': {server_ids}")
|
||||
|
||||
# If no email or no results, try username lookup (queries Oracle directly)
|
||||
if not server_ids:
|
||||
server_ids = await email_server_cache.get_servers_for_username(current_user.username)
|
||||
logger.debug(f"Username lookup for '{current_user.username}': {server_ids}")
|
||||
|
||||
# Build server info list with human-readable names
|
||||
servers: List[ServerInfo] = []
|
||||
for server_id in server_ids:
|
||||
server_config = settings.get_oracle_server(server_id)
|
||||
if server_config:
|
||||
servers.append(ServerInfo(
|
||||
id=server_config.id,
|
||||
name=server_config.name
|
||||
))
|
||||
else:
|
||||
# Fallback if server config not found
|
||||
logger.warning(f"Server '{server_id}' not found in config")
|
||||
servers.append(ServerInfo(id=server_id, name=server_id))
|
||||
|
||||
logger.info(f"User '{current_user.username}' has access to {len(servers)} server(s)")
|
||||
return {"servers": [s.model_dump() for s in servers]}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting servers for user '{current_user.username}': {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error retrieving user servers"
|
||||
)
|
||||
|
||||
@router.get("/status")
|
||||
async def get_auth_status(
|
||||
current_user: Optional[CurrentUser] = Depends(get_optional_user)
|
||||
|
||||
Reference in New Issue
Block a user