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:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

View File

@@ -80,23 +80,71 @@ class UserAuthService:
'timestamp': datetime.now()
}
logger.debug(f"Cached data for user {username}")
async def verify_user_credentials(self, username: str, password: str) -> bool:
async def get_username_by_email(
self,
email: str,
server_id: Optional[str] = None
) -> Optional[str]:
"""
Obține username-ul Oracle corespunzător unui email.
Necesar pentru login cu email - convertește email-ul în username-ul
real din tabelul UTILIZATORI pentru autentificare cu pack_drepturi.
Args:
email: Email-ul utilizatorului
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Username-ul Oracle sau None dacă email-ul nu există
"""
try:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT UTILIZATOR
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE LOWER(EMAIL) = :email
AND INACTIV = 0
AND STERS = 0
""", {'email': email.lower().strip()})
row = cursor.fetchone()
if row:
username = row[0]
logger.info(f"Resolved email '{email}' to username '{username}' on server '{server_id}'")
return username
else:
logger.warning(f"No username found for email '{email}' on server '{server_id}'")
return None
except Exception as e:
logger.error(f"Database error resolving email '{email}' to username: {str(e)}")
return None
async def verify_user_credentials(
self,
username: str,
password: str,
server_id: Optional[str] = None
) -> bool:
"""
Verifică credențialele utilizatorului folosind pack_drepturi.verificautilizator
Args:
username: Numele utilizatorului
password: Parola utilizatorului
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
Returns:
True dacă credențialele sunt corecte, False altfel
Raises:
AuthenticationError: Dacă apar erori în procesul de verificare
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Apelarea procedurii pack_drepturi.verificautilizator
# Această procedură returnează ID-ul utilizatorului cu checksum pentru succes, -1 pentru eșec
@@ -110,7 +158,10 @@ class UserAuthService:
result = cursor.fetchone()
verification_result = result[0] if result else -1
# DEBUG: Log the exact result from Oracle
logger.info(f"[DEBUG] verificautilizator('{username.upper()}', '***') on server '{server_id}' = {verification_result}")
# Interpretarea rezultatului conform logicii VFP:
# -1 = invalid credentials
# > 0 = valid user ID with checksum
@@ -136,27 +187,33 @@ class UserAuthService:
logger.error(f"Database error during authentication for user {username}: {str(e)}")
raise AuthenticationError(f"Database authentication error: {str(e)}")
async def get_user_companies(self, username: str) -> List[str]:
async def get_user_companies(
self,
username: str,
server_id: Optional[str] = None
) -> List[str]:
"""
Obține lista firmelor la care utilizatorul are acces din V_NOM_FIRME
folosind ID-ul utilizatorului din UTILIZATORI
Args:
username: Numele utilizatorului
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
Returns:
Lista codurilor firmelor la care utilizatorul are acces
Raises:
AuthenticationError: Dacă apar erori în procesul de obținere
"""
# Verifică cache-ul mai întâi
cached_data = self._get_cached_user_data(username)
# Verifică cache-ul mai întâi (include server_id în cheie pentru multi-server)
cache_key_suffix = f"_{server_id}" if server_id else ""
cached_data = self._get_cached_user_data(f"{username}{cache_key_suffix}")
if cached_data and 'companies' in cached_data:
return cached_data['companies']
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
try:
# Debug: să vedem ce utilizatori există în tabela UTILIZATORI
@@ -222,85 +279,111 @@ class UserAuthService:
# În caz de eroare, returnăm listă goală în loc de TEST_COMPANY
return []
# Cache rezultatul
self._cache_user_data(username, {'companies': companies})
# Cache rezultatul (include server_id pentru multi-server)
cache_key = f"{username}{cache_key_suffix}"
self._cache_user_data(cache_key, {'companies': companies})
return companies
except Exception as e:
logger.error(f"Database error getting companies for user {username}: {str(e)}")
raise AuthenticationError(f"Error retrieving user companies: {str(e)}")
async def get_user_permissions(self, username: str, company: str) -> List[str]:
async def get_user_permissions(
self,
username: str,
company: str,
server_id: Optional[str] = None
) -> List[str]:
"""
Obține permisiunile utilizatorului pentru o anumită firmă
Args:
username: Numele utilizatorului
company: Codul firmei
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Lista permisiunilor pentru firma specificată
"""
# Implementare de bază - poate fi extinsă în viitor
companies = await self.get_user_companies(username)
companies = await self.get_user_companies(username, server_id)
# Dacă nu există companii sau compania nu este în listă, returnează permisiuni minime
if not companies or company not in companies:
return ["read"] if not companies else []
# Pentru moment, toți utilizatorii autentificați au permisiuni de citire
# Acest sistem poate fi extins cu permisiuni granulare în viitor
return ["read", "reports"]
async def authenticate_and_create_tokens(
self,
username: str,
password: str
self,
username: str,
password: str,
server_id: Optional[str] = None
) -> Tuple[bool, Optional[TokenResponse], Optional[str]]:
"""
Autentifică utilizatorul și creează token-urile JWT
Suportă atât username clasic cât și email pentru login.
Dacă input-ul conține '@', se tratează ca email și se convertește
în username-ul Oracle corespunzător.
Args:
username: Numele utilizatorului
username: Numele utilizatorului sau email-ul
password: Parola utilizatorului
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
Returns:
Tuple cu (success, token_response, error_message)
"""
try:
# Verifică credențialele
is_valid = await self.verify_user_credentials(username, password)
# Detectăm dacă input-ul este email sau username clasic
actual_username = username
if '@' in username:
# Este email - convertim în username Oracle
resolved_username = await self.get_username_by_email(username, server_id)
if not resolved_username:
logger.warning(f"Could not resolve email '{username}' to username on server '{server_id}'")
return False, None, "Invalid username or password"
actual_username = resolved_username
logger.info(f"Login with email '{username}' resolved to username '{actual_username}'")
# Verifică credențialele pe serverul specificat
is_valid = await self.verify_user_credentials(actual_username, password, server_id)
if not is_valid:
return False, None, "Invalid username or password"
# Obține firmele utilizatorului
companies = await self.get_user_companies(username)
# Obține firmele utilizatorului de pe serverul specificat
companies = await self.get_user_companies(actual_username, server_id)
# Nu blocăm login-ul dacă utilizatorul nu are firme - îl lăsăm să vadă mesajul în frontend
if not companies:
logger.info(f"User {username} has no companies assigned - allowing login but with empty companies list")
logger.info(f"User {actual_username} has no companies assigned - allowing login but with empty companies list")
# Obține permisiunile (pentru prima firmă ca default sau lista goală)
permissions = await self.get_user_permissions(username, companies[0] if companies else "")
permissions = await self.get_user_permissions(actual_username, companies[0] if companies else "", server_id)
# Creează token-urile folosind jwt_handler
# Include server_id în JWT pentru ca request-urile ulterioare să știe pe care server să execute query-uri
jwt_tokens = jwt_handler.create_token_response(
username=username,
username=actual_username,
companies=companies,
user_id=None, # Poate fi adăugat în viitor dacă avem user_id în DB
permissions=permissions
permissions=permissions,
server_id=server_id
)
# Creează obiectul CurrentUser
current_user = CurrentUser(
username=username,
username=actual_username,
user_id=None,
companies=companies,
permissions=permissions
)
# Creează TokenResponse-ul complet cu user info
token_response = TokenResponse(
access_token=jwt_tokens.access_token,
@@ -309,10 +392,10 @@ class UserAuthService:
expires_in=jwt_tokens.expires_in,
user=current_user
)
logger.info(f"Successfully created tokens for user {username}")
logger.info(f"Successfully created tokens for user {actual_username} on server {server_id or 'default'}")
return True, token_response, None
except AuthenticationError as e:
logger.error(f"Authentication error for user {username}: {str(e)}")
return False, None, str(e)

View File

@@ -0,0 +1,362 @@
"""
Email-Server Cache for Multi-Oracle Auto-Discovery
Builds and maintains a cache mapping emails to server IDs:
- At startup, connects to each Oracle server and extracts emails from CONTAFIN_ORACLE.UTILIZATORI
- Cache structure: {email: [server_ids]}
- Auto-refresh every 15 minutes (configurable)
- Thread-safe with asyncio.Lock
US-003: Auto-Discovery Email-Server Cache
US-013: Added username lookup support (direct query, no caching)
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Set
logger = logging.getLogger(__name__)
class EmailServerCache:
"""
Cache for email-to-server mapping.
Builds a dictionary {email: [server_ids]} by querying CONTAFIN_ORACLE.UTILIZATORI
on each configured Oracle server.
Features:
- Lazy initialization (build on first access or explicit call)
- Auto-refresh at configurable intervals
- Thread-safe operations
- Graceful handling of server connection failures
"""
_instance: Optional['EmailServerCache'] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(EmailServerCache, cls).__new__(cls)
cls._instance._cache: Dict[str, List[str]] = {}
cls._instance._last_refresh: Optional[datetime] = None
cls._instance._refresh_interval = timedelta(minutes=15)
cls._instance._lock = asyncio.Lock()
cls._instance._initialized = False
cls._instance._refresh_task: Optional[asyncio.Task] = None
return cls._instance
def set_refresh_interval(self, minutes: int) -> None:
"""
Set the cache refresh interval.
Args:
minutes: Refresh interval in minutes (default: 15)
"""
self._refresh_interval = timedelta(minutes=minutes)
logger.info(f"Email cache refresh interval set to {minutes} minutes")
async def build_cache(self) -> None:
"""
Build the email-server cache by querying all configured Oracle servers.
Connects to each server and extracts active user emails from
CONTAFIN_ORACLE.UTILIZATORI table.
"""
from shared.database.oracle_pool import oracle_pool
from backend.config import settings
async with self._lock:
logger.info("[EMAIL-CACHE] Building email-server cache...")
new_cache: Dict[str, Set[str]] = {} # Use set to avoid duplicates
servers = settings.get_oracle_servers()
if not servers:
logger.warning("[EMAIL-CACHE] No Oracle servers configured")
self._cache = {}
self._last_refresh = datetime.now()
self._initialized = True
return
for server in servers:
try:
logger.info(f"[EMAIL-CACHE] Querying server '{server.id}' ({server.name})...")
# Get connection from the multi-pool
async with oracle_pool.get_connection(server.id) as connection:
with connection.cursor() as cursor:
# Query emails from UTILIZATORI table
# Only active users (INACTIV=0, STERS=0) with valid emails
cursor.execute("""
SELECT LOWER(EMAIL) as email
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE EMAIL IS NOT NULL
AND TRIM(EMAIL) IS NOT NULL
AND INACTIV = 0
AND STERS = 0
""")
rows = cursor.fetchall()
email_count = 0
for row in rows:
email = row[0].strip().lower() if row[0] else None
if email and '@' in email: # Basic email validation
if email not in new_cache:
new_cache[email] = set()
new_cache[email].add(server.id)
email_count += 1
logger.info(f"[EMAIL-CACHE] Found {email_count} valid emails on server '{server.id}'")
except Exception as e:
# Log error but continue with other servers
logger.error(f"[EMAIL-CACHE] Failed to query server '{server.id}': {e}")
continue
# Convert sets to sorted lists for consistent ordering
self._cache = {email: sorted(list(server_ids)) for email, server_ids in new_cache.items()}
self._last_refresh = datetime.now()
self._initialized = True
total_emails = len(self._cache)
multi_server_emails = sum(1 for servers in self._cache.values() if len(servers) > 1)
logger.info(f"[EMAIL-CACHE] ✅ Cache built: {total_emails} unique emails")
logger.info(f"[EMAIL-CACHE] {multi_server_emails} emails exist on multiple servers")
async def refresh_if_needed(self) -> bool:
"""
Refresh cache if the refresh interval has passed.
Returns:
True if cache was refreshed, False otherwise
"""
if not self._initialized:
await self.build_cache()
return True
if self._last_refresh is None:
await self.build_cache()
return True
time_since_refresh = datetime.now() - self._last_refresh
if time_since_refresh >= self._refresh_interval:
await self.build_cache()
return True
return False
def get_servers_for_email(self, email: str) -> List[str]:
"""
Get list of server IDs where the email exists.
Args:
email: User email address
Returns:
List of server_ids where this email exists.
Empty list if email not found (NOT an error).
"""
if not email:
return []
normalized_email = email.strip().lower()
servers = self._cache.get(normalized_email, [])
if servers:
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' found on servers: {servers}")
else:
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' not found in cache")
return servers.copy() # Return a copy to prevent external modification
def is_initialized(self) -> bool:
"""Check if cache has been built at least once."""
return self._initialized
def get_cache_stats(self) -> Dict:
"""
Get cache statistics.
Returns:
Dict with cache stats (total_emails, multi_server_count, last_refresh, etc.)
"""
if not self._initialized:
return {
'initialized': False,
'total_emails': 0,
'last_refresh': None,
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
}
multi_server = sum(1 for servers in self._cache.values() if len(servers) > 1)
server_distribution = {}
for servers in self._cache.values():
count = len(servers)
server_distribution[count] = server_distribution.get(count, 0) + 1
return {
'initialized': True,
'total_emails': len(self._cache),
'multi_server_count': multi_server,
'server_distribution': server_distribution,
'last_refresh': self._last_refresh.isoformat() if self._last_refresh else None,
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
}
async def start_auto_refresh(self) -> None:
"""
Start background task for automatic cache refresh.
Runs refresh at the configured interval (default: 15 minutes).
"""
if self._refresh_task and not self._refresh_task.done():
logger.warning("[EMAIL-CACHE] Auto-refresh task already running")
return
async def refresh_loop():
while True:
try:
await asyncio.sleep(self._refresh_interval.total_seconds())
logger.info("[EMAIL-CACHE] Auto-refresh triggered")
await self.build_cache()
except asyncio.CancelledError:
logger.info("[EMAIL-CACHE] Auto-refresh task cancelled")
break
except Exception as e:
logger.error(f"[EMAIL-CACHE] Auto-refresh error: {e}")
# Continue running, will retry on next interval
self._refresh_task = asyncio.create_task(refresh_loop())
logger.info(f"[EMAIL-CACHE] Auto-refresh started (every {self._refresh_interval.total_seconds() / 60:.0f} minutes)")
async def stop_auto_refresh(self) -> None:
"""Stop the auto-refresh background task."""
if self._refresh_task and not self._refresh_task.done():
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
self._refresh_task = None
logger.info("[EMAIL-CACHE] Auto-refresh stopped")
def clear_cache(self) -> None:
"""Clear the cache (useful for testing)."""
self._cache = {}
self._initialized = False
self._last_refresh = None
logger.info("[EMAIL-CACHE] Cache cleared")
async def get_servers_for_username(self, username: str) -> List[str]:
"""
Get list of server IDs where the username exists (US-013).
Unlike email lookup which uses the cache, username lookup queries
Oracle directly on each server. This is because:
- Usernames are less commonly used for login
- Direct query ensures fresh data
- Avoids bloating the cache with both email and username mappings
Args:
username: Username to look up (case-insensitive, converted to uppercase)
Returns:
List of server_ids where this username exists.
Empty list if username not found (NOT an error).
"""
if not username:
return []
from shared.database.oracle_pool import oracle_pool
from backend.config import settings
normalized_username = username.strip().upper()
found_servers: List[str] = []
servers = settings.get_oracle_servers()
if not servers:
logger.warning("[EMAIL-CACHE] No Oracle servers configured for username lookup")
return []
for server in servers:
try:
async with oracle_pool.get_connection(server.id) as connection:
with connection.cursor() as cursor:
# Query for username in UTILIZATORI table
# Only active users (INACTIV=0, STERS=0)
cursor.execute("""
SELECT 1
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
AND INACTIV = 0
AND STERS = 0
AND ROWNUM = 1
""", {"username": normalized_username})
row = cursor.fetchone()
if row:
found_servers.append(server.id)
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' found on server '{server.id}'")
except Exception as e:
logger.error(f"[EMAIL-CACHE] Failed to query username on server '{server.id}': {e}")
continue
if found_servers:
logger.info(f"[EMAIL-CACHE] Username '{normalized_username}' found on {len(found_servers)} server(s): {found_servers}")
else:
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' not found on any server")
return sorted(found_servers)
# Global singleton instance
email_server_cache = EmailServerCache()
# Convenience functions for external use
def get_servers_for_email(email: str) -> List[str]:
"""
Get list of server IDs where the email exists.
This is a convenience function that wraps the singleton instance.
Args:
email: User email address
Returns:
List of server_ids. Empty list if email not found (NOT an error).
"""
return email_server_cache.get_servers_for_email(email)
async def build_email_cache() -> None:
"""Build/refresh the email-server cache."""
await email_server_cache.build_cache()
async def start_email_cache_refresh() -> None:
"""Start automatic cache refresh."""
await email_server_cache.start_auto_refresh()
async def stop_email_cache_refresh() -> None:
"""Stop automatic cache refresh."""
await email_server_cache.stop_auto_refresh()
async def get_servers_for_username(username: str) -> List[str]:
"""
Get list of server IDs where the username exists (US-013).
This is a convenience function that wraps the singleton instance.
Args:
username: Username to look up (case-insensitive)
Returns:
List of server_ids. Empty list if username not found (NOT an error).
"""
return await email_server_cache.get_servers_for_username(username)

View File

@@ -7,9 +7,10 @@ pentru autentificarea utilizatorilor în ecosistemul ROA2WEB.
Payload structure:
{
"username": "string",
"user_id": "integer",
"user_id": "integer",
"companies": ["schema1", "schema2"],
"permissions": ["read", "write", "admin"],
"server_id": "string|null", // ID-ul serverului Oracle (multi-server mode)
"exp": "timestamp",
"iat": "timestamp",
"type": "access|refresh"
@@ -31,6 +32,7 @@ class TokenData(BaseModel):
user_id: Optional[int] = Field(default=None, description="ID-ul utilizatorului")
companies: List[str] = Field(default_factory=list, description="Lista firmelor accesibile")
permissions: List[str] = Field(default_factory=list, description="Lista permisiunilor")
server_id: Optional[str] = Field(default=None, description="ID-ul serverului Oracle (pentru multi-server mode)")
exp: datetime = Field(description="Data expirării")
iat: datetime = Field(description="Data creării")
token_type: str = Field(alias="type", description="Tipul token-ului (access/refresh)")
@@ -72,67 +74,77 @@ class JWTHandler:
logger.warning("Using default JWT secret key! Change JWT_SECRET_KEY in production!")
def create_access_token(
self,
username: str,
companies: List[str],
self,
username: str,
companies: List[str],
user_id: Optional[int] = None,
permissions: Optional[List[str]] = None
permissions: Optional[List[str]] = None,
server_id: Optional[str] = None
) -> str:
"""
Creează un JWT access token
Args:
username: Numele utilizatorului
companies: Lista firmelor la care utilizatorul are acces
user_id: ID-ul utilizatorului în baza de date
permissions: Lista permisiunilor utilizatorului
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Token JWT ca string
"""
now = datetime.utcnow()
expire = now + timedelta(minutes=self.access_token_expire_minutes)
payload = {
"username": username,
"user_id": user_id,
"companies": companies or [],
"permissions": permissions or ["read"],
"server_id": server_id,
"exp": expire,
"iat": now,
"type": "access"
}
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
logger.debug(f"Created access token for user {username} with companies: {companies}")
logger.debug(f"Created access token for user {username} on server {server_id or 'default'} with companies: {companies}")
return token
def create_refresh_token(self, username: str, user_id: Optional[int] = None) -> str:
def create_refresh_token(
self,
username: str,
user_id: Optional[int] = None,
server_id: Optional[str] = None
) -> str:
"""
Creează un refresh token cu durată mai mare
Args:
username: Numele utilizatorului
user_id: ID-ul utilizatorului
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Refresh token JWT ca string
"""
now = datetime.utcnow()
expire = now + timedelta(days=self.refresh_token_expire_days)
payload = {
"username": username,
"user_id": user_id,
"server_id": server_id,
"exp": expire,
"iat": now,
"type": "refresh"
}
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
logger.debug(f"Created refresh token for user {username}")
logger.debug(f"Created refresh token for user {username} on server {server_id or 'default'}")
return token
def verify_token(self, token: str) -> Optional[TokenData]:
@@ -159,56 +171,69 @@ class JWTHandler:
logger.debug(f"Token that failed verification: {token[:50]}...")
return None
def refresh_access_token(self, refresh_token: str, companies: List[str], permissions: Optional[List[str]] = None) -> Optional[str]:
def refresh_access_token(
self,
refresh_token: str,
companies: List[str],
permissions: Optional[List[str]] = None
) -> Optional[str]:
"""
Creează un nou access token folosind refresh token-ul
Args:
refresh_token: Refresh token-ul valid
companies: Lista actualizată a firmelor (poate fi modificată între refresh-uri)
permissions: Lista actualizată a permisiunilor
Returns:
Noul access token sau None dacă refresh token-ul e invalid
"""
token_data = self.verify_token(refresh_token)
if not token_data or token_data.token_type != "refresh":
logger.warning("Invalid refresh token")
return None
# Creează nou access token cu datele din refresh token
# Păstrează server_id din refresh token pentru consistență multi-server
return self.create_access_token(
username=token_data.username,
companies=companies,
user_id=token_data.user_id,
permissions=permissions
permissions=permissions,
server_id=token_data.server_id
)
def create_token_response(
self,
username: str,
companies: List[str],
self,
username: str,
companies: List[str],
user_id: Optional[int] = None,
permissions: Optional[List[str]] = None,
include_refresh: bool = True
include_refresh: bool = True,
server_id: Optional[str] = None
) -> TokenResponse:
"""
Creează un răspuns complet cu access și refresh token
Args:
username: Numele utilizatorului
companies: Lista firmelor accesibile
user_id: ID-ul utilizatorului
permissions: Lista permisiunilor
include_refresh: Dacă să includă și refresh token
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
TokenResponse cu toate token-urile
"""
access_token = self.create_access_token(username, companies, user_id, permissions)
refresh_token = self.create_refresh_token(username, user_id) if include_refresh else None
access_token = self.create_access_token(
username, companies, user_id, permissions, server_id
)
refresh_token = self.create_refresh_token(
username, user_id, server_id
) if include_refresh else None
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,

View File

@@ -310,8 +310,10 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
request.state.user = current_user
request.state.is_authenticated = True
request.state.token_data = token_data
logger.debug(f"User {current_user.username} authenticated successfully for path {path}")
# 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)}")

View File

@@ -36,14 +36,14 @@ class TokenType(str, Enum):
class LoginRequest(BaseModel):
"""Model pentru request-ul de login"""
username: str = Field(
...,
min_length=3,
...,
min_length=3,
max_length=50,
description="Numele utilizatorului",
example="admin"
)
password: str = Field(
...,
...,
min_length=1,
description="Parola utilizatorului"
)
@@ -51,15 +51,32 @@ class LoginRequest(BaseModel):
default=False,
description="Dacă să păstreze utilizatorul autentificat mai mult timp"
)
server_id: Optional[str] = Field(
default=None,
description="ID-ul serverului Oracle pentru autentificare (opțional în modul single-server)",
example="romfast"
)
@validator('username')
def username_alphanumeric(cls, v):
"""Validează că username-ul conține doar caractere permise (inclusiv spații)"""
# Permitem litere, cifre, spații, _, și -
allowed_chars = v.replace(' ', '').replace('_', '').replace('-', '')
"""Validează că username-ul conține doar caractere permise (inclusiv email-uri)
Pentru backward compatibility:
- Permite username-uri clasice: litere, cifre, spații, _, -
- Permite email-uri pentru noul flow multi-server: @, .
"""
# Permitem litere, cifre, spații, _, -, @, și . (pentru email-uri)
allowed_chars = v.replace(' ', '').replace('_', '').replace('-', '').replace('@', '').replace('.', '')
if not allowed_chars.isalnum():
raise ValueError('Username-ul poate conține doar litere, cifre, spații, _ și -')
return v.upper() # Convertim la uppercase pentru consistență cu Oracle
raise ValueError('Username-ul poate conține doar litere, cifre, spații, _, -, @ și .')
# Detectăm dacă este email sau username clasic
if '@' in v:
# Email: păstrăm lowercase pentru consistență cu email-urile
return v.lower().strip()
else:
# Username clasic: uppercase pentru consistență cu Oracle
return v.upper().strip()
class TokenResponse(BaseModel):
@@ -227,5 +244,101 @@ class SessionInfo(BaseModel):
)
# ============================================================================
# MULTI-ORACLE IDENTITY CHECK MODELS (US-004, US-013)
# ============================================================================
class CheckIdentityRequest(BaseModel):
"""
Model pentru verificarea identității în sistemul multi-Oracle (US-013)
Suportă atât email cât și username:
- Cu '@': tratează ca email și caută în EmailServerCache
- Fără '@': tratează ca username și caută în Oracle pe toate serverele
"""
identity: str = Field(
...,
min_length=2,
max_length=100,
description="Email sau username de verificat",
example="user@example.com sau MARIUS"
)
@validator('identity')
def validate_identity(cls, v):
"""Validează și normalizează identitatea"""
stripped = v.strip()
if not stripped:
raise ValueError('Identitatea nu poate fi goală')
# Pentru email-uri, normalizăm la lowercase
if '@' in stripped:
return stripped.lower()
# Pentru username-uri, normalizăm la uppercase (convenție Oracle)
return stripped.upper()
class CheckEmailRequest(BaseModel):
"""
Model pentru verificarea email-ului în sistemul multi-Oracle (US-004)
DEPRECATED: Folosește CheckIdentityRequest pentru suport dual email/username
Păstrat pentru backward compatibility.
"""
email: EmailStr = Field(
...,
description="Adresa email a utilizatorului de verificat",
example="user@example.com"
)
class ServerInfo(BaseModel):
"""Informații despre un server Oracle disponibil pentru utilizator"""
id: str = Field(description="ID-ul serverului (ex: 'romfast')")
name: str = Field(description="Numele human-readable al serverului (ex: 'Romfast - Producție')")
class CheckIdentityResponse(BaseModel):
"""
Răspunsul pentru verificarea identității (email sau username) (US-013).
SECURITATE:
- Pentru identitate validă: returnează exists=True și lista serverelor
- Pentru identitate invalidă: returnează exists=False și listă goală de servere
(NU expunem serverele disponibile pentru a preveni enumerarea!)
"""
exists: bool = Field(
description="True dacă identitatea există în sistem pe cel puțin un server"
)
servers: List[ServerInfo] = Field(
default_factory=list,
description="Lista serverelor pe care există identitatea (goală pentru identitate invalidă)"
)
identity_type: str = Field(
default="unknown",
description="Tipul identității: 'email' sau 'username'"
)
class CheckEmailResponse(BaseModel):
"""
Răspunsul pentru verificarea email-ului (US-004).
DEPRECATED: Folosește CheckIdentityResponse pentru suport dual email/username
Păstrat pentru backward compatibility.
SECURITATE:
- Pentru email valid: returnează exists=True și lista serverelor
- Pentru email invalid: returnează exists=False și listă goală de servere
(NU expunem serverele disponibile pentru a preveni enumerarea!)
"""
exists: bool = Field(
description="True dacă email-ul există în sistem pe cel puțin un server"
)
servers: List[ServerInfo] = Field(
default_factory=list,
description="Lista serverelor pe care există email-ul (goală pentru email invalid)"
)
# Update la forward references pentru TokenResponse
TokenResponse.model_rebuild()

View File

@@ -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)