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>
476 lines
20 KiB
Python
476 lines
20 KiB
Python
"""
|
|
Authentication Service - Oracle Database Integration pentru ROA2WEB
|
|
|
|
Acest modul integrează sistemul de autentificare JWT cu baza de date Oracle,
|
|
reutilizând funcționalitatea existentă din aplicația Flask originală.
|
|
|
|
Funcționalități:
|
|
- Verificare utilizatori prin pack_drepturi.verificautilizator
|
|
- Obținere lista firmelor din vdef_util_grup
|
|
- Gestionarea sesiunilor și permisiunilor utilizatorilor
|
|
- Caching pentru performanță optimă
|
|
"""
|
|
|
|
import logging
|
|
import hashlib
|
|
from typing import Optional, List, Dict, Any, Tuple
|
|
from datetime import datetime, timedelta
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
|
|
# Import shared database pool
|
|
# IMPORTANT: Use shared.database.oracle_pool to ensure singleton consistency
|
|
# DO NOT use relative imports like 'database.oracle_pool'
|
|
from shared.database.oracle_pool import oracle_pool
|
|
from .jwt_handler import jwt_handler
|
|
from .models import TokenResponse, CurrentUser
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuthenticationError(Exception):
|
|
"""Excepție pentru erorile de autentificare"""
|
|
pass
|
|
|
|
|
|
class UserAuthService:
|
|
"""
|
|
Serviciu pentru autentificarea utilizatorilor folosind Oracle Database
|
|
|
|
Acest serviciu integrează:
|
|
- Verificarea credențialelor prin pack_drepturi.verificautilizator
|
|
- Obținerea listei de firme prin vdef_util_grup
|
|
- Generarea token-urilor JWT
|
|
- Cache pentru performanță
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Inițializează serviciul de autentificare"""
|
|
self._user_cache: Dict[str, Dict[str, Any]] = {}
|
|
self._cache_ttl = timedelta(minutes=15) # Cache 15 minute
|
|
|
|
def _get_cache_key(self, username: str) -> str:
|
|
"""Generează cheia de cache pentru utilizator"""
|
|
return f"auth_user_{username.lower()}"
|
|
|
|
def _is_cache_valid(self, cache_entry: Dict[str, Any]) -> bool:
|
|
"""Verifică dacă entry-ul din cache este încă valid"""
|
|
if not cache_entry or 'timestamp' not in cache_entry:
|
|
return False
|
|
|
|
cache_time = cache_entry['timestamp']
|
|
return datetime.now() - cache_time < self._cache_ttl
|
|
|
|
def _get_cached_user_data(self, username: str) -> Optional[Dict[str, Any]]:
|
|
"""Obține datele utilizatorului din cache dacă sunt valide"""
|
|
cache_key = self._get_cache_key(username)
|
|
cache_entry = self._user_cache.get(cache_key)
|
|
|
|
if self._is_cache_valid(cache_entry):
|
|
logger.debug(f"Cache hit for user {username}")
|
|
return cache_entry['data']
|
|
|
|
return None
|
|
|
|
def _cache_user_data(self, username: str, data: Dict[str, Any]) -> None:
|
|
"""Salvează datele utilizatorului în cache"""
|
|
cache_key = self._get_cache_key(username)
|
|
self._user_cache[cache_key] = {
|
|
'data': data,
|
|
'timestamp': datetime.now()
|
|
}
|
|
logger.debug(f"Cached data for user {username}")
|
|
|
|
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(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
|
|
cursor.execute("""
|
|
SELECT pack_drepturi.verificautilizator(:username, :password)
|
|
FROM DUAL
|
|
""", {
|
|
'username': username.upper(),
|
|
'password': password
|
|
})
|
|
|
|
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
|
|
# < -1000000 = admin/super user
|
|
is_valid = verification_result != -1
|
|
|
|
if is_valid:
|
|
# Extrage ID-ul real al utilizatorului conform logicii VFP
|
|
if verification_result < -1000000:
|
|
# Admin/Super user
|
|
user_id = verification_result + 1000000
|
|
logger.info(f"Admin/Super user {username} authenticated successfully (ID: {user_id})")
|
|
else:
|
|
# User normal - extrage ID-ul din checksum
|
|
user_id = int(verification_result / 100)
|
|
logger.info(f"User {username} authenticated successfully (ID: {user_id}, verification: {verification_result})")
|
|
else:
|
|
logger.warning(f"Authentication failed for user {username}")
|
|
|
|
return is_valid
|
|
|
|
except Exception as e:
|
|
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,
|
|
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 (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(server_id) as connection:
|
|
with connection.cursor() as cursor:
|
|
try:
|
|
# Debug: să vedem ce utilizatori există în tabela UTILIZATORI
|
|
cursor.execute("""
|
|
SELECT ID_UTIL, UTILIZATOR
|
|
FROM UTILIZATORI
|
|
WHERE UPPER(UTILIZATOR) LIKE '%MARIUS%'
|
|
ORDER BY UTILIZATOR
|
|
""")
|
|
|
|
debug_users = cursor.fetchall()
|
|
logger.info(f"DEBUG: Users with MARIUS in name: {debug_users}")
|
|
|
|
# Primul pas: obținem ID-ul utilizatorului din UTILIZATORI
|
|
cursor.execute("""
|
|
SELECT ID_UTIL, UTILIZATOR
|
|
FROM UTILIZATORI
|
|
WHERE UPPER(UTILIZATOR) = :username
|
|
""", {'username': username.upper()})
|
|
|
|
user_row = cursor.fetchone()
|
|
if not user_row:
|
|
logger.warning(f"User {username} not found in UTILIZATORI table")
|
|
# Să încercăm să găsim utilizatori similari
|
|
cursor.execute("""
|
|
SELECT ID_UTIL, UTILIZATOR
|
|
FROM UTILIZATORI
|
|
WHERE UPPER(UTILIZATOR) LIKE :username_pattern
|
|
ORDER BY UTILIZATOR
|
|
""", {'username_pattern': f'%{username.upper()}%'})
|
|
similar_users = cursor.fetchall()
|
|
logger.info(f"Similar users found: {similar_users}")
|
|
return []
|
|
|
|
user_id = user_row[0]
|
|
actual_name = user_row[1]
|
|
logger.info(f"Found user {username} with ID: {user_id}, actual name: {actual_name}")
|
|
|
|
# Al doilea pas: obținem firmele folosind query-ul corect (cu ID_FIRMA)
|
|
cursor.execute("""
|
|
SELECT A.ID_FIRMA, A.FIRMA
|
|
FROM V_NOM_FIRME A
|
|
WHERE A.ID_FIRMA IN (
|
|
SELECT ID_FIRMA
|
|
FROM VDEF_UTIL_FIRME
|
|
WHERE ID_PROGRAM = 2
|
|
AND ID_UTIL = :user_id
|
|
)
|
|
ORDER BY A.FIRMA
|
|
""", {'user_id': user_id})
|
|
|
|
companies_rows = cursor.fetchall()
|
|
companies = [str(row[0]) for row in companies_rows if row[0]]
|
|
|
|
if not companies:
|
|
logger.warning(f"No companies found for user {username} (ID: {user_id})")
|
|
return []
|
|
|
|
logger.info(f"User {username} has access to {len(companies)} companies: {companies}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Could not query companies for user {username}: {e}")
|
|
# În caz de eroare, returnăm listă goală în loc de TEST_COMPANY
|
|
return []
|
|
|
|
# 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,
|
|
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, 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,
|
|
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 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:
|
|
# 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 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 {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(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=actual_username,
|
|
companies=companies,
|
|
user_id=None, # Poate fi adăugat în viitor dacă avem user_id în DB
|
|
permissions=permissions,
|
|
server_id=server_id
|
|
)
|
|
|
|
# Creează obiectul CurrentUser
|
|
current_user = CurrentUser(
|
|
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,
|
|
refresh_token=jwt_tokens.refresh_token,
|
|
token_type=jwt_tokens.token_type,
|
|
expires_in=jwt_tokens.expires_in,
|
|
user=current_user
|
|
)
|
|
|
|
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)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during authentication for user {username}: {str(e)}")
|
|
return False, None, "Internal authentication error"
|
|
|
|
async def validate_user_company_access(self, username: str, company: str) -> bool:
|
|
"""
|
|
Validează dacă utilizatorul are acces la o anumită firmă
|
|
|
|
Args:
|
|
username: Numele utilizatorului
|
|
company: Codul firmei de verificat
|
|
|
|
Returns:
|
|
True dacă utilizatorul are acces, False altfel
|
|
"""
|
|
try:
|
|
companies = await self.get_user_companies(username)
|
|
has_access = company in companies
|
|
|
|
if not has_access:
|
|
logger.warning(f"User {username} attempted to access unauthorized company {company}")
|
|
|
|
return has_access
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error validating company access for user {username}: {str(e)}")
|
|
return False
|
|
|
|
async def refresh_user_data(self, username: str) -> bool:
|
|
"""
|
|
Reîmprospătează datele utilizatorului din cache
|
|
|
|
Args:
|
|
username: Numele utilizatorului
|
|
|
|
Returns:
|
|
True dacă refresh-ul a fost cu succes
|
|
"""
|
|
try:
|
|
# Șterge din cache
|
|
cache_key = self._get_cache_key(username)
|
|
if cache_key in self._user_cache:
|
|
del self._user_cache[cache_key]
|
|
|
|
# Reîncarcă datele
|
|
await self.get_user_companies(username)
|
|
logger.info(f"Refreshed user data for {username}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error refreshing user data for {username}: {str(e)}")
|
|
return False
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Șterge tot cache-ul utilizatorilor"""
|
|
self._user_cache.clear()
|
|
logger.info("User cache cleared")
|
|
|
|
def get_cache_stats(self) -> Dict[str, Any]:
|
|
"""Returnează statistici despre cache"""
|
|
total_entries = len(self._user_cache)
|
|
valid_entries = sum(
|
|
1 for entry in self._user_cache.values()
|
|
if self._is_cache_valid(entry)
|
|
)
|
|
|
|
return {
|
|
'total_entries': total_entries,
|
|
'valid_entries': valid_entries,
|
|
'cache_hit_ratio': valid_entries / total_entries if total_entries > 0 else 0
|
|
}
|
|
|
|
|
|
# Instance globală pentru folosire în toate aplicațiile
|
|
auth_service = UserAuthService() |