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:
@@ -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)
|
||||
|
||||
362
shared/auth/email_server_cache.py
Normal file
362
shared/auth/email_server_cache.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -1,112 +1,254 @@
|
||||
"""
|
||||
Oracle Database Connection Pool - Shared între toate aplicațiile ROA2WEB
|
||||
Folosește oracledb cu connection pooling pentru performance optimă
|
||||
Oracle Database Connection Pool - Multi-Server Support for ROA2WEB
|
||||
|
||||
Supports both single-server (backward compatible) and multi-server configurations.
|
||||
Pool-uri sunt create lazy (la prima conexiune pe fiecare server) pentru optimizare.
|
||||
"""
|
||||
import asyncio
|
||||
import oracledb
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OraclePool:
|
||||
|
||||
class OracleMultiPool:
|
||||
"""
|
||||
Singleton class pentru Oracle connection pool
|
||||
Partajat între toate microservicele ROA2WEB
|
||||
Multi-tenant Oracle connection pool manager.
|
||||
|
||||
Supports:
|
||||
- Multiple Oracle servers with separate pools: {server_id: pool}
|
||||
- Lazy pool creation (created on first connection)
|
||||
- Backward compatibility (default server when no server_id specified)
|
||||
- Graceful shutdown of all pools
|
||||
"""
|
||||
_instance: Optional['OraclePool'] = None
|
||||
_pool: Optional[oracledb.ConnectionPool] = None
|
||||
|
||||
_instance: Optional['OracleMultiPool'] = None
|
||||
_pools: Dict[str, oracledb.ConnectionPool]
|
||||
_pool_configs: Dict[str, Dict[str, Any]]
|
||||
_pool_lock: asyncio.Lock
|
||||
_legacy_pool: Optional[oracledb.ConnectionPool] # For backward compatibility
|
||||
_initialized: bool
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(OraclePool, cls).__new__(cls)
|
||||
cls._instance = super(OracleMultiPool, cls).__new__(cls)
|
||||
cls._instance._pools = {}
|
||||
cls._instance._pool_configs = {}
|
||||
cls._instance._pool_lock = asyncio.Lock()
|
||||
cls._instance._legacy_pool = None
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
|
||||
async def initialize(self, **config):
|
||||
"""Inițializează pool-ul de conexiuni"""
|
||||
if self._pool is None:
|
||||
# Check if we have DSN or individual parameters
|
||||
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
|
||||
if dsn:
|
||||
# Use DSN connection
|
||||
self._pool = oracledb.create_pool(
|
||||
user=config.get('user', os.getenv('ORACLE_USER')),
|
||||
password=config.get('password', os.getenv('ORACLE_PASSWORD')),
|
||||
dsn=dsn,
|
||||
min=config.get('min_connections', 2),
|
||||
max=config.get('max_connections', 10),
|
||||
increment=config.get('increment', 1),
|
||||
getmode=oracledb.POOL_GETMODE_WAIT
|
||||
)
|
||||
"""
|
||||
Initialize pool manager.
|
||||
|
||||
For backward compatibility, this can:
|
||||
1. Create a legacy single pool (if called with individual params)
|
||||
2. Just mark as initialized (if using lazy multi-pool loading)
|
||||
"""
|
||||
if self._initialized:
|
||||
logger.debug("Pool manager already initialized")
|
||||
return
|
||||
|
||||
# Check if we have DSN or individual parameters (legacy mode)
|
||||
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
|
||||
user = config.get('user', os.getenv('ORACLE_USER'))
|
||||
|
||||
if dsn or user:
|
||||
# Legacy single-pool mode - create pool immediately
|
||||
await self._create_legacy_pool(config)
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Oracle pool manager initialized")
|
||||
|
||||
async def _create_legacy_pool(self, config: Dict[str, Any]) -> None:
|
||||
"""Create legacy single pool for backward compatibility."""
|
||||
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
|
||||
if dsn:
|
||||
# Use DSN connection
|
||||
self._legacy_pool = oracledb.create_pool(
|
||||
user=config.get('user', os.getenv('ORACLE_USER')),
|
||||
password=config.get('password', os.getenv('ORACLE_PASSWORD')),
|
||||
dsn=dsn,
|
||||
min=config.get('min_connections', 2),
|
||||
max=config.get('max_connections', 10),
|
||||
increment=config.get('increment', 1),
|
||||
getmode=oracledb.POOL_GETMODE_WAIT
|
||||
)
|
||||
else:
|
||||
# Use individual parameters (host, port, service_name or sid)
|
||||
service_name = config.get('service_name', os.getenv('ORACLE_SERVICE_NAME'))
|
||||
sid = config.get('sid', os.getenv('ORACLE_SID'))
|
||||
|
||||
pool_params = {
|
||||
'user': config.get('user', os.getenv('ORACLE_USER')),
|
||||
'password': config.get('password', os.getenv('ORACLE_PASSWORD')),
|
||||
'host': config.get('host', os.getenv('ORACLE_HOST', 'localhost')),
|
||||
'port': config.get('port', int(os.getenv('ORACLE_PORT', '1526'))),
|
||||
'min': config.get('min_connections', 2),
|
||||
'max': config.get('max_connections', 10),
|
||||
'increment': config.get('increment', 1),
|
||||
'getmode': oracledb.POOL_GETMODE_WAIT
|
||||
}
|
||||
|
||||
if service_name:
|
||||
pool_params['service_name'] = service_name
|
||||
logger.info(f"Using SERVICE_NAME: {service_name}")
|
||||
elif sid:
|
||||
pool_params['sid'] = sid
|
||||
logger.info(f"Using SID: {sid}")
|
||||
else:
|
||||
# Use individual parameters (host, port, service_name or sid)
|
||||
# Prefer SERVICE_NAME over SID (more modern Oracle approach)
|
||||
service_name = config.get('service_name', os.getenv('ORACLE_SERVICE_NAME'))
|
||||
sid = config.get('sid', os.getenv('ORACLE_SID'))
|
||||
pool_params['service_name'] = 'ROA'
|
||||
logger.info("Using default SERVICE_NAME: ROA")
|
||||
|
||||
pool_params = {
|
||||
'user': config.get('user', os.getenv('ORACLE_USER')),
|
||||
'password': config.get('password', os.getenv('ORACLE_PASSWORD')),
|
||||
'host': config.get('host', os.getenv('ORACLE_HOST', 'localhost')),
|
||||
'port': config.get('port', int(os.getenv('ORACLE_PORT', '1526'))),
|
||||
'min': config.get('min_connections', 2),
|
||||
'max': config.get('max_connections', 10),
|
||||
'increment': config.get('increment', 1),
|
||||
'getmode': oracledb.POOL_GETMODE_WAIT
|
||||
}
|
||||
self._legacy_pool = oracledb.create_pool(**pool_params)
|
||||
|
||||
# Use service_name if available, otherwise fall back to sid
|
||||
if service_name:
|
||||
pool_params['service_name'] = service_name
|
||||
logger.info(f"Using SERVICE_NAME: {service_name}")
|
||||
elif sid:
|
||||
pool_params['sid'] = sid
|
||||
logger.info(f"Using SID: {sid}")
|
||||
else:
|
||||
# Default fallback
|
||||
pool_params['service_name'] = 'ROA'
|
||||
logger.info("Using default SERVICE_NAME: ROA")
|
||||
logger.info(f"Legacy Oracle pool created with {self._legacy_pool.opened} connections")
|
||||
|
||||
def register_server(
|
||||
self,
|
||||
server_id: str,
|
||||
host: str,
|
||||
port: int,
|
||||
user: str,
|
||||
password: str,
|
||||
sid: Optional[str] = None,
|
||||
service_name: Optional[str] = None,
|
||||
min_connections: int = 2,
|
||||
max_connections: int = 10,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Register a server configuration for lazy pool creation.
|
||||
|
||||
Pool will be created on first get_connection(server_id) call.
|
||||
"""
|
||||
self._pool_configs[server_id] = {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'password': password,
|
||||
'sid': sid,
|
||||
'service_name': service_name,
|
||||
'min_connections': min_connections,
|
||||
'max_connections': max_connections,
|
||||
}
|
||||
logger.info(f"Registered server '{server_id}' ({host}:{port}) for lazy pool creation")
|
||||
|
||||
async def _get_or_create_pool(self, server_id: str) -> oracledb.ConnectionPool:
|
||||
"""
|
||||
Get existing pool or create new one (lazy loading).
|
||||
|
||||
Thread-safe: uses asyncio.Lock to prevent duplicate pool creation.
|
||||
"""
|
||||
# Fast path: pool already exists
|
||||
if server_id in self._pools:
|
||||
return self._pools[server_id]
|
||||
|
||||
# Slow path: need to create pool
|
||||
async with self._pool_lock:
|
||||
# Double-check after acquiring lock
|
||||
if server_id in self._pools:
|
||||
return self._pools[server_id]
|
||||
|
||||
# Check if server is registered
|
||||
if server_id not in self._pool_configs:
|
||||
raise ValueError(f"Server '{server_id}' not registered. Call register_server() first.")
|
||||
|
||||
config = self._pool_configs[server_id]
|
||||
logger.info(f"Creating pool for server '{server_id}' (lazy initialization)...")
|
||||
|
||||
pool_params = {
|
||||
'user': config['user'],
|
||||
'password': config['password'],
|
||||
'host': config['host'],
|
||||
'port': config['port'],
|
||||
'min': config['min_connections'],
|
||||
'max': config['max_connections'],
|
||||
'increment': 1,
|
||||
'getmode': oracledb.POOL_GETMODE_WAIT
|
||||
}
|
||||
|
||||
if config.get('service_name'):
|
||||
pool_params['service_name'] = config['service_name']
|
||||
elif config.get('sid'):
|
||||
pool_params['sid'] = config['sid']
|
||||
else:
|
||||
pool_params['service_name'] = 'ROA'
|
||||
|
||||
pool = oracledb.create_pool(**pool_params)
|
||||
self._pools[server_id] = pool
|
||||
|
||||
logger.info(f"Pool created for server '{server_id}' with {pool.opened} connections")
|
||||
return pool
|
||||
|
||||
self._pool = oracledb.create_pool(**pool_params)
|
||||
logger.info(f"Oracle pool created with {self._pool.opened} connections")
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection(self):
|
||||
"""Context manager pentru obținerea unei conexiuni din pool"""
|
||||
if self._pool is None:
|
||||
raise RuntimeError("Pool not initialized. Call initialize() first.")
|
||||
|
||||
async def get_connection(self, server_id: Optional[str] = None):
|
||||
"""
|
||||
Context manager pentru obținerea unei conexiuni din pool.
|
||||
|
||||
Args:
|
||||
server_id: ID-ul serverului. Dacă None, folosește legacy pool sau default.
|
||||
|
||||
Usage:
|
||||
# Multi-server mode
|
||||
async with oracle_pool.get_connection('romfast') as conn:
|
||||
...
|
||||
|
||||
# Backward compatible (legacy single pool)
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
...
|
||||
"""
|
||||
connection = None
|
||||
pool = None
|
||||
|
||||
try:
|
||||
connection = self._pool.acquire()
|
||||
logger.debug("Connection acquired from pool")
|
||||
if server_id is None:
|
||||
# Backward compatibility: use legacy pool
|
||||
if self._legacy_pool is None:
|
||||
# If no legacy pool, try to use 'default' server
|
||||
if 'default' in self._pool_configs:
|
||||
pool = await self._get_or_create_pool('default')
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"No pool available. Either initialize() with config "
|
||||
"or register_server() with server_id='default'."
|
||||
)
|
||||
else:
|
||||
pool = self._legacy_pool
|
||||
else:
|
||||
pool = await self._get_or_create_pool(server_id)
|
||||
|
||||
connection = pool.acquire()
|
||||
logger.debug(f"Connection acquired from pool (server_id={server_id})")
|
||||
yield connection
|
||||
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
logger.debug("Connection returned to pool")
|
||||
logger.debug(f"Connection returned to pool (server_id={server_id})")
|
||||
|
||||
|
||||
async def execute_query(self, query: str, parameters=None):
|
||||
async def execute_query(self, query: str, parameters=None, server_id: Optional[str] = None):
|
||||
"""
|
||||
Execute a SQL query and return all results
|
||||
Based on official Oracle python-oracledb patterns
|
||||
Execute a SQL query and return all results.
|
||||
|
||||
Args:
|
||||
query: SQL query string
|
||||
parameters: Query parameters (dict or tuple)
|
||||
server_id: Server ID for multi-pool mode (optional)
|
||||
"""
|
||||
if self._pool is None:
|
||||
raise RuntimeError("Pool not initialized. Call initialize() first.")
|
||||
|
||||
connection = None
|
||||
try:
|
||||
connection = self._pool.acquire()
|
||||
logger.debug(f"Executing query: {query[:100]}...")
|
||||
|
||||
async with self.get_connection(server_id) as connection:
|
||||
logger.debug(f"Executing query on server '{server_id}': {query[:100]}...")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
if parameters:
|
||||
cursor.execute(query, parameters)
|
||||
else:
|
||||
cursor.execute(query)
|
||||
|
||||
|
||||
# Check if this is a SELECT statement
|
||||
if query.strip().upper().startswith('SELECT') or query.strip().upper().startswith('WITH'):
|
||||
return cursor.fetchall()
|
||||
@@ -114,23 +256,95 @@ class OraclePool:
|
||||
# For DML statements, return affected row count
|
||||
connection.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
except Exception as e:
|
||||
if connection:
|
||||
connection.rollback()
|
||||
logger.error(f"Query execution failed: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
logger.debug("Connection returned to pool")
|
||||
|
||||
async def close_pool(self):
|
||||
"""Închide pool-ul de conexiuni"""
|
||||
if self._pool is not None:
|
||||
self._pool.close()
|
||||
self._pool = None
|
||||
logger.info("Oracle pool closed")
|
||||
|
||||
async def close_pool(self, server_id: Optional[str] = None):
|
||||
"""
|
||||
Close a specific pool or all pools.
|
||||
|
||||
Args:
|
||||
server_id: Close specific pool. If None, close all pools.
|
||||
"""
|
||||
if server_id is not None:
|
||||
# Close specific pool
|
||||
if server_id in self._pools:
|
||||
self._pools[server_id].close()
|
||||
del self._pools[server_id]
|
||||
logger.info(f"Closed pool for server '{server_id}'")
|
||||
else:
|
||||
# Close all pools (graceful shutdown)
|
||||
if self._legacy_pool is not None:
|
||||
self._legacy_pool.close()
|
||||
self._legacy_pool = None
|
||||
logger.info("Closed legacy pool")
|
||||
|
||||
for srv_id, pool in list(self._pools.items()):
|
||||
pool.close()
|
||||
logger.info(f"Closed pool for server '{srv_id}'")
|
||||
|
||||
self._pools.clear()
|
||||
self._initialized = False
|
||||
logger.info("All Oracle pools closed")
|
||||
|
||||
def get_pool_stats(self, server_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics for pool(s).
|
||||
|
||||
Args:
|
||||
server_id: Get stats for specific server. If None, get all stats.
|
||||
|
||||
Returns:
|
||||
Dict with pool statistics (opened, busy, min, max connections)
|
||||
"""
|
||||
stats = {}
|
||||
|
||||
if server_id is not None:
|
||||
pool = self._pools.get(server_id)
|
||||
if pool:
|
||||
stats[server_id] = {
|
||||
'opened': pool.opened,
|
||||
'busy': pool.busy,
|
||||
'min': pool.min,
|
||||
'max': pool.max,
|
||||
}
|
||||
else:
|
||||
# All pools including legacy
|
||||
if self._legacy_pool:
|
||||
stats['legacy'] = {
|
||||
'opened': self._legacy_pool.opened,
|
||||
'busy': self._legacy_pool.busy,
|
||||
'min': self._legacy_pool.min,
|
||||
'max': self._legacy_pool.max,
|
||||
}
|
||||
|
||||
for srv_id, pool in self._pools.items():
|
||||
stats[srv_id] = {
|
||||
'opened': pool.opened,
|
||||
'busy': pool.busy,
|
||||
'min': pool.min,
|
||||
'max': pool.max,
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def is_server_registered(self, server_id: str) -> bool:
|
||||
"""Check if a server is registered (config exists)."""
|
||||
return server_id in self._pool_configs
|
||||
|
||||
def is_pool_active(self, server_id: str) -> bool:
|
||||
"""Check if a pool is active (created) for a server."""
|
||||
return server_id in self._pools
|
||||
|
||||
def get_registered_servers(self) -> list:
|
||||
"""Get list of registered server IDs."""
|
||||
return list(self._pool_configs.keys())
|
||||
|
||||
def get_active_pools(self) -> list:
|
||||
"""Get list of server IDs with active pools."""
|
||||
return list(self._pools.keys())
|
||||
|
||||
|
||||
# Backward compatibility: keep old class name as alias
|
||||
OraclePool = OracleMultiPool
|
||||
|
||||
# Instance globală pentru folosire în toate aplicațiile
|
||||
oracle_pool = OraclePool()
|
||||
oracle_pool = OracleMultiPool()
|
||||
|
||||
@@ -14,7 +14,7 @@ Usage:
|
||||
import logging
|
||||
from typing import Optional, Callable, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from auth.dependencies import get_current_user
|
||||
from auth.models import CurrentUser
|
||||
@@ -51,9 +51,14 @@ def create_calendar_router(
|
||||
)
|
||||
|
||||
# Helper to get schema for company
|
||||
async def _get_schema_for_company(company_id: int) -> Optional[str]:
|
||||
"""Get Oracle schema for company ID."""
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async def _get_schema_for_company(company_id: int, server_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Get Oracle schema for company ID.
|
||||
|
||||
Args:
|
||||
company_id: The company ID to get schema for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME
|
||||
@@ -63,22 +68,28 @@ def create_calendar_router(
|
||||
return result[0] if result else None
|
||||
|
||||
# Apply cache to schema lookup if decorator provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_schema_for_company = cache_decorator(
|
||||
cache_type='schema',
|
||||
key_params=['company_id']
|
||||
key_params=['company_id', 'server_id']
|
||||
)(_get_schema_for_company)
|
||||
|
||||
# Helper to get periods - can be cached
|
||||
async def _get_available_periods(company_id: int) -> CalendarPeriodsResponse:
|
||||
"""Get available accounting periods for a company."""
|
||||
schema = await _get_schema_for_company(company_id)
|
||||
async def _get_available_periods(company_id: int, server_id: Optional[str] = None) -> CalendarPeriodsResponse:
|
||||
"""Get available accounting periods for a company.
|
||||
|
||||
Args:
|
||||
company_id: The company ID to get periods for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
schema = await _get_schema_for_company(company_id, server_id)
|
||||
if not schema:
|
||||
logger.warning(f"Schema not found for company {company_id}")
|
||||
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"""
|
||||
SELECT ANUL, LUNA
|
||||
@@ -112,14 +123,16 @@ def create_calendar_router(
|
||||
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
|
||||
|
||||
# Apply cache decorator if provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_available_periods = cache_decorator(
|
||||
cache_type='calendar_periods',
|
||||
key_params=['company_id']
|
||||
key_params=['company_id', 'server_id']
|
||||
)(_get_available_periods)
|
||||
|
||||
@router.get("/periods", response_model=CalendarPeriodsResponse)
|
||||
async def get_calendar_periods(
|
||||
request: Request,
|
||||
company: int = Query(..., description="Company ID"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> CalendarPeriodsResponse:
|
||||
@@ -131,6 +144,8 @@ def create_calendar_router(
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(403, f"Nu aveți acces la firma {company}")
|
||||
|
||||
return await _get_available_periods(company)
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
return await _get_available_periods(company, server_id)
|
||||
|
||||
return router
|
||||
|
||||
@@ -45,13 +45,17 @@ def create_companies_router(
|
||||
)
|
||||
|
||||
# Helper function to get companies - can be cached
|
||||
async def _get_user_companies_data(username: str) -> List[Company]:
|
||||
async def _get_user_companies_data(username: str, server_id: Optional[str] = None) -> List[Company]:
|
||||
"""
|
||||
Get list of companies for a user from Oracle.
|
||||
|
||||
Args:
|
||||
username: The username to get companies for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
companies = []
|
||||
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
# Get user ID
|
||||
@@ -97,10 +101,11 @@ def create_companies_router(
|
||||
return companies
|
||||
|
||||
# Apply cache decorator if provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_user_companies_data = cache_decorator(
|
||||
cache_type='companies',
|
||||
key_params=['username']
|
||||
key_params=['username', 'server_id']
|
||||
)(_get_user_companies_data)
|
||||
|
||||
@router.get("", response_model=CompanyListResponse)
|
||||
@@ -111,7 +116,9 @@ def create_companies_router(
|
||||
):
|
||||
"""Get list of companies the user has access to."""
|
||||
try:
|
||||
companies = await _get_user_companies_data(current_user.username)
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
companies = await _get_user_companies_data(current_user.username, server_id)
|
||||
|
||||
return CompanyListResponse(
|
||||
companies=companies,
|
||||
@@ -124,6 +131,7 @@ def create_companies_router(
|
||||
@router.get("/{company_id}", response_model=Company)
|
||||
async def get_company_details(
|
||||
company_id: str,
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""Get details of a specific company."""
|
||||
@@ -132,7 +140,9 @@ def create_companies_router(
|
||||
raise HTTPException(403, f"Nu aveți acces la firma {company_id}")
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT ID_FIRMA, FIRMA, SCHEMA, COD_FISCAL
|
||||
|
||||
@@ -13,6 +13,12 @@ from pydantic import BaseModel
|
||||
from shared.auth.dependencies import get_current_user, CurrentUser
|
||||
|
||||
|
||||
class AuthModeResponse(BaseModel):
|
||||
"""Response for auth mode endpoint."""
|
||||
mode: str # "single-server" or "multi-server"
|
||||
supports_email_login: bool # True if email-based login is available
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
"""Single log entry."""
|
||||
line: str
|
||||
@@ -36,6 +42,36 @@ def create_system_router() -> APIRouter:
|
||||
"""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/auth-mode", response_model=AuthModeResponse)
|
||||
async def get_auth_mode():
|
||||
"""
|
||||
Get the authentication mode configuration.
|
||||
|
||||
This is a PUBLIC endpoint (no auth required) that tells the frontend
|
||||
whether to use the email-based multi-server login flow or the classic
|
||||
username/password single-server flow.
|
||||
|
||||
Returns:
|
||||
- mode: "single-server" for legacy config, "multi-server" for ORACLE_SERVERS
|
||||
- supports_email_login: True only in multi-server mode with email cache
|
||||
"""
|
||||
from backend.config import settings
|
||||
|
||||
servers = settings.get_oracle_servers()
|
||||
|
||||
# Multi-server mode: 2+ servers configured via ORACLE_SERVERS
|
||||
if servers and len(servers) > 1:
|
||||
return AuthModeResponse(
|
||||
mode="multi-server",
|
||||
supports_email_login=True
|
||||
)
|
||||
|
||||
# Single-server mode: legacy config or single ORACLE_SERVERS entry
|
||||
return AuthModeResponse(
|
||||
mode="single-server",
|
||||
supports_email_login=False
|
||||
)
|
||||
|
||||
def get_logs_path() -> Path:
|
||||
"""Get logs directory path based on environment."""
|
||||
# Windows production: C:\inetpub\wwwroot\roa2web\logs
|
||||
|
||||
Reference in New Issue
Block a user