fix telegram

This commit is contained in:
Claude Agent
2026-02-23 15:12:33 +00:00
parent 6c78fec8a7
commit 8bc567a9c5
426 changed files with 112478 additions and 1 deletions

View File

@@ -0,0 +1,649 @@
# 🔐 ROA2WEB Shared Authentication System
Sistem de autentificare JWT partajat între toate aplicațiile din ecosistemul ROA2WEB, integrat cu Oracle Database și optimizat pentru aplicații FastAPI.
## 📋 Table of Contents
- [Features](#features)
- [Architecture](#architecture)
- [Quick Start](#quick-start)
- [Components](#components)
- [Integration Guide](#integration-guide)
- [Security Features](#security-features)
- [API Reference](#api-reference)
- [Testing](#testing)
- [Deployment](#deployment)
- [Troubleshooting](#troubleshooting)
## ✨ Features
### Core Features
- **JWT Authentication**: Secure token-based authentication cu access și refresh tokens
- **Oracle Database Integration**: Folosește `pack_drepturi.verificautilizator` pentru autentificare
- **Multi-Company Support**: Acces controlat la multiple firme/schemas Oracle
- **Permission System**: Sistem granular de permisiuni (read, write, admin, reports)
- **FastAPI Integration**: Dependencies și middleware native pentru FastAPI
- **Rate Limiting**: Protecție împotriva brute force attacks
- **Caching**: Cache inteligent pentru performanță optimă
### Security Features
- **Token Expiration**: Configurabil pentru access și refresh tokens
- **SQL Injection Protection**: Parametri legați în toate query-urile
- **Rate Limiting**: Configurabil per IP și endpoint
- **CORS Protection**: Configurare flexibilă pentru origins
- **Header Security**: Security headers automate
- **Token Blacklisting**: Suport pentru invalidarea token-urilor (în dezvoltare)
## 🏗️ Architecture
```
ROA2WEB Authentication Flow:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Client │───▶│ FastAPI │───▶│ JWT │───▶│ Oracle │
│ (Frontend) │ │ Application │ │ Handler │ │ Database │
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Auth Service │ │ Middleware │ │ User Cache │
└──────────────┘ └─────────────┘ └──────────────┘
```
### Components Overview
```
shared/auth/
├── jwt_handler.py # JWT token creation și validation
├── auth_service.py # Oracle database integration
├── models.py # Pydantic models pentru data validation
├── middleware.py # FastAPI middleware pentru auto-authentication
├── dependencies.py # FastAPI dependencies pentru protected routes
├── routes.py # Pre-built authentication routes
├── test_auth.py # Comprehensive test suite
├── demo_app.py # Demo application cu examples
└── README.md # Această documentație
```
## 🚀 Quick Start
### 1. Environment Setup
```bash
# Copy și configurează environment variables
cp .env.example .env
# Edit .env cu configurările tale
JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
ORACLE_USER=your_oracle_username
ORACLE_PASSWORD=your_oracle_password
ORACLE_DSN=your_oracle_connection_string
```
### 2. Basic Integration
```python
from fastapi import FastAPI, Depends
from roa2web.shared.auth import (
AuthenticationMiddleware, create_auth_router,
get_current_user, CurrentUser
)
from roa2web.shared.database import oracle_pool
app = FastAPI(title="My ROA2WEB App")
# Add authentication middleware
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/", "/docs", "/health", "/auth/login"]
)
# Include authentication routes
auth_router = create_auth_router()
app.include_router(auth_router)
@app.on_event("startup")
async def startup():
await oracle_pool.initialize()
@app.get("/protected")
async def protected_endpoint(
current_user: CurrentUser = Depends(get_current_user)
):
return {"message": f"Hello {current_user.username}!"}
```
### 3. Test Authentication
```bash
# Start demo application
cd roa2web/shared/auth
python demo_app.py
# Open browser
open http://localhost:8000/docs
# Login prin Swagger UI cu credențialele Oracle
```
## 🧩 Components
### JWT Handler (`jwt_handler.py`)
Gestionează crearea, validarea și refresh-ul token-urilor JWT.
```python
from roa2web.shared.auth import jwt_handler
# Create access token
token = jwt_handler.create_access_token(
username="admin",
companies=["COMP1", "COMP2"],
permissions=["read", "write", "reports"]
)
# Verify token
token_data = jwt_handler.verify_token(token)
if token_data:
print(f"Valid token for user: {token_data.username}")
```
### Auth Service (`auth_service.py`)
Integrează cu Oracle Database pentru autentificare și management utilizatori.
```python
from roa2web.shared.auth import auth_service
# Authenticate user
success, token_response, error = await auth_service.authenticate_and_create_tokens(
"username", "password"
)
if success:
print(f"Access token: {token_response.access_token}")
else:
print(f"Authentication failed: {error}")
```
### FastAPI Dependencies (`dependencies.py`)
Oferă dependencies pentru protejarea endpoint-urilor.
```python
from fastapi import Depends
from roa2web.shared.auth import (
get_current_user, require_company_access,
require_permissions, PermissionType
)
@app.get("/admin-only")
async def admin_endpoint(
user: CurrentUser = Depends(require_permissions([PermissionType.ADMIN]))
):
return {"message": "Admin access granted"}
@app.get("/company/{company_code}/data")
async def company_data(
company_code: str,
user: CurrentUser = Depends(require_company_access(company_code))
):
return {"company": company_code, "data": "..."}
```
### Authentication Routes (`routes.py`)
Pre-built routes pentru operații de autentificare.
```python
from roa2web.shared.auth import create_auth_router
# Basic auth router
auth_router = create_auth_router()
app.include_router(auth_router)
# Auth router cu admin routes
auth_router_admin = create_auth_router(include_admin_routes=True)
app.include_router(auth_router_admin)
```
Available routes:
- `POST /auth/login` - User authentication
- `POST /auth/refresh` - Token refresh
- `POST /auth/logout` - User logout
- `GET /auth/me` - Current user info
- `GET /auth/companies` - User companies
- `GET /auth/status` - Authentication status
## 🔧 Integration Guide
### Full FastAPI Application
```python
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from roa2web.shared.auth import (
AuthenticationMiddleware, create_auth_router,
get_current_user, require_company_access,
CurrentUser, PermissionType
)
from roa2web.shared.database import oracle_pool
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await oracle_pool.initialize()
yield
# Shutdown
await oracle_pool.close_pool()
app = FastAPI(
title="ROA2WEB Application",
lifespan=lifespan
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Authentication
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/", "/docs", "/health"],
rate_limit_paths=["/auth/login"]
)
# Routes
auth_router = create_auth_router()
app.include_router(auth_router)
# Protected endpoints
@app.get("/")
async def public_endpoint():
return {"message": "Public endpoint"}
@app.get("/me")
async def my_info(current_user: CurrentUser = Depends(get_current_user)):
return current_user
@app.get("/company/{company_code}/invoices")
async def get_invoices(
company_code: str,
current_user: CurrentUser = Depends(require_company_access(company_code))
):
# Business logic here
return {"company": company_code, "invoices": []}
```
### Custom Permissions
```python
from roa2web.shared.auth import require_permissions, PermissionType
# Define custom permissions
class CustomPermissionType(str, Enum):
INVOICE_READ = "invoice_read"
INVOICE_WRITE = "invoice_write"
REPORT_EXPORT = "report_export"
# Use in endpoints
@app.get("/invoices")
async def get_invoices(
user: CurrentUser = Depends(require_permissions([CustomPermissionType.INVOICE_READ]))
):
return {"invoices": []}
```
### Company-Specific Endpoints
```python
from fastapi import Header
from roa2web.shared.auth import get_current_company_from_header
@app.get("/current-company-data")
async def get_current_company_data(
company_code: str = Depends(get_current_company_from_header),
current_user: CurrentUser = Depends(get_current_user)
):
# company_code is automatically extracted from X-Company-Code header
# and validated against user's accessible companies
return {"company": company_code, "data": "..."}
```
## 🔒 Security Features
### JWT Configuration
```python
# Environment variables
JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
```
### Rate Limiting
```python
from roa2web.shared.auth import RateLimiter, AuthenticationMiddleware
# Custom rate limiter
custom_rate_limiter = RateLimiter(
max_requests=10, # 10 requests
time_window=60 # per minute
)
app.add_middleware(
AuthenticationMiddleware,
rate_limit_paths=["/auth/login", "/auth/register"],
rate_limiter=custom_rate_limiter
)
```
### Security Headers
Middleware-ul adaugă automat header-e de securitate:
```
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
```
## 📚 API Reference
### JWT Handler Methods
```python
class JWTHandler:
def create_access_token(username, companies, user_id=None, permissions=None) -> str
def create_refresh_token(username, user_id=None) -> str
def verify_token(token) -> Optional[TokenData]
def refresh_access_token(refresh_token, companies, permissions=None) -> Optional[str]
def create_token_response(username, companies, ...) -> TokenResponse
```
### Auth Service Methods
```python
class UserAuthService:
async def verify_user_credentials(username, password) -> bool
async def get_user_companies(username) -> List[str]
async def get_user_permissions(username, company) -> List[str]
async def authenticate_and_create_tokens(username, password) -> Tuple[bool, TokenResponse, str]
async def validate_user_company_access(username, company) -> bool
```
### FastAPI Dependencies
```python
# User dependencies
get_current_user() -> CurrentUser
get_optional_user() -> Optional[CurrentUser]
# Permission dependencies
require_permissions(permissions: List[PermissionType])
require_company_access(company_code: str)
require_company_and_permissions(company_code: str, permissions: List[PermissionType])
# Utility dependencies
get_current_company_from_header() -> str
```
## 🧪 Testing
### Running Tests
```bash
# Install test dependencies
pip install pytest pytest-asyncio httpx
# Run all tests
cd roa2web/shared/auth
python -m pytest test_auth.py -v
# Run specific test categories
python -m pytest test_auth.py::TestJWTHandler -v
python -m pytest test_auth.py::TestUserAuthService -v
python -m pytest test_auth.py::TestSecurityFeatures -v
# Run with coverage
python -m pytest test_auth.py --cov=. --cov-report=html
```
### Test Categories
- **Unit Tests**: JWT operations, auth service methods
- **Integration Tests**: Database integration, full auth flow
- **Security Tests**: Token tampering, SQL injection, rate limiting
- **Performance Tests**: Token creation/verification speed
### Demo Application
```bash
# Start demo app for manual testing
cd roa2web/shared/auth
python demo_app.py
# Available demo endpoints:
# http://localhost:8000/ - Home page cu documentație
# http://localhost:8000/docs - Swagger UI pentru testare
# http://localhost:8000/demo/* - Various demo endpoints
```
## 🚀 Deployment
### Production Configuration
```bash
# Strong JWT secret key
JWT_SECRET_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
# Shorter token expiration
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=1
# Strict rate limiting
RATE_LIMIT_MAX_REQUESTS=3
RATE_LIMIT_TIME_WINDOW=300
# Secure headers
SECURE_SSL_REDIRECT=true
SESSION_COOKIE_SECURE=true
```
### Docker Integration
```dockerfile
# În Dockerfile-ul aplicației
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Environment pentru container
ENV JWT_SECRET_KEY=${JWT_SECRET_KEY}
ENV ORACLE_USER=${ORACLE_USER}
ENV ORACLE_PASSWORD=${ORACLE_PASSWORD}
ENV ORACLE_DSN=${ORACLE_DSN}
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### Health Checks
```python
@app.get("/health")
async def health_check():
# Test database connection
try:
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
db_status = "healthy"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy" if db_status == "healthy" else "degraded",
"database": db_status,
"jwt": "functional",
"timestamp": datetime.now().isoformat()
}
```
## 🔧 Troubleshooting
### Common Issues
#### 1. "Invalid token" errors
```python
# Check JWT secret key consistency
print(f"JWT Secret: {os.getenv('JWT_SECRET_KEY')}")
# Verify token creation and validation
token = jwt_handler.create_access_token("test", ["COMP1"])
token_data = jwt_handler.verify_token(token)
print(f"Token valid: {token_data is not None}")
```
#### 2. Database connection errors
```python
# Test Oracle connection
try:
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
result = cursor.fetchone()
print("Database connection: OK")
except Exception as e:
print(f"Database error: {e}")
```
#### 3. Rate limiting issues
```python
# Check rate limiter stats
from roa2web.shared.auth import default_rate_limiter
client_ip = "192.168.1.1"
allowed = default_rate_limiter.is_allowed(client_ip)
reset_time = default_rate_limiter.get_reset_time(client_ip)
print(f"IP {client_ip} allowed: {allowed}, resets at: {reset_time}")
```
#### 4. Permission denied errors
```python
# Check user companies and permissions
companies = await auth_service.get_user_companies("username")
permissions = await auth_service.get_user_permissions("username", "COMP1")
print(f"User companies: {companies}")
print(f"User permissions: {permissions}")
```
### Debug Mode
```python
import logging
# Enable debug logging
logging.basicConfig(level=logging.DEBUG)
# Specific loggers
logging.getLogger("roa2web.shared.auth").setLevel(logging.DEBUG)
```
### Environment Validation
```python
from roa2web.shared.utils.config import shared_config
# Validate configuration
print(f"Oracle User: {shared_config.oracle_user}")
print(f"JWT Secret set: {'***' if shared_config.jwt_secret_key else 'NOT SET'}")
print(f"Token expiry: {shared_config.access_token_expire_minutes} minutes")
```
## 📈 Performance Optimization
### Caching
```python
# Cache configuration
AUTH_CACHE_TTL_MINUTES=15 # User data cache TTL
# Monitor cache performance
stats = auth_service.get_cache_stats()
print(f"Cache hit ratio: {stats['cache_hit_ratio']:.2%}")
```
### Connection Pooling
```python
# Oracle pool configuration
DB_MIN_CONNECTIONS=2
DB_MAX_CONNECTIONS=10
DB_CONNECTION_INCREMENT=1
```
### Token Optimization
```python
# Optimize token size by limiting payload
token = jwt_handler.create_access_token(
username="user",
companies=["COMP1"], # Limit companies in token
permissions=["read"] # Essential permissions only
)
```
## 🤝 Contributing
Pentru contribuții la sistemul de autentificare:
1. **Fork repository-ul** și creează o ramură pentru feature
2. **Implementează schimbările** cu tests comprehensive
3. **Rulează toate testele** pentru a verifica compatibilitatea
4. **Actualizează documentația** dacă este necesar
5. **Creează Pull Request** cu descriere detaliată
### Development Setup
```bash
# Clone repository
git clone [repository-url]
cd roa-flask
# Setup environment
python -m venv venv
source venv/bin/activate # Linux/Mac
# or
venv\Scripts\activate # Windows
pip install -r requirements.txt
# Run tests
cd roa2web/shared/auth
python -m pytest test_auth.py -v
```
## 📜 License
Acest sistem de autentificare face parte din proiectul ROA2WEB și este disponibil sub aceleași condiții de licențiere ca și proiectul principal.
---
**ROA2WEB Authentication System v1.0.0**
*Secure, scalable, Oracle-integrated authentication pentru aplicații moderne*

View File

@@ -0,0 +1,23 @@
"""
ROA2WEB Shared Authentication Module
This module provides JWT-based authentication functionality that can be shared
across all ROA2WEB microservices.
Components:
- jwt_handler: JWT token creation, validation, and refresh
- auth_service: Oracle database authentication integration
- middleware: FastAPI middleware for token validation
- dependencies: FastAPI dependencies for protected routes
- models: Pydantic models for authentication data
- routes: Template authentication routes for FastAPI apps
"""
from .jwt_handler import jwt_handler, JWTHandler, TokenData, TokenResponse
__all__ = [
'jwt_handler',
'JWTHandler',
'TokenData',
'TokenResponse'
]

View File

@@ -0,0 +1,476 @@
"""
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()

View File

@@ -0,0 +1,540 @@
"""
FastAPI Demo App demonstrând sistemul de autentificare ROA2WEB
Această aplicație demonstrează integrarea completă a sistemului de autentificare:
- Login și logout cu Oracle database
- Protected routes cu JWT authentication
- Company-specific access control
- Permission-based authorization
- Rate limiting și security features
Funcționează ca:
1. Exemplu de integrare pentru dezvoltatori
2. Tool de testare pentru sistemul de autentificare
3. Demonstrație pentru managementul proiectului
Pentru a rula demo-ul:
1. Configurează variabilele de mediu în .env
2. Asigură-te că Oracle database este accesibil
3. Rulează: python demo_app.py
4. Acesează http://localhost:8000/docs pentru Swagger UI
"""
import asyncio
import logging
import sys
import os
from datetime import datetime
from typing import List, Optional
import uvicorn
from fastapi import FastAPI, Depends, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from contextlib import asynccontextmanager
# Adaugă calea pentru modulele shared
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
# Import modulele de autentificare
from .jwt_handler import jwt_handler
from .auth_service import auth_service
from .models import CurrentUser, LoginRequest, PermissionType
from .routes import create_auth_router
from .middleware import AuthenticationMiddleware, default_rate_limiter
from .dependencies import (
get_current_user, get_optional_user, require_company_access,
require_permissions, get_current_company_from_header
)
# Import componente shared
from database.oracle_pool import oracle_pool
from utils.config import shared_config
# Configurare logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Lifecycle events pentru demo app
"""
# Startup
logger.info("🚀 Starting ROA2WEB Authentication Demo")
try:
# Inițializează Oracle pool
await oracle_pool.initialize(
user=shared_config.oracle_user,
password=shared_config.oracle_password,
dsn=shared_config.oracle_dsn,
min_connections=2,
max_connections=5
)
logger.info("✅ Oracle connection pool initialized")
# Test database connection
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 'Database connected successfully' FROM DUAL")
result = cursor.fetchone()
logger.info(f"✅ Database test: {result[0]}")
except Exception as e:
logger.error(f"❌ Startup error: {str(e)}")
logger.warning("Demo will continue but database features may not work")
yield
# Shutdown
logger.info("🛑 Shutting down ROA2WEB Authentication Demo")
try:
await oracle_pool.close_pool()
logger.info("✅ Oracle connection pool closed")
except Exception as e:
logger.error(f"❌ Shutdown error: {str(e)}")
# Crearea aplicației FastAPI
app = FastAPI(
title="ROA2WEB Authentication Demo",
description="""
Demonstrație completă a sistemului de autentificare ROA2WEB
Această aplicație demonstrează:
- JWT Authentication cu Oracle Database
- Protected routes și company access control
- Permission-based authorization
- Rate limiting și security features
- Integration patterns pentru aplicații ROA2WEB
""",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan
)
# CORS pentru development
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173", "http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Authentication middleware
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/", "/docs", "/redoc", "/openapi.json", "/health", "/demo", "/auth/login"],
rate_limit_paths=["/auth/login"],
rate_limiter=default_rate_limiter
)
# Include authentication router
auth_router = create_auth_router(include_admin_routes=True)
app.include_router(auth_router)
# =============================================================================
# DEMO ENDPOINTS
# =============================================================================
@app.get("/", response_class=HTMLResponse)
async def demo_home():
"""
Pagina principală cu informații despre demo
"""
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>ROA2WEB Authentication Demo</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #34495e; margin-top: 30px; }
.endpoint { background: #ecf0f1; padding: 15px; margin: 10px 0; border-radius: 5px; border-left: 4px solid #3498db; }
.method { font-weight: bold; color: #e74c3c; }
.protected { border-left-color: #f39c12; }
.public { border-left-color: #27ae60; }
code { background: #34495e; color: white; padding: 2px 6px; border-radius: 3px; }
.status { padding: 10px; margin: 15px 0; border-radius: 5px; }
.success { background: #d5edda; border: 1px solid #c3e6cb; color: #155724; }
.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
</style>
</head>
<body>
<div class="container">
<h1>🔐 ROA2WEB Authentication Demo</h1>
<div class="status info">
<strong>Status:</strong> Demo aplicație ROA2WEB Authentication System<br>
<strong>Versiune:</strong> 1.0.0<br>
<strong>Timp:</strong> """ + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + """
</div>
<h2>📋 Endpoints Disponibile</h2>
<div class="endpoint public">
<div class="method">GET</div>
<strong>/docs</strong> - Swagger UI pentru testarea API-ului
</div>
<div class="endpoint public">
<div class="method">GET</div>
<strong>/health</strong> - Health check pentru aplicație și database
</div>
<div class="endpoint public">
<div class="method">POST</div>
<strong>/auth/login</strong> - Autentificare utilizator cu username/password
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/auth/me</strong> - Informații utilizator curent (protejat)
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/demo/protected</strong> - Endpoint protejat simplu
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/demo/company/{company_code}</strong> - Endpoint cu verificare acces firmă
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/demo/admin</strong> - Endpoint cu verificare admin permissions
</div>
<h2>🧪 Cum să testezi</h2>
<ol>
<li>Accesează <a href="/docs">/docs</a> pentru Swagger UI</li>
<li>Folosește <code>POST /auth/login</code> cu credențiale valide</li>
<li>Copiază <code>access_token</code> din răspuns</li>
<li>Click pe "Authorize" în Swagger UI și introdu: <code>Bearer YOUR_TOKEN</code></li>
<li>Testează endpoint-urile protejate</li>
</ol>
<h2>🔧 Configurare</h2>
<p>Pentru a funcționa complet, demo-ul necesită:</p>
<ul>
<li>Variabile de mediu configurate în <code>.env</code></li>
<li>Conexiune la Oracle Database</li>
<li>Utilizatori valizi în sistemul Oracle</li>
</ul>
<div class="status success">
<strong>💡 Tip:</strong> Pentru dezvoltare rapidă, vezi <code>demo_app.py</code>
pentru exemple de integrare a autentificării în aplicațiile tale FastAPI.
</div>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content)
@app.get("/health")
async def health_check():
"""
Health check complet pentru demo
"""
health_status = {
"service": "ROA2WEB Authentication Demo",
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"version": "1.0.0"
}
# Test database connection
try:
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
cursor.fetchone()
health_status["database"] = "connected"
except Exception as e:
health_status["database"] = f"error: {str(e)}"
health_status["status"] = "degraded"
# Test JWT handler
try:
test_token = jwt_handler.create_access_token("healthcheck", ["TEST"])
token_data = jwt_handler.verify_token(test_token)
if token_data and token_data.username == "healthcheck":
health_status["jwt"] = "functional"
else:
health_status["jwt"] = "error: token verification failed"
health_status["status"] = "degraded"
except Exception as e:
health_status["jwt"] = f"error: {str(e)}"
health_status["status"] = "degraded"
# Authentication service status
try:
cache_stats = auth_service.get_cache_stats()
health_status["auth_cache"] = {
"total_entries": cache_stats["total_entries"],
"cache_hit_ratio": cache_stats["cache_hit_ratio"]
}
except Exception as e:
health_status["auth_cache"] = f"error: {str(e)}"
status_code = 200 if health_status["status"] == "healthy" else 503
return JSONResponse(content=health_status, status_code=status_code)
@app.get("/demo/public")
async def demo_public_endpoint():
"""
Endpoint public - nu necesită autentificare
"""
return {
"message": "Acesta este un endpoint public",
"authenticated": False,
"timestamp": datetime.now().isoformat(),
"info": "Acest endpoint poate fi accesat fără autentificare"
}
@app.get("/demo/optional-auth")
async def demo_optional_auth(
current_user: Optional[CurrentUser] = Depends(get_optional_user)
):
"""
Endpoint cu autentificare opțională
"""
if current_user:
return {
"message": f"Salut, {current_user.username}!",
"authenticated": True,
"user": current_user.username,
"companies": current_user.companies,
"timestamp": datetime.now().isoformat()
}
else:
return {
"message": "Acesta este un endpoint cu autentificare opțională",
"authenticated": False,
"timestamp": datetime.now().isoformat(),
"info": "Poți accesa și fără autentificare, dar cu token obții mai multe informații"
}
@app.get("/demo/protected")
async def demo_protected_endpoint(
current_user: CurrentUser = Depends(get_current_user)
):
"""
Endpoint protejat - necesită autentificare
"""
return {
"message": f"Bună ziua, {current_user.username}!",
"authenticated": True,
"user_info": {
"username": current_user.username,
"companies": current_user.companies,
"permissions": current_user.permissions,
"companies_count": len(current_user.companies)
},
"timestamp": datetime.now().isoformat(),
"info": "Acest endpoint necesită JWT token valid pentru acces"
}
@app.get("/demo/company/{company_code}")
async def demo_company_specific_endpoint(
company_code: str,
current_user: CurrentUser = Depends(require_company_access("")) # Will be overridden
):
"""
Endpoint cu verificare acces la firmă specifică
Demonstrează cum să verifici dacă utilizatorul are acces la o anumită firmă
"""
# Verificare manuală pentru demonstrație (în practică folosești dependency)
if company_code not in current_user.companies:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Nu aveți acces la firma {company_code}"
)
return {
"message": f"Acces permis la firma {company_code}",
"company_code": company_code,
"user": current_user.username,
"user_companies": current_user.companies,
"timestamp": datetime.now().isoformat(),
"info": "Utilizatorul are acces la această firmă"
}
@app.get("/demo/admin")
async def demo_admin_endpoint(
current_user: CurrentUser = Depends(require_permissions([PermissionType.ADMIN]))
):
"""
Endpoint cu verificare permisiuni admin
"""
return {
"message": f"Bună ziua, admin {current_user.username}!",
"admin_info": {
"username": current_user.username,
"permissions": current_user.permissions,
"companies": current_user.companies,
"admin_since": datetime.now().isoformat()
},
"system_stats": {
"total_companies": len(current_user.companies),
"demo_version": "1.0.0",
"auth_system": "ROA2WEB JWT"
},
"timestamp": datetime.now().isoformat(),
"info": "Acest endpoint necesită permisiuni de administrator"
}
@app.get("/demo/reports")
async def demo_reports_endpoint(
request: Request,
current_user: CurrentUser = Depends(require_permissions([PermissionType.REPORTS]))
):
"""
Endpoint pentru rapoarte - demonstrează integrarea cu header-ul Company
"""
# Obține company din header (X-Company-Code) sau folosește prima disponibilă
company_code = request.headers.get("X-Company-Code")
if not company_code:
company_code = current_user.companies[0] if current_user.companies else None
if not company_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Nu s-a specificat codul firmei (X-Company-Code header)"
)
if company_code not in current_user.companies:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Nu aveți acces la rapoartele firmei {company_code}"
)
# Simulează generarea unui raport
mock_report_data = {
"company_code": company_code,
"report_type": "demo_report",
"generated_by": current_user.username,
"generated_at": datetime.now().isoformat(),
"data": {
"total_invoices": 150,
"total_amount": 125000.50,
"paid_invoices": 120,
"outstanding_amount": 25000.00
}
}
return {
"message": f"Raport generat pentru firma {company_code}",
"report": mock_report_data,
"user_info": {
"username": current_user.username,
"permissions": current_user.permissions
},
"info": "Acesta este un exemplu de endpoint pentru rapoarte cu verificare company access"
}
@app.get("/demo/rate-limited")
async def demo_rate_limited_endpoint():
"""
Endpoint cu rate limiting pentru demonstrație
"""
return {
"message": "Acest endpoint are rate limiting aplicat",
"timestamp": datetime.now().isoformat(),
"info": "Încercați să faceți mai multe request-uri rapid pentru a vedea rate limiting-ul"
}
# =============================================================================
# DEMO UTILITIES
# =============================================================================
@app.get("/demo/token-info")
async def demo_token_info(
request: Request,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Endpoint pentru afișarea informațiilor despre token-ul curent
"""
# Extrage token-ul din header
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
# Decodează token-ul pentru informații (fără verificare pentru demo)
payload = jwt_handler.decode_token_payload(token)
return {
"message": "Informații despre token-ul curent",
"token_info": {
"user": current_user.username,
"companies": current_user.companies,
"permissions": current_user.permissions,
"token_type": payload.get("type") if payload else "unknown",
"issued_at": payload.get("iat") if payload else None,
"expires_at": payload.get("exp") if payload else None
},
"timestamp": datetime.now().isoformat()
}
else:
return {
"error": "Nu s-a găsit token în header-ul Authorization"
}
# =============================================================================
# MAIN EXECUTION
# =============================================================================
def main():
"""
Funcția principală pentru rularea demo-ului
"""
print("🚀 Starting ROA2WEB Authentication Demo")
print("📋 Available endpoints:")
print(" • http://localhost:8000/ - Demo home page")
print(" • http://localhost:8000/docs - Swagger UI")
print(" • http://localhost:8000/health - Health check")
print(" • http://localhost:8000/demo/* - Demo endpoints")
print("")
print("💡 Pentru testare completă:")
print(" 1. Configurează .env cu credențialele Oracle")
print(" 2. Asigură-te că database-ul este accesibil")
print(" 3. Folosește /docs pentru testarea interactivă")
print("")
uvicorn.run(
"demo_app:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,402 @@
"""
FastAPI Authentication Dependencies pentru ROA2WEB
Acest modul oferă dependency functions pentru FastAPI care pot fi folosite
pentru a proteja endpoint-urile și a obține informații despre utilizatorul curent.
Dependencies disponibile:
- get_current_user: Obține utilizatorul curent (obligatoriu)
- get_optional_user: Obține utilizatorul curent (opțional)
- require_company_access: Verifică accesul la o firmă specifică
- require_permissions: Verifică permisiunile necesare
- get_current_company: Obține firma curentă din context
"""
import logging
from typing import Optional, List, Callable, Any
from functools import wraps
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from .middleware import security_required, security_optional
from .jwt_handler import jwt_handler, TokenData
from .auth_service import auth_service
from .models import CurrentUser, PermissionType, AuthError
logger = logging.getLogger(__name__)
class AuthenticationRequired(Exception):
"""Excepție pentru când autentificarea este obligatorie"""
pass
class InsufficientPermissions(Exception):
"""Excepție pentru permisiuni insuficiente"""
pass
class CompanyAccessDenied(Exception):
"""Excepție pentru acces refuzat la firmă"""
pass
async def get_current_user_from_token(
credentials: HTTPAuthorizationCredentials = Depends(security_required)
) -> CurrentUser:
"""
Extrage și validează utilizatorul curent din token JWT
Args:
credentials: Credențialele HTTP de autentificare din header
Returns:
Utilizatorul curent autentificat
Raises:
HTTPException: Dacă token-ul este invalid sau utilizatorul nu există
"""
if not credentials:
logger.warning("No credentials provided for protected endpoint")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
# Validează token-ul
token_data = jwt_handler.verify_token(credentials.credentials)
if not token_data:
logger.warning("Invalid token provided")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
if token_data.token_type != "access":
logger.warning(f"Invalid token type: {token_data.token_type}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"},
)
# Creează obiectul CurrentUser
current_user = CurrentUser(
username=token_data.username,
user_id=token_data.user_id,
companies=token_data.companies,
permissions=token_data.permissions
)
logger.debug(f"Successfully authenticated user: {current_user.username}")
return current_user
async def get_current_user_from_request(request: Request) -> CurrentUser:
"""
Obține utilizatorul curent din request state (setat de middleware)
Args:
request: Request-ul HTTP curent
Returns:
Utilizatorul curent autentificat
Raises:
HTTPException: Dacă utilizatorul nu este autentificat
"""
if not hasattr(request.state, 'is_authenticated') or not request.state.is_authenticated:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
if not hasattr(request.state, 'user') or not request.state.user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in request",
headers={"WWW-Authenticate": "Bearer"},
)
return request.state.user
async def get_optional_user_from_request(request: Request) -> Optional[CurrentUser]:
"""
Obține utilizatorul curent din request (opțional)
Args:
request: Request-ul HTTP curent
Returns:
Utilizatorul curent sau None dacă nu este autentificat
"""
if (hasattr(request.state, 'is_authenticated') and
request.state.is_authenticated and
hasattr(request.state, 'user')):
return request.state.user
return None
async def get_optional_user_from_token(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional)
) -> Optional[CurrentUser]:
"""
Extrage utilizatorul curent din token (opțional)
Args:
credentials: Credențialele HTTP Bearer (opționale)
Returns:
Utilizatorul curent sau None
"""
if not credentials:
return None
try:
return await get_current_user_from_token(credentials)
except HTTPException:
return None
def require_company_access(company_code: str):
"""
Dependency factory care verifică accesul la o firmă specifică
Args:
company_code: Codul firmei la care se verifică accesul
Returns:
Dependency function pentru FastAPI
"""
async def check_company_access(
current_user: CurrentUser = Depends(get_current_user_from_request)
) -> CurrentUser:
"""
Verifică dacă utilizatorul curent are acces la firma specificată
Args:
current_user: Utilizatorul curent autentificat
Returns:
Utilizatorul curent dacă are acces
Raises:
HTTPException: Dacă nu are acces la firmă
"""
if company_code not in current_user.companies:
logger.warning(
f"User {current_user.username} attempted to access "
f"unauthorized company {company_code}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to company {company_code}"
)
# Verifică și în baza de date pentru siguranță
has_access = await auth_service.validate_user_company_access(
current_user.username, company_code
)
if not has_access:
logger.error(
f"Database access check failed for user {current_user.username} "
f"and company {company_code}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Database access denied to company {company_code}"
)
logger.debug(f"User {current_user.username} granted access to company {company_code}")
return current_user
return check_company_access
def require_permissions(required_permissions: List[PermissionType]):
"""
Dependency factory care verifică permisiunile necesare
Args:
required_permissions: Lista permisiunilor necesare
Returns:
Dependency function pentru FastAPI
"""
async def check_permissions(
current_user: CurrentUser = Depends(get_current_user_from_request)
) -> CurrentUser:
"""
Verifică dacă utilizatorul are permisiunile necesare
Args:
current_user: Utilizatorul curent autentificat
Returns:
Utilizatorul curent dacă are permisiunile
Raises:
HTTPException: Dacă nu are permisiunile necesare
"""
user_permissions = set(current_user.permissions)
missing_permissions = [
perm for perm in required_permissions
if perm not in user_permissions
]
if missing_permissions:
logger.warning(
f"User {current_user.username} missing permissions: {missing_permissions}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {missing_permissions}"
)
logger.debug(f"User {current_user.username} has required permissions")
return current_user
return check_permissions
def require_company_and_permissions(
company_code: str,
required_permissions: List[PermissionType]
):
"""
Dependency factory care verifică atât accesul la firmă cât și permisiunile
Args:
company_code: Codul firmei
required_permissions: Lista permisiunilor necesare
Returns:
Dependency function pentru FastAPI
"""
async def check_company_and_permissions(
current_user: CurrentUser = Depends(get_current_user_from_request)
) -> CurrentUser:
"""
Verifică accesul la firmă și permisiunile
Args:
current_user: Utilizatorul curent
Returns:
Utilizatorul curent dacă are acces și permisiuni
"""
# Verifică accesul la firmă
company_checker = require_company_access(company_code)
await company_checker(current_user)
# Verifică permisiunile
permissions_checker = require_permissions(required_permissions)
await permissions_checker(current_user)
return current_user
return check_company_and_permissions
async def get_current_company_from_header(
request: Request,
current_user: CurrentUser = Depends(get_current_user_from_request)
) -> str:
"""
Obține codul firmei curente din header-ul X-Company-Code
Args:
request: Request-ul HTTP
current_user: Utilizatorul curent
Returns:
Codul firmei curente
Raises:
HTTPException: Dacă header-ul lipsește sau utilizatorul nu are acces
"""
company_code = request.headers.get("X-Company-Code")
if not company_code:
# Folosește prima firmă ca default dacă nu este specificată
if current_user.companies:
company_code = current_user.companies[0]
logger.debug(f"Using default company {company_code} for user {current_user.username}")
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Company code required (X-Company-Code header or user default)"
)
# Verifică accesul
if company_code not in current_user.companies:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to company {company_code}"
)
return company_code
# Aliasuri pentru folosire mai ușoară
get_current_user = get_current_user_from_request
get_optional_user = get_optional_user_from_request
# Dependency-uri predefinite pentru permisiuni comune
require_read_permission = require_permissions([PermissionType.READ])
require_write_permission = require_permissions([PermissionType.WRITE])
require_admin_permission = require_permissions([PermissionType.ADMIN])
require_reports_permission = require_permissions([PermissionType.REPORTS])
# Decorator pentru validarea companiei în funcții
def validate_company_access(company_param: str = "company"):
"""
Decorator pentru validarea automată a accesului la firmă
Args:
company_param: Numele parametrului care conține codul firmei
Returns:
Decorator function
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
# Caută utilizatorul curent în argumentele funcției
current_user = None
for arg in args:
if isinstance(arg, CurrentUser):
current_user = arg
break
if not current_user:
# Caută în kwargs
current_user = kwargs.get('current_user')
if not current_user:
raise ValueError("CurrentUser not found in function arguments")
# Obține codul firmei
company_code = kwargs.get(company_param)
if not company_code:
raise ValueError(f"Company parameter '{company_param}' not found")
# Validează accesul
if company_code not in current_user.companies:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to company {company_code}"
)
return await func(*args, **kwargs)
return wrapper
return decorator

View File

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

View File

@@ -0,0 +1,264 @@
"""
JWT Authentication Handler - Shared între toate aplicațiile ROA2WEB
Acest modul gestionează crearea, validarea și refresh-ul token-urilor JWT
pentru autentificarea utilizatorilor în ecosistemul ROA2WEB.
Payload structure:
{
"username": "string",
"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"
}
"""
from jose import jwt
import os
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field
import logging
logger = logging.getLogger(__name__)
class TokenData(BaseModel):
"""Date conținute în token"""
username: str = Field(description="Numele utilizatorului")
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)")
class TokenResponse(BaseModel):
"""Răspuns pentru token-uri"""
access_token: str = Field(description="JWT access token")
refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
token_type: str = Field(default="bearer", description="Tipul token-ului")
expires_in: int = Field(description="Timpul de expirare în secunde")
class JWTHandler:
"""
Gestionarea JWT tokens pentru autentificare
Această clasă oferă funcționalități pentru:
- Crearea token-urilor access și refresh
- Validarea și decodificarea token-urilor
- Gestionarea expirării token-urilor
"""
def __init__(self, secret_key: Optional[str] = None, algorithm: str = "HS256"):
"""
Inițializează JWT handler
Args:
secret_key: Cheia secretă pentru semnarea token-urilor
algorithm: Algoritmul de criptare (default: HS256)
"""
self.secret_key = secret_key or os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-in-production')
self.algorithm = algorithm
self.access_token_expire_minutes = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', 30))
self.refresh_token_expire_days = int(os.getenv('REFRESH_TOKEN_EXPIRE_DAYS', 7))
# Warning pentru development
if self.secret_key == 'your-secret-key-change-in-production':
logger.warning("Using default JWT secret key! Change JWT_SECRET_KEY in production!")
def create_access_token(
self,
username: str,
companies: List[str],
user_id: Optional[int] = 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} on server {server_id or 'default'} with companies: {companies}")
return token
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} on server {server_id or 'default'}")
return token
def verify_token(self, token: str) -> Optional[TokenData]:
"""
Verifică și decodează un JWT token
Args:
token: Token-ul JWT de verificat
Returns:
TokenData cu informațiile din token sau None dacă token-ul e invalid
"""
try:
logger.debug(f"Using JWT secret key (first 10 chars): {self.secret_key[:10]}...")
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
token_data = TokenData(**payload)
logger.debug(f"Token verified successfully for user {token_data.username}")
return token_data
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
return None
except jwt.JWTError as e:
logger.warning(f"Invalid token: {str(e)}")
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]:
"""
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,
server_id=token_data.server_id
)
def create_token_response(
self,
username: str,
companies: List[str],
user_id: Optional[int] = None,
permissions: Optional[List[str]] = None,
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, 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,
token_type="bearer",
expires_in=self.access_token_expire_minutes * 60
)
def decode_token_payload(self, token: str) -> Optional[Dict[str, Any]]:
"""
Decodează token-ul fără verificare (pentru debugging)
Args:
token: Token-ul de decodat
Returns:
Payload-ul token-ului sau None
"""
try:
# Decodare fără verificare - doar pentru debugging
payload = jwt.decode(token, key="", algorithms=[self.algorithm], options={"verify_signature": False})
return payload
except Exception as e:
logger.error(f"Error decoding token payload: {str(e)}")
return None
# Instance globală pentru folosire în toate aplicațiile
jwt_handler = JWTHandler()

View File

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

View File

@@ -0,0 +1,344 @@
"""
Authentication Pydantic Models pentru ROA2WEB
Acest modul definește toate modelele de date folosite în sistemul de autentificare,
incluzând request/response models și modele pentru user data.
Modelele acoperă:
- Login request și response
- Token data și management
- User information și permisiuni
- Company access control
"""
from pydantic import BaseModel, Field, validator, EmailStr
from typing import List, Optional, Dict, Any
from datetime import datetime
from enum import Enum
class PermissionType(str, Enum):
"""Tipurile de permisiuni disponibile în sistem"""
READ = "read"
WRITE = "write"
DELETE = "delete"
ADMIN = "admin"
REPORTS = "reports"
EXPORT = "export"
class TokenType(str, Enum):
"""Tipurile de token-uri JWT"""
ACCESS = "access"
REFRESH = "refresh"
class LoginRequest(BaseModel):
"""Model pentru request-ul de login"""
username: str = Field(
...,
min_length=3,
max_length=50,
description="Numele utilizatorului",
example="admin"
)
password: str = Field(
...,
min_length=1,
description="Parola utilizatorului"
)
remember_me: bool = Field(
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 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 .')
# 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):
"""Model pentru răspunsul de autentificare cu token-uri"""
access_token: str = Field(description="JWT access token")
refresh_token: Optional[str] = Field(
default=None,
description="JWT refresh token (opțional)"
)
token_type: str = Field(
default="bearer",
description="Tipul token-ului (întotdeauna 'bearer')"
)
expires_in: int = Field(
description="Timpul de expirare al access token-ului în secunde"
)
user: 'CurrentUser' = Field(description="Informațiile utilizatorului autentificat")
class RefreshTokenRequest(BaseModel):
"""Model pentru request-ul de refresh token"""
refresh_token: str = Field(description="Refresh token-ul valid")
class LogoutRequest(BaseModel):
"""Model pentru request-ul de logout"""
refresh_token: Optional[str] = Field(
default=None,
description="Refresh token de invalidat (opțional)"
)
class CurrentUser(BaseModel):
"""Model pentru utilizatorul curent autentificat"""
username: str = Field(description="Numele utilizatorului")
user_id: Optional[int] = Field(
default=None,
description="ID-ul utilizatorului în baza de date"
)
email: Optional[EmailStr] = Field(
default=None,
description="Email-ul utilizatorului"
)
companies: List[str] = Field(
default_factory=list,
description="Lista codurilor firmelor la care utilizatorul are acces"
)
permissions: List[PermissionType] = Field(
default_factory=lambda: [PermissionType.READ],
description="Lista permisiunilor utilizatorului"
)
is_active: bool = Field(
default=True,
description="Dacă utilizatorul este activ"
)
last_login: Optional[datetime] = Field(
default=None,
description="Data ultimei autentificări"
)
@validator('companies')
def companies_not_empty_if_active(cls, v, values):
"""Validează că utilizatorii activi au cel puțin o firmă"""
if values.get('is_active', True) and not v:
raise ValueError('Utilizatorii activi trebuie să aibă acces la cel puțin o firmă')
return v
class UserCompany(BaseModel):
"""Model pentru o firmă la care utilizatorul are acces"""
code: str = Field(description="Codul firmei (schema Oracle)")
name: Optional[str] = Field(
default=None,
description="Numele firmei (dacă este disponibil)"
)
permissions: List[PermissionType] = Field(
default_factory=lambda: [PermissionType.READ],
description="Permisiunile utilizatorului pentru această firmă"
)
is_default: bool = Field(
default=False,
description="Dacă aceasta este firma implicită pentru utilizator"
)
class CompanyAccessRequest(BaseModel):
"""Model pentru verificarea accesului la o firmă"""
company_code: str = Field(description="Codul firmei de verificat")
required_permissions: Optional[List[PermissionType]] = Field(
default=None,
description="Permisiunile necesare (opțional)"
)
class CompanyAccessResponse(BaseModel):
"""Model pentru răspunsul de verificare acces firmă"""
has_access: bool = Field(description="Dacă utilizatorul are acces")
company: Optional[UserCompany] = Field(
default=None,
description="Detaliile firmei dacă utilizatorul are acces"
)
missing_permissions: Optional[List[PermissionType]] = Field(
default=None,
description="Permisiunile lipsă (dacă aplicabil)"
)
class AuthError(BaseModel):
"""Model pentru erorile de autentificare"""
error: str = Field(description="Tipul erorii")
error_description: str = Field(description="Descrierea detaliată a erorii")
error_code: Optional[str] = Field(
default=None,
description="Codul de eroare pentru procesare automată"
)
class AuthStats(BaseModel):
"""Model pentru statisticile de autentificare"""
total_users: int = Field(description="Numărul total de utilizatori")
active_sessions: int = Field(description="Sesiuni active curente")
cache_hit_ratio: float = Field(
description="Rata de hit a cache-ului pentru date utilizatori"
)
last_cleanup: Optional[datetime] = Field(
default=None,
description="Ultima curățare a cache-ului"
)
class PasswordChangeRequest(BaseModel):
"""Model pentru schimbarea parolei (pentru viitor)"""
current_password: str = Field(description="Parola curentă")
new_password: str = Field(
min_length=8,
description="Noua parolă (minim 8 caractere)"
)
confirm_password: str = Field(description="Confirmarea noii parole")
@validator('confirm_password')
def passwords_match(cls, v, values):
"""Validează că parolele coincid"""
if 'new_password' in values and v != values['new_password']:
raise ValueError('Parolele nu coincid')
return v
class SessionInfo(BaseModel):
"""Model pentru informațiile despre sesiune"""
session_id: str = Field(description="ID-ul sesiunii")
username: str = Field(description="Numele utilizatorului")
created_at: datetime = Field(description="Data creării sesiunii")
last_activity: datetime = Field(description="Ultima activitate")
ip_address: Optional[str] = Field(
default=None,
description="Adresa IP a utilizatorului"
)
user_agent: Optional[str] = Field(
default=None,
description="User agent-ul browserului"
)
is_active: bool = Field(
default=True,
description="Dacă sesiunea este încă activă"
)
# ============================================================================
# MULTI-ORACLE IDENTITY CHECK MODELS (US-004, US-013)
# ============================================================================
class CheckIdentityRequest(BaseModel):
"""
Model pentru verificarea identității în sistemul multi-Oracle (US-013)
Suportă atât email cât și username:
- Cu '@': tratează ca email și caută în EmailServerCache
- Fără '@': tratează ca username și caută în Oracle pe toate serverele
"""
identity: str = Field(
...,
min_length=2,
max_length=100,
description="Email sau username de verificat",
example="user@example.com sau MARIUS"
)
@validator('identity')
def validate_identity(cls, v):
"""Validează și normalizează identitatea"""
stripped = v.strip()
if not stripped:
raise ValueError('Identitatea nu poate fi goală')
# Pentru email-uri, normalizăm la lowercase
if '@' in stripped:
return stripped.lower()
# Pentru username-uri, normalizăm la uppercase (convenție Oracle)
return stripped.upper()
class CheckEmailRequest(BaseModel):
"""
Model pentru verificarea email-ului în sistemul multi-Oracle (US-004)
DEPRECATED: Folosește CheckIdentityRequest pentru suport dual email/username
Păstrat pentru backward compatibility.
"""
email: EmailStr = Field(
...,
description="Adresa email a utilizatorului de verificat",
example="user@example.com"
)
class ServerInfo(BaseModel):
"""Informații despre un server Oracle disponibil pentru utilizator"""
id: str = Field(description="ID-ul serverului (ex: 'romfast')")
name: str = Field(description="Numele human-readable al serverului (ex: 'Romfast - Producție')")
class CheckIdentityResponse(BaseModel):
"""
Răspunsul pentru verificarea identității (email sau username) (US-013).
SECURITATE:
- Pentru identitate validă: returnează exists=True și lista serverelor
- Pentru identitate invalidă: returnează exists=False și listă goală de servere
(NU expunem serverele disponibile pentru a preveni enumerarea!)
"""
exists: bool = Field(
description="True dacă identitatea există în sistem pe cel puțin un server"
)
servers: List[ServerInfo] = Field(
default_factory=list,
description="Lista serverelor pe care există identitatea (goală pentru identitate invalidă)"
)
identity_type: str = Field(
default="unknown",
description="Tipul identității: 'email' sau 'username'"
)
class CheckEmailResponse(BaseModel):
"""
Răspunsul pentru verificarea email-ului (US-004).
DEPRECATED: Folosește CheckIdentityResponse pentru suport dual email/username
Păstrat pentru backward compatibility.
SECURITATE:
- Pentru email valid: returnează exists=True și lista serverelor
- Pentru email invalid: returnează exists=False și listă goală de servere
(NU expunem serverele disponibile pentru a preveni enumerarea!)
"""
exists: bool = Field(
description="True dacă email-ul există în sistem pe cel puțin un server"
)
servers: List[ServerInfo] = Field(
default_factory=list,
description="Lista serverelor pe care există email-ul (goală pentru email invalid)"
)
# Update la forward references pentru TokenResponse
TokenResponse.model_rebuild()

View File

@@ -0,0 +1,681 @@
"""
Authentication Routes Template pentru ROA2WEB FastAPI Applications
Acest modul oferă rute predefinite pentru autentificare care pot fi integrate
în orice aplicație FastAPI din ecosistemul ROA2WEB.
Endpoints disponibile:
- POST /auth/login - Autentificare utilizator
- POST /auth/refresh - Refresh access token
- POST /auth/logout - Deconectare utilizator
- GET /auth/me - Informații utilizator curent
- GET /auth/companies - Firmele utilizatorului
- GET /auth/status - Status autentificare
"""
import logging
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from fastapi.security import HTTPAuthorizationCredentials
from .models import (
LoginRequest, TokenResponse, RefreshTokenRequest, LogoutRequest,
CurrentUser, UserCompany, CompanyAccessRequest, CompanyAccessResponse,
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,
security_required, security_optional
)
from .middleware import default_rate_limiter, RateLimiter
logger = logging.getLogger(__name__)
def create_auth_router(
prefix: str = "/auth",
tags: Optional[List[str]] = None,
include_admin_routes: bool = False
) -> APIRouter:
"""
Creează un router FastAPI cu toate rutele de autentificare
Args:
prefix: Prefix-ul pentru toate rutele
tags: Tag-urile pentru documentația OpenAPI
include_admin_routes: Dacă să includă rutele de administrare
Returns:
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,
request: Request,
response: Response
) -> 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, 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 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"
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.server_id
)
if not success:
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"
)
# 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}{server_info}")
return token_response
except HTTPException:
# Re-raise HTTP exceptions as-is (e.g., 401 for invalid credentials)
raise
except AuthenticationError as e:
logger.error(f"Authentication error for user {login_data.username}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e)
)
except Exception as e:
logger.error(f"Unexpected error during login for user {login_data.username}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal authentication error"
)
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
async def refresh_token(refresh_data: RefreshTokenRequest) -> TokenResponse:
"""
Reîmprospătează access token-ul folosind refresh token-ul
Args:
refresh_data: Refresh token-ul valid
Returns:
Noul access token și informațiile utilizatorului
Raises:
HTTPException: Pentru refresh token-uri invalide
"""
try:
# Validează refresh token-ul
token_data = jwt_handler.verify_token(refresh_data.refresh_token)
if not token_data or token_data.token_type != "refresh":
logger.warning("Invalid refresh token provided")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# Obține datele actualizate ale utilizatorului
companies = await auth_service.get_user_companies(token_data.username)
permissions = ["read", "reports"] # Poate fi extins în viitor
# Creează noul access token
new_access_token = jwt_handler.create_access_token(
username=token_data.username,
companies=companies,
user_id=token_data.user_id,
permissions=permissions
)
# Informațiile utilizatorului
current_user = CurrentUser(
username=token_data.username,
user_id=token_data.user_id,
companies=companies,
permissions=permissions
)
token_response = TokenResponse(
access_token=new_access_token,
token_type="bearer",
expires_in=jwt_handler.access_token_expire_minutes * 60,
user=current_user
)
logger.info(f"Token refreshed for user {token_data.username}")
return token_response
except Exception as e:
logger.error(f"Error refreshing token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token refresh failed"
)
@router.post("/logout", status_code=status.HTTP_200_OK)
async def logout(
logout_data: Optional[LogoutRequest] = None,
current_user: CurrentUser = Depends(get_current_user)
) -> dict:
"""
Deconectează utilizatorul (invalidează token-urile)
Note: În implementarea curentă, token-urile JWT sunt stateless,
deci nu pot fi invalidate direct. În viitor poate fi implementat
un blacklist pentru token-uri.
Args:
logout_data: Date pentru logout (opțional)
current_user: Utilizatorul curent autentificat
Returns:
Confirmarea deconectării
"""
logger.info(f"User {current_user.username} logged out")
# În viitor, aici se poate implementa:
# - Adăugarea token-ului într-un blacklist
# - Invalidarea tuturor sesiunilor utilizatorului
# - Notificări de securitate
return {
"message": "Successfully logged out",
"username": current_user.username,
"logout_time": datetime.now().isoformat()
}
@router.get("/me", response_model=CurrentUser)
async def get_current_user_info(
current_user: CurrentUser = Depends(get_current_user)
) -> CurrentUser:
"""
Returnează informațiile despre utilizatorul curent
Args:
current_user: Utilizatorul curent autentificat
Returns:
Informațiile complete ale utilizatorului
"""
logger.debug(f"User info requested for {current_user.username}")
return current_user
@router.get("/companies", response_model=List[UserCompany])
async def get_user_companies(
current_user: CurrentUser = Depends(get_current_user)
) -> List[UserCompany]:
"""
Returnează lista firmelor la care utilizatorul are acces
Args:
current_user: Utilizatorul curent autentificat
Returns:
Lista firmelor cu permisiunile asociate
"""
try:
# Obține firmele actualizate din baza de date
companies = await auth_service.get_user_companies(current_user.username)
user_companies = []
for i, company_code in enumerate(companies):
# Obține permisiunile pentru fiecare firmă
permissions = await auth_service.get_user_permissions(
current_user.username,
company_code
)
user_company = UserCompany(
code=company_code,
permissions=permissions,
is_default=(i == 0) # Prima firmă ca default
)
user_companies.append(user_company)
logger.debug(f"Returned {len(user_companies)} companies for user {current_user.username}")
return user_companies
except Exception as e:
logger.error(f"Error getting companies for user {current_user.username}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error retrieving user companies"
)
@router.post("/check-company-access", response_model=CompanyAccessResponse)
async def check_company_access(
access_request: CompanyAccessRequest,
current_user: CurrentUser = Depends(get_current_user)
) -> CompanyAccessResponse:
"""
Verifică dacă utilizatorul are acces la o firmă specifică
Args:
access_request: Request-ul de verificare acces
current_user: Utilizatorul curent autentificat
Returns:
Răspunsul cu informații despre acces
"""
try:
has_access = await auth_service.validate_user_company_access(
current_user.username,
access_request.company_code
)
if not has_access:
return CompanyAccessResponse(
has_access=False,
company=None,
missing_permissions=None
)
# Obține permisiunile pentru firmă
permissions = await auth_service.get_user_permissions(
current_user.username,
access_request.company_code
)
# Verifică permisiunile cerute
missing_permissions = []
if access_request.required_permissions:
missing_permissions = [
perm for perm in access_request.required_permissions
if perm not in permissions
]
user_company = UserCompany(
code=access_request.company_code,
permissions=permissions
)
return CompanyAccessResponse(
has_access=True,
company=user_company,
missing_permissions=missing_permissions if missing_permissions else None
)
except Exception as e:
logger.error(f"Error checking company access: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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)
) -> dict:
"""
Returnează statusul de autentificare (endpoint public)
Args:
current_user: Utilizatorul curent (opțional)
Returns:
Statusul de autentificare
"""
if current_user:
return {
"authenticated": True,
"username": current_user.username,
"companies_count": len(current_user.companies),
"permissions": current_user.permissions
}
else:
return {
"authenticated": False,
"username": None,
"companies_count": 0,
"permissions": []
}
# Rute de administrare (opționale)
if include_admin_routes:
@router.get("/admin/stats", response_model=AuthStats)
async def get_auth_stats(
current_user: CurrentUser = Depends(get_current_user)
) -> AuthStats:
"""
Returnează statistici despre sistemul de autentificare
Necesită permisiuni de admin.
"""
# Verifică permisiuni admin
if "admin" not in current_user.permissions:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin permissions required"
)
cache_stats = auth_service.get_cache_stats()
return AuthStats(
total_users=1, # Placeholder - poate fi implementat
active_sessions=1, # Placeholder - poate fi implementat
cache_hit_ratio=cache_stats.get('cache_hit_ratio', 0),
last_cleanup=datetime.now()
)
@router.post("/admin/refresh-cache")
async def refresh_user_cache(
username: Optional[str] = None,
current_user: CurrentUser = Depends(get_current_user)
) -> dict:
"""
Reîmprospătează cache-ul utilizatorilor
Necesită permisiuni de admin.
"""
if "admin" not in current_user.permissions:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin permissions required"
)
if username:
success = await auth_service.refresh_user_data(username)
return {
"message": f"Cache refreshed for user {username}",
"success": success
}
else:
auth_service.clear_cache()
return {"message": "All user cache cleared"}
return router
# Router implicit pentru folosire rapidă
auth_router = create_auth_router()
# Router cu rute de admin incluse
auth_router_with_admin = create_auth_router(include_admin_routes=True)

View File

@@ -0,0 +1,559 @@
"""
Comprehensive Authentication Tests pentru ROA2WEB
Acest modul conține teste pentru toate componentele sistemului de autentificare:
- JWT Handler functionality
- Oracle authentication service
- FastAPI dependencies și middleware
- Rate limiting și security features
Testele acoperă:
- Unit tests pentru funcționalitatea de bază
- Integration tests cu Oracle database (mock)
- Security tests pentru vulnerabilități comune
- Performance tests pentru scalabilitate
"""
import pytest
import asyncio
import os
import time
from datetime import datetime, timedelta
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from typing import List, Dict, Any, Optional
import jwt as pyjwt
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from httpx import AsyncClient
# Import modulele de testat
from .jwt_handler import JWTHandler, TokenData, TokenResponse
from .auth_service import UserAuthService, AuthenticationError
from .models import (
LoginRequest, CurrentUser, PermissionType,
CompanyAccessRequest, CompanyAccessResponse
)
from .middleware import AuthenticationMiddleware, RateLimiter
from .dependencies import (
get_current_user_from_token, require_company_access,
require_permissions, get_current_company_from_header
)
from .routes import create_auth_router
class TestJWTHandler:
"""Test suite pentru JWT Handler"""
@pytest.fixture
def jwt_handler(self):
"""Fixture pentru JWT handler cu configurare de test"""
return JWTHandler(
secret_key="test-secret-key",
algorithm="HS256"
)
def test_create_access_token(self, jwt_handler):
"""Test pentru crearea access token-urilor"""
username = "testuser"
companies = ["COMP1", "COMP2"]
permissions = ["read", "write"]
token = jwt_handler.create_access_token(
username=username,
companies=companies,
user_id=123,
permissions=permissions
)
assert isinstance(token, str)
assert len(token) > 0
# Verifică că token-ul poate fi decodat
payload = pyjwt.decode(token, "test-secret-key", algorithms=["HS256"])
assert payload["username"] == username
assert payload["companies"] == companies
assert payload["permissions"] == permissions
assert payload["user_id"] == 123
assert payload["type"] == "access"
def test_create_refresh_token(self, jwt_handler):
"""Test pentru crearea refresh token-urilor"""
username = "testuser"
user_id = 123
token = jwt_handler.create_refresh_token(username, user_id)
assert isinstance(token, str)
assert len(token) > 0
# Verifică payload-ul
payload = pyjwt.decode(token, "test-secret-key", algorithms=["HS256"])
assert payload["username"] == username
assert payload["user_id"] == user_id
assert payload["type"] == "refresh"
def test_verify_valid_token(self, jwt_handler):
"""Test pentru verificarea token-urilor valide"""
username = "testuser"
companies = ["COMP1"]
token = jwt_handler.create_access_token(username, companies)
token_data = jwt_handler.verify_token(token)
assert token_data is not None
assert isinstance(token_data, TokenData)
assert token_data.username == username
assert token_data.companies == companies
assert token_data.token_type == "access"
def test_verify_expired_token(self, jwt_handler):
"""Test pentru token-uri expirate"""
# Creează token cu expirare în trecut
past_time = datetime.utcnow() - timedelta(minutes=10)
payload = {
"username": "testuser",
"companies": ["COMP1"],
"permissions": ["read"],
"exp": past_time,
"iat": past_time - timedelta(minutes=5),
"type": "access"
}
expired_token = pyjwt.encode(payload, "test-secret-key", algorithm="HS256")
token_data = jwt_handler.verify_token(expired_token)
assert token_data is None
def test_verify_invalid_token(self, jwt_handler):
"""Test pentru token-uri invalide"""
invalid_tokens = [
"invalid.token.here",
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.invalid",
"",
None
]
for invalid_token in invalid_tokens:
if invalid_token is not None:
token_data = jwt_handler.verify_token(invalid_token)
assert token_data is None
def test_create_token_response(self, jwt_handler):
"""Test pentru crearea răspunsului complet cu token-uri"""
username = "testuser"
companies = ["COMP1", "COMP2"]
permissions = ["read", "reports"]
response = jwt_handler.create_token_response(
username=username,
companies=companies,
permissions=permissions,
include_refresh=True
)
assert isinstance(response, TokenResponse)
assert response.access_token is not None
assert response.refresh_token is not None
assert response.token_type == "bearer"
assert response.expires_in > 0
def test_refresh_access_token(self, jwt_handler):
"""Test pentru refresh-ul access token-urilor"""
username = "testuser"
refresh_token = jwt_handler.create_refresh_token(username, 123)
companies = ["COMP1", "COMP2"]
new_access_token = jwt_handler.refresh_access_token(
refresh_token, companies, ["read", "write"]
)
assert new_access_token is not None
# Verifică noul token
token_data = jwt_handler.verify_token(new_access_token)
assert token_data.username == username
assert token_data.companies == companies
assert token_data.token_type == "access"
class TestUserAuthService:
"""Test suite pentru User Authentication Service"""
@pytest.fixture
def auth_service(self):
"""Fixture pentru auth service cu mock database"""
return UserAuthService()
@pytest.fixture
def mock_oracle_pool(self):
"""Mock pentru Oracle connection pool"""
with patch('roa2web.shared.auth.auth_service.oracle_pool') as mock_pool:
yield mock_pool
@pytest.mark.asyncio
async def test_verify_user_credentials_success(self, auth_service, mock_oracle_pool):
"""Test pentru verificarea cu succes a credențialelor"""
# Mock pentru conexiunea Oracle
mock_connection = AsyncMock()
mock_cursor = MagicMock()
mock_cursor.fetchone.return_value = [1] # Success
mock_connection.__aenter__.return_value = mock_connection
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mock_oracle_pool.get_connection.return_value = mock_connection
result = await auth_service.verify_user_credentials("testuser", "password")
assert result is True
mock_cursor.execute.assert_called_once()
@pytest.mark.asyncio
async def test_verify_user_credentials_failure(self, auth_service, mock_oracle_pool):
"""Test pentru verificarea eșuată a credențialelor"""
# Mock pentru conexiunea Oracle
mock_connection = AsyncMock()
mock_cursor = MagicMock()
mock_cursor.fetchone.return_value = [0] # Failure
mock_connection.__aenter__.return_value = mock_connection
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mock_oracle_pool.get_connection.return_value = mock_connection
result = await auth_service.verify_user_credentials("testuser", "wrongpassword")
assert result is False
@pytest.mark.asyncio
async def test_get_user_companies(self, auth_service, mock_oracle_pool):
"""Test pentru obținerea firmelor utilizatorului"""
# Mock pentru conexiunea Oracle
mock_connection = AsyncMock()
mock_cursor = MagicMock()
mock_cursor.fetchall.return_value = [["COMP1"], ["COMP2"], ["COMP3"]]
mock_connection.__aenter__.return_value = mock_connection
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mock_oracle_pool.get_connection.return_value = mock_connection
companies = await auth_service.get_user_companies("testuser")
assert companies == ["COMP1", "COMP2", "COMP3"]
mock_cursor.execute.assert_called_once()
@pytest.mark.asyncio
async def test_authenticate_and_create_tokens_success(self, auth_service, mock_oracle_pool):
"""Test pentru autentificare completă cu succes"""
# Mock pentru conexiunea Oracle
mock_connection = AsyncMock()
mock_cursor = MagicMock()
# Prima chiamată pentru verificare credențiale (succes)
# A doua chiamată pentru obținerea firmelor
mock_cursor.fetchone.return_value = [1]
mock_cursor.fetchall.return_value = [["COMP1"], ["COMP2"]]
mock_connection.__aenter__.return_value = mock_connection
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mock_oracle_pool.get_connection.return_value = mock_connection
success, token_response, error = await auth_service.authenticate_and_create_tokens(
"testuser", "password"
)
assert success is True
assert token_response is not None
assert isinstance(token_response, TokenResponse)
assert error is None
@pytest.mark.asyncio
async def test_authenticate_and_create_tokens_failure(self, auth_service, mock_oracle_pool):
"""Test pentru autentificare eșuată"""
# Mock pentru conexiunea Oracle
mock_connection = AsyncMock()
mock_cursor = MagicMock()
mock_cursor.fetchone.return_value = [0] # Credențiale invalide
mock_connection.__aenter__.return_value = mock_connection
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mock_oracle_pool.get_connection.return_value = mock_connection
success, token_response, error = await auth_service.authenticate_and_create_tokens(
"testuser", "wrongpassword"
)
assert success is False
assert token_response is None
assert error is not None
@pytest.mark.asyncio
async def test_validate_user_company_access(self, auth_service, mock_oracle_pool):
"""Test pentru validarea accesului la firmă"""
# Mock pentru conexiunea Oracle
mock_connection = AsyncMock()
mock_cursor = MagicMock()
mock_cursor.fetchall.return_value = [["COMP1"], ["COMP2"]]
mock_connection.__aenter__.return_value = mock_connection
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mock_oracle_pool.get_connection.return_value = mock_connection
# Test acces valid
has_access = await auth_service.validate_user_company_access("testuser", "COMP1")
assert has_access is True
# Test acces invalid
has_access = await auth_service.validate_user_company_access("testuser", "COMP3")
assert has_access is False
def test_cache_functionality(self, auth_service):
"""Test pentru funcționalitatea de cache"""
# Test cache stats
stats = auth_service.get_cache_stats()
assert isinstance(stats, dict)
assert 'total_entries' in stats
assert 'valid_entries' in stats
assert 'cache_hit_ratio' in stats
# Test clear cache
auth_service.clear_cache()
stats_after_clear = auth_service.get_cache_stats()
assert stats_after_clear['total_entries'] == 0
class TestRateLimiter:
"""Test suite pentru Rate Limiter"""
@pytest.fixture
def rate_limiter(self):
"""Fixture pentru rate limiter"""
return RateLimiter(max_requests=3, time_window=5)
def test_rate_limiting_within_limit(self, rate_limiter):
"""Test pentru request-uri în limita permisă"""
client_ip = "192.168.1.1"
# Primele 3 request-uri trebuie să fie permise
for i in range(3):
assert rate_limiter.is_allowed(client_ip) is True
# Al 4-lea request trebuie refuzat
assert rate_limiter.is_allowed(client_ip) is False
def test_rate_limiting_reset_after_time(self, rate_limiter):
"""Test pentru resetarea rate limiting după expirarea ferestrei"""
client_ip = "192.168.1.2"
# Consumă toate request-urile
for i in range(3):
assert rate_limiter.is_allowed(client_ip) is True
# Request-ul următor trebuie refuzat
assert rate_limiter.is_allowed(client_ip) is False
# Simulează trecerea timpului
time.sleep(6)
# Acum ar trebui să funcționeze din nou
assert rate_limiter.is_allowed(client_ip) is True
def test_rate_limiting_different_ips(self, rate_limiter):
"""Test pentru rate limiting pe IP-uri diferite"""
ip1 = "192.168.1.1"
ip2 = "192.168.1.2"
# Consumă toate request-urile pentru primul IP
for i in range(3):
assert rate_limiter.is_allowed(ip1) is True
assert rate_limiter.is_allowed(ip1) is False
# Al doilea IP ar trebui să funcționeze normal
for i in range(3):
assert rate_limiter.is_allowed(ip2) is True
class TestAuthenticationRoutes:
"""Test suite pentru rutele de autentificare"""
@pytest.fixture
def app(self):
"""Fixture pentru aplicația FastAPI de test"""
app = FastAPI()
auth_router = create_auth_router()
app.include_router(auth_router)
return app
@pytest.fixture
def client(self, app):
"""Fixture pentru client de test"""
return TestClient(app)
@pytest.fixture
def mock_auth_service(self):
"""Mock pentru auth service"""
with patch('roa2web.shared.auth.routes.auth_service') as mock_service:
yield mock_service
def test_login_success(self, client, mock_auth_service):
"""Test pentru login cu succes"""
# Mock pentru autentificare cu succes
mock_token_response = TokenResponse(
access_token="test-access-token",
refresh_token="test-refresh-token",
token_type="bearer",
expires_in=1800,
user=CurrentUser(
username="testuser",
companies=["COMP1", "COMP2"],
permissions=[PermissionType.READ, PermissionType.REPORTS]
)
)
mock_auth_service.authenticate_and_create_tokens.return_value = (
True, mock_token_response, None
)
mock_auth_service.get_user_companies.return_value = ["COMP1", "COMP2"]
response = client.post("/auth/login", json={
"username": "testuser",
"password": "password"
})
assert response.status_code == 200
data = response.json()
assert data["access_token"] == "test-access-token"
assert data["token_type"] == "bearer"
assert data["user"]["username"] == "testuser"
def test_login_failure(self, client, mock_auth_service):
"""Test pentru login eșuat"""
mock_auth_service.authenticate_and_create_tokens.return_value = (
False, None, "Invalid credentials"
)
response = client.post("/auth/login", json={
"username": "testuser",
"password": "wrongpassword"
})
assert response.status_code == 401
assert "Invalid credentials" in response.json()["detail"]
def test_protected_endpoint_without_token(self, client):
"""Test pentru endpoint protejat fără token"""
response = client.get("/auth/me")
assert response.status_code == 401
def test_protected_endpoint_with_valid_token(self, client):
"""Test pentru endpoint protejat cu token valid"""
# Creează un token de test
jwt_handler = JWTHandler(secret_key="test-secret-key")
token = jwt_handler.create_access_token(
username="testuser",
companies=["COMP1"],
permissions=["read"]
)
with patch('roa2web.shared.auth.dependencies.jwt_handler', jwt_handler):
response = client.get("/auth/me", headers={
"Authorization": f"Bearer {token}"
})
assert response.status_code == 200
data = response.json()
assert data["username"] == "testuser"
class TestSecurityFeatures:
"""Test suite pentru funcții de securitate"""
def test_jwt_token_tampering(self):
"""Test pentru detectarea modificării token-urilor JWT"""
jwt_handler = JWTHandler(secret_key="test-secret-key")
token = jwt_handler.create_access_token("testuser", ["COMP1"])
# Modifică token-ul
tampered_token = token[:-5] + "XXXXX"
# Token-ul modificat trebuie să fie invalid
token_data = jwt_handler.verify_token(tampered_token)
assert token_data is None
def test_jwt_secret_key_different(self):
"""Test pentru token-uri semnate cu chei diferite"""
jwt_handler1 = JWTHandler(secret_key="secret1")
jwt_handler2 = JWTHandler(secret_key="secret2")
token = jwt_handler1.create_access_token("testuser", ["COMP1"])
# Token-ul nu trebuie să fie valid cu o cheie diferită
token_data = jwt_handler2.verify_token(token)
assert token_data is None
@pytest.mark.asyncio
async def test_sql_injection_prevention(self):
"""Test pentru prevenirea SQL injection"""
auth_service = UserAuthService()
with patch('roa2web.shared.auth.auth_service.oracle_pool') as mock_pool:
mock_connection = AsyncMock()
mock_cursor = MagicMock()
mock_connection.__aenter__.return_value = mock_connection
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mock_oracle_pool.get_connection.return_value = mock_connection
# Încearcă SQL injection în username
malicious_username = "admin'; DROP TABLE users; --"
await auth_service.verify_user_credentials(malicious_username, "password")
# Verifică că query-ul folosește parametri legați
mock_cursor.execute.assert_called_once()
call_args = mock_cursor.execute.call_args
assert ':username' in call_args[0][0] # Query cu parametri
assert malicious_username.upper() == call_args[1]['username'] # Parametri legați
@pytest.mark.performance
class TestPerformance:
"""Test suite pentru performanță"""
def test_jwt_token_creation_performance(self):
"""Test pentru performanța creării token-urilor"""
jwt_handler = JWTHandler(secret_key="test-secret-key")
start_time = time.time()
# Creează 1000 de token-uri
for i in range(1000):
jwt_handler.create_access_token(f"user{i}", ["COMP1"])
end_time = time.time()
total_time = end_time - start_time
# Ar trebui să dureze mai puțin de 1 secundă
assert total_time < 1.0
print(f"Created 1000 tokens in {total_time:.4f} seconds")
def test_jwt_token_verification_performance(self):
"""Test pentru performanța verificării token-urilor"""
jwt_handler = JWTHandler(secret_key="test-secret-key")
# Creează 100 de token-uri
tokens = []
for i in range(100):
token = jwt_handler.create_access_token(f"user{i}", ["COMP1"])
tokens.append(token)
start_time = time.time()
# Verifică toate token-urile
for token in tokens:
jwt_handler.verify_token(token)
end_time = time.time()
total_time = end_time - start_time
# Ar trebui să dureze mai puțin de 0.5 secunde
assert total_time < 0.5
print(f"Verified 100 tokens in {total_time:.4f} seconds")
if __name__ == "__main__":
# Rulează testele
pytest.main([__file__, "-v", "--tb=short"])

View File

@@ -0,0 +1,124 @@
# ROA2WEB Shared Database Pool
Sistem de pool de conexiuni Oracle partajat între toate microserviciile ROA2WEB.
## Componente
### 📦 oracle_pool.py
Clasa singleton `OraclePool` pentru gestionarea pool-ului de conexiuni Oracle.
### 📋 models.py
Modele Pydantic comune:
- `User` - Model pentru utilizatori
- `Company` - Model pentru firme/scheme Oracle
- `DatabaseConfig` - Configurare conexiune database
### ⚙️ config.py (în utils/)
Configurări partajate prin environment variables.
### ❌ exceptions.py (în utils/)
Exception handlers personalizate pentru ROA2WEB.
## Utilizare
### Inițializare în aplicații FastAPI
```python
from contextlib import asynccontextmanager
from fastapi import FastAPI
import sys
import os
# Import shared pool
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../shared'))
from database.oracle_pool import oracle_pool
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup - inițializare pool
await oracle_pool.initialize()
print("📊 Oracle pool initialized")
yield
# Shutdown - închidere pool
await oracle_pool.close_pool()
print("📊 Oracle pool closed")
app = FastAPI(lifespan=lifespan)
```
### Utilizare conexiune în endpoint-uri
```python
from fastapi import APIRouter, HTTPException
from database.oracle_pool import oracle_pool
router = APIRouter()
@router.get("/companies")
async def get_companies():
try:
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT schema, firma FROM vdef_util_grup WHERE id_firma <> 0")
results = cursor.fetchall()
companies = []
for row in results:
companies.append({
"code": row[0],
"name": row[1]
})
return companies
except Exception as e:
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
```
### Configurare Environment Variables
```bash
# Oracle Database
ORACLE_USER=your_oracle_username
ORACLE_PASSWORD=your_oracle_password
ORACLE_DSN=your_oracle_dsn
# Pool Settings
DB_MIN_CONNECTIONS=2
DB_MAX_CONNECTIONS=10
DB_CONNECTION_INCREMENT=1
# JWT (pentru autentificare)
JWT_SECRET_KEY=your-super-secret-key
ACCESS_TOKEN_EXPIRE_MINUTES=30
```
## Testare
Pentru a testa pool-ul de conexiuni:
```bash
cd roa2web/shared/database
python test_pool.py
```
**Notă**: Testul necesită configurarea variabilelor de environment pentru Oracle.
## Caracteristici
**Singleton Pattern** - O singură instanță de pool pentru toată aplicația
**Async Context Manager** - Gestionare automată a conexiunilor
**Connection Pooling** - Performanță optimizată prin reutilizarea conexiunilor
**Configurabil** - Setări flexibile prin environment variables
**Logging** - Urmărirea operațiilor de pool
**Error Handling** - Excepții personalizate pentru debugging
## Următorii Pași
👉 **ZIUA 3**: Implementarea sistemului JWT partajat (`shared/auth/`)
---
*Documentație generată pentru ROA2WEB Shared Database Pool - ZIUA 2* 🚀

View File

@@ -0,0 +1,30 @@
"""
Modele comune pentru toate aplicațiile ROA2WEB
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict
from datetime import datetime
class Company(BaseModel):
"""Model pentru firma/schema Oracle"""
code: str = Field(description="Codul firmei (schema Oracle)")
name: str = Field(description="Numele firmei")
fiscal_code: Optional[str] = Field(description="Codul fiscal")
is_active: bool = Field(default=True, description="Firma activă")
class User(BaseModel):
"""Model pentru utilizator"""
username: str = Field(description="Numele utilizatorului")
email: Optional[str] = Field(description="Email utilizator")
companies: List[str] = Field(description="Lista codurilor firmelor la care are acces")
is_active: bool = Field(default=True, description="Utilizator activ")
last_login: Optional[datetime] = Field(description="Ultima autentificare")
class DatabaseConfig(BaseModel):
"""Configurare conexiune bază de date"""
user: str
password: str
dsn: str
min_connections: int = 2
max_connections: int = 10
increment: int = 1

View File

@@ -0,0 +1,276 @@
"""
Oracle Database Connection Pool - Multi-Server Support for ROA2WEB
Uses ORACLE_SERVERS from .env for server configuration.
Pool-uri sunt create lazy (la prima conexiune pe fiecare server) pentru optimizare.
"""
import asyncio
import oracledb
from contextlib import asynccontextmanager
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
class OracleMultiPool:
"""
Multi-tenant Oracle connection pool manager.
Supports:
- Multiple Oracle servers with separate pools: {server_id: pool}
- Lazy pool creation (created on first connection)
- First registered server used when no server_id specified
- Graceful shutdown of all pools
"""
_instance: Optional['OracleMultiPool'] = None
_pools: Dict[str, oracledb.ConnectionPool]
_pool_configs: Dict[str, Dict[str, Any]]
_pool_lock: asyncio.Lock
_initialized: bool
def __new__(cls):
if cls._instance is None:
cls._instance = super(OracleMultiPool, cls).__new__(cls)
cls._instance._pools = {}
cls._instance._pool_configs = {}
cls._instance._pool_lock = asyncio.Lock()
cls._instance._initialized = False
return cls._instance
async def initialize(self):
"""
Initialize pool manager.
Call this after registering servers with register_server().
Pools are created lazily on first connection.
"""
if self._initialized:
logger.debug("Pool manager already initialized")
return
self._initialized = True
logger.info("Oracle pool manager initialized")
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
def _get_first_server_id(self) -> str:
"""Get the first registered server ID."""
if not self._pool_configs:
raise RuntimeError("No servers registered. Call register_server() first.")
return next(iter(self._pool_configs))
@asynccontextmanager
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 primul server înregistrat.
Usage:
# Explicit server
async with oracle_pool.get_connection('romfast') as conn:
...
# First registered server (when only one server configured)
async with oracle_pool.get_connection() as conn:
...
"""
connection = None
pool = None
try:
if server_id is None:
# Use first registered server
server_id = self._get_first_server_id()
logger.debug(f"No server_id specified, using first registered: '{server_id}'")
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(f"Connection returned to pool (server_id={server_id})")
async def execute_query(self, query: str, parameters=None, server_id: Optional[str] = None):
"""
Execute a SQL query and return all results.
Args:
query: SQL query string
parameters: Query parameters (dict or tuple)
server_id: Server ID (optional, uses first server if not specified)
"""
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()
else:
# For DML statements, return affected row count
connection.commit()
return cursor.rowcount
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)
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:
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 alias
OraclePool = OracleMultiPool
# Global instance
oracle_pool = OracleMultiPool()

View File

@@ -0,0 +1,69 @@
"""
Test script pentru verificarea conexiunii Oracle pool
"""
import asyncio
import sys
import os
from datetime import datetime
# Load environment variables
try:
from dotenv import load_dotenv
# Load .env from roa2web root directory
env_path = os.path.join(os.path.dirname(__file__), '../../.env')
load_dotenv(env_path)
print(f"📄 Loaded environment from: {env_path}")
except ImportError:
print("⚠️ python-dotenv not available, using system environment variables")
# Adăugare path pentru shared modules
sys.path.append(os.path.dirname(__file__))
from oracle_pool import oracle_pool
async def test_oracle_pool():
"""Test simplu pentru verificarea pool-ului Oracle"""
print("🔄 Testing Oracle connection pool...")
try:
# Inițializare pool
print("📊 Initializing Oracle pool...")
await oracle_pool.initialize()
print("✅ Pool initialized successfully")
# Test conexiune
print("🔍 Testing database connection...")
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
result = cursor.fetchone()
print(f"✅ Database connection test successful: {result}")
print("🎯 Testing connection pool info...")
if oracle_pool._pool:
print(f"📈 Pool connections opened: {oracle_pool._pool.opened}")
print(f"📊 Pool connections busy: {oracle_pool._pool.busy}")
# Cleanup
print("🧹 Closing pool...")
await oracle_pool.close_pool()
print("✅ Pool closed successfully")
print("\n🎉 All tests passed! Oracle pool is working correctly.")
except Exception as e:
print(f"❌ Error testing Oracle pool: {str(e)}")
print(f"💡 Make sure environment variables are set:")
print(f" - ORACLE_USER: {'✅ SET' if os.getenv('ORACLE_USER') else '❌ NOT SET'}")
print(f" - ORACLE_PASSWORD: {'✅ SET' if os.getenv('ORACLE_PASSWORD') else '❌ NOT SET'}")
print(f" - ORACLE_DSN: {'✅ SET' if os.getenv('ORACLE_DSN') else '❌ NOT SET'}")
return False
return True
if __name__ == "__main__":
print(f"🚀 ROA2WEB Oracle Pool Test - {datetime.now()}")
print("=" * 50)
success = asyncio.run(test_oracle_pool())
sys.exit(0 if success else 1)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,910 @@
# Plan Implementare Redis Caching - ROA2WEB
**Data Creare:** 2025-01-25
**Status:** DRAFT - Ready for Implementation
**Durata Estimată:** 2-3 ore
---
## 📋 Sumar Executiv
- **Obiectiv:** Implementare Redis caching layer pentru reducerea query-urilor repetitive la Oracle DB
- **Target:** 60-80% reducere în numărul de query-uri pentru date frecvent accesate
- **Strategie:** Caching simplu la nivel de Service layer cu invalidation manuală
- **Backward Compatibility:** ✅ Aplicația funcționează fără Redis (graceful degradation)
- **Multi-Tenant Ready:** ✅ Cache keys pregătite pentru viitoare multi-tenancy
**Infrastructure Status:**
- ✅ Redis container configurat în `docker-compose.yml` (lines 147-163)
- ✅ Backend `depends_on: roa-redis`
- ❌ Redis client library LIPSĂ
- ❌ Cod de caching LIPSĂ
---
## 🗂️ Structura Fișierelor
### Fișiere Noi (6 fișiere)
- [ ] `shared/cache/__init__.py` - Package initialization
- [ ] `shared/cache/redis_client.py` - Redis connection client (90 lines)
- [ ] `shared/cache/decorators.py` - Cache decorators (60 lines)
- [ ] `shared/cache/utils.py` - Cache key generators, helpers (40 lines)
- [ ] `shared/tests/test_redis_client.py` - Unit tests pentru Redis client (120 lines)
- [ ] `shared/tests/test_cache_decorators.py` - Unit tests pentru decorators (80 lines)
### Fișiere Modificate (7 fișiere)
- [ ] `backend/requirements.txt` - Adaugă redis>=5.0.0
- [ ] `backend/.env.example` - Adaugă REDIS_* env vars
- [ ] `backend/app/main.py` - Initialize Redis at startup
- [ ] `backend/app/services/dashboard_service.py` - Apply caching
- [ ] `backend/app/services/invoice_service.py` - Apply caching
- [ ] `backend/app/routers/dashboard.py` - Cache invalidation on mutations
- [ ] `backend/app/routers/invoices.py` - Cache invalidation on mutations
---
## 🚀 Faze de Implementare
### FAZA 1: Setup Redis Client și Connection (30 min)
**Obiectiv:** Creează Redis client singleton cu connection pooling, similar cu `OraclePool` pattern.
**Tasks:**
1. [ ] **Adaugă dependency în requirements.txt**
- Fișier: `backend/requirements.txt`
- Acțiune: Adaugă linia `redis>=5.0.0` după `httpx>=0.27.0`
- Motivație: redis 5.0+ are async support nativ
2. [ ] **Creează package cache în shared/**
- Fișiere: `shared/cache/__init__.py`
- Acțiune: Creează directorul și fișierul de init cu exports:
```python
from .redis_client import redis_cache
from .decorators import cached, invalidate_cache
__all__ = ['redis_cache', 'cached', 'invalidate_cache']
```
3. [ ] **Implementează RedisCache singleton client**
- Fișier: `shared/cache/redis_client.py`
- Acțiune: Creează clasa `RedisCache` similar cu `OraclePool`:
- Singleton pattern (nu multi-instance)
- Async redis client cu connection pooling
- Methods: `initialize()`, `get()`, `set()`, `delete()`, `delete_pattern()`, `close()`
- Graceful degradation: dacă Redis e down, log warning și return None
- Connection retry cu exponential backoff
- Template (vezi secțiunea Code Templates mai jos)
4. [ ] **Adaugă env variables în .env.example**
- Fișier: `backend/.env.example`
- Acțiune: Adaugă la sfârșitul fișierului:
```bash
# Redis Configuration
REDIS_URL=redis://roa-redis:6379/0
REDIS_PASSWORD=roa2web_redis_password
REDIS_ENABLED=true
CACHE_DEFAULT_TTL=300
```
5. [ ] **Initialize Redis la startup în main.py**
- Fișier: `backend/app/main.py`
- Acțiune: În funcția `startup_event()`:
```python
from shared.cache import redis_cache
@app.on_event("startup")
async def startup_event():
# ... existing oracle pool init ...
# Initialize Redis cache
if os.getenv('REDIS_ENABLED', 'false').lower() == 'true':
await redis_cache.initialize(
url=os.getenv('REDIS_URL'),
password=os.getenv('REDIS_PASSWORD')
)
@app.on_event("shutdown")
async def shutdown_event():
# ... existing oracle pool close ...
await redis_cache.close()
```
**Output Verificabil:**
- [ ] `pip install -r requirements.txt` rulează fără erori
- [ ] Redis client se conectează cu succes la container
- [ ] Test manual: `python -c "import asyncio; from shared.cache import redis_cache; asyncio.run(redis_cache.initialize())"`
- [ ] Log message: "✅ Redis cache initialized successfully"
---
### FAZA 2: Cache Decorator și Helpers (30 min)
**Obiectiv:** Creează decorator `@cached()` pentru aplicare ușoară în Services.
**Tasks:**
1. [ ] **Implementează cache key generator**
- Fișier: `shared/cache/utils.py`
- Acțiune: Funcții helper pentru key generation:
```python
import hashlib
import json
from typing import Any, Dict
def make_cache_key(tenant_id: str, resource: str, **params) -> str:
"""
Generate tenant-aware cache key
Format: cache:{tenant_id}:{resource}:{params_hash}
"""
params_str = json.dumps(params, sort_keys=True)
params_hash = hashlib.md5(params_str.encode()).hexdigest()[:12]
return f"cache:{tenant_id}:{resource}:{params_hash}"
def extract_tenant_id(kwargs: Dict[str, Any]) -> str:
"""
Extract tenant_id from function kwargs
For now returns 'default', later extract from JWT token
"""
# TODO: Extract from request.state.tenant_id when multi-tenant implemented
return kwargs.get('tenant_id', 'default')
```
2. [ ] **Implementează @cached decorator**
- Fișier: `shared/cache/decorators.py`
- Acțiune: Decorator pentru auto-caching de funcții async:
```python
from functools import wraps
from typing import Callable, Optional
import logging
from .redis_client import redis_cache
from .utils import make_cache_key, extract_tenant_id
logger = logging.getLogger(__name__)
def cached(resource: str, ttl: int = 300):
"""
Cache decorator pentru funcții async
Usage:
@cached(resource="dashboard_summary", ttl=300)
async def get_dashboard_summary(company: str, username: str):
# ... query Oracle ...
return data
Args:
resource: Resource name (e.g., 'dashboard_summary', 'invoices_list')
ttl: Time-to-live în secunde (default: 5 min)
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
# Skip cache dacă Redis e disabled
if not redis_cache.is_enabled():
return await func(*args, **kwargs)
# Extract tenant_id și params pentru cache key
tenant_id = extract_tenant_id(kwargs)
cache_params = {k: v for k, v in kwargs.items()
if k not in ['username', 'current_user']}
cache_key = make_cache_key(tenant_id, resource, **cache_params)
# Try cache GET
cached_value = await redis_cache.get(cache_key)
if cached_value is not None:
logger.debug(f"Cache HIT: {cache_key}")
return cached_value
# Cache MISS - execute function
logger.debug(f"Cache MISS: {cache_key}")
result = await func(*args, **kwargs)
# Save to cache
await redis_cache.set(cache_key, result, ttl=ttl)
return result
return wrapper
return decorator
```
3. [ ] **Implementează invalidate_cache helper**
- Fișier: `shared/cache/decorators.py` (same file)
- Acțiune: Helper function pentru manual invalidation:
```python
async def invalidate_cache(
tenant_id: str = "default",
resource: Optional[str] = None
):
"""
Invalidate cache entries
Examples:
await invalidate_cache(resource="dashboard_summary") # clear specific resource
await invalidate_cache() # clear all for default tenant
"""
if not redis_cache.is_enabled():
return
if resource:
pattern = f"cache:{tenant_id}:{resource}:*"
else:
pattern = f"cache:{tenant_id}:*"
await redis_cache.delete_pattern(pattern)
logger.info(f"Cache invalidated: {pattern}")
```
**Output Verificabil:**
- [ ] Decorator funcționează fără erori
- [ ] Cache key format: `cache:default:dashboard_summary:abc123`
- [ ] Test unit: `pytest shared/tests/test_cache_decorators.py -v`
---
### FAZA 3: Aplicare în Endpoint-uri (45 min)
**Obiectiv:** Aplică caching în Service layer pentru dashboard și invoices.
**Tasks:**
1. [ ] **Apply @cached în DashboardService.get_complete_summary()**
- Fișier: `backend/app/services/dashboard_service.py`
- Acțiune: Adaugă decorator la metoda `get_complete_summary`:
```python
from shared.cache import cached
class DashboardService:
@staticmethod
@cached(resource="dashboard_summary", ttl=300) # 5 min
async def get_complete_summary(company: str, username: str):
# ... existing implementation ...
```
- Motivație: Dashboard e accesat des, datele se schimbă rar
2. [ ] **Apply @cached în DashboardService.get_trends()**
- Fișier: `backend/app/services/dashboard_service.py`
- Acțiune: Similar, TTL=180 (3 min pentru trends)
- Cache key va include period: `cache:default:dashboard_trends:company-X:period-30d:abc123`
3. [ ] **Apply @cached în DashboardService.get_detailed_data()**
- Fișier: `backend/app/services/dashboard_service.py`
- Acțiune: TTL=60 (1 min pentru tabel detalii - se refreshează des)
- Cache key include page, page_size, search
4. [ ] **Apply @cached în InvoiceService.get_invoices()**
- Fișier: `backend/app/services/invoice_service.py`
- Acțiune: TTL=60 (1 min)
- Cache key include filter params (partner_type, date_from, date_to, etc.)
5. [ ] **Apply @cached în InvoiceService.get_invoice_summary()**
- Fișier: `backend/app/services/invoice_service.py`
- Acțiune: TTL=180 (3 min pentru summary)
6. [ ] **Cache invalidation în dashboard mutations (viitor)**
- Fișier: `backend/app/routers/dashboard.py`
- Acțiune: Pregătește cod pentru invalidation (de activat când există POST/PUT/DELETE):
```python
# TODO: Activate când implementăm mutations
# from shared.cache import invalidate_cache
#
# @router.post("/...")
# async def update_dashboard_data(...):
# # ... update logic ...
# await invalidate_cache(resource="dashboard_summary")
# await invalidate_cache(resource="dashboard_trends")
```
7. [ ] **Cache invalidation în invoice mutations**
- Fișier: `backend/app/routers/invoices.py`
- Acțiune: Când se implementează POST/PUT/DELETE pentru invoices, invalidează:
- `invoices_list`
- `invoices_summary`
- `dashboard_summary` (afectează dashboard)
**Output Verificabil:**
- [ ] Backend pornește fără erori
- [ ] First request: Cache MISS + Oracle query (măsoară timp: ~500-1000ms)
- [ ] Second request (same params): Cache HIT (măsoară timp: ~10-20ms)
- [ ] Cache hit rate > 80% după 100 requests repetitive
- [ ] Logs arată `Cache HIT/MISS` messages
---
### FAZA 4: Testing, Monitoring și Cleanup (45 min)
**Obiectiv:** Validare funcționare corectă, performance benchmarks, și documentare.
**Tasks:**
1. [ ] **Unit tests pentru RedisCache client**
- Fișier: `shared/tests/test_redis_client.py`
- Acțiune: Testează:
- Connection success/failure
- Get/Set/Delete operations
- Pattern matching delete
- Graceful degradation când Redis e down
- Run: `pytest shared/tests/test_redis_client.py -v`
2. [ ] **Unit tests pentru cache decorators**
- Fișier: `shared/tests/test_cache_decorators.py`
- Acțiune: Testează:
- Decorator aplică caching corect
- Cache key generation
- TTL respectat
- Invalidation funcționează
- Run: `pytest shared/tests/test_cache_decorators.py -v`
3. [ ] **Integration test în Docker**
- Acțiune: Pornește stack complet cu `docker-compose up`
- Verifică:
- Backend se conectează la Redis
- Cache funcționează end-to-end
- Logs arată cache hits/misses
4. [ ] **Performance benchmark**
- Tool: Apache Bench sau Python requests loop
- Test case: 100 requests la `/api/dashboard/summary?company=X`
- Măsoară:
- **Without cache** (REDIS_ENABLED=false):
- Avg response time: ~800ms
- Total time: ~80 seconds
- **With cache** (REDIS_ENABLED=true):
- First request: ~800ms (MISS)
- Next 99 requests: ~15ms (HIT)
- Total time: ~2 seconds
- **Improvement: 97.5%**
- Salvează results în `shared/docs/REDIS_PERFORMANCE_BENCHMARK.md`
5. [ ] **Manual testing checklist**
- [ ] Dashboard: Refresh multiple ori (verify cache HIT în logs)
- [ ] Invoices: Filtrare diferită (verify cache keys unice)
- [ ] Redis failure test: Stop Redis container, verify app funcționează (fallback la Oracle)
- [ ] Cache invalidation: Manual invalidate via Redis CLI, verify re-query
6. [ ] **Update CLAUDE.md documentation**
- Fișier: `CLAUDE.md`
- Acțiune: Adaugă secțiune "Redis Caching":
```markdown
## 💾 Redis Caching
ROA2WEB folosește Redis pentru caching layer:
- **Client**: `shared/cache/redis_client.py` (singleton pattern)
- **Decorator**: `@cached(resource="name", ttl=300)` în Services
- **Cache Keys**: `cache:{tenant_id}:{resource}:{params_hash}`
- **TTL Defaults**:
- Dashboard summary: 5 min
- Dashboard trends: 3 min
- Invoices list: 1 min
- Invoices summary: 3 min
**Toggle cache:** Set `REDIS_ENABLED=false` în `.env`
**Invalidate manual:**
```python
from shared.cache import invalidate_cache
await invalidate_cache(resource="dashboard_summary")
```
**Performance:** 60-80% reduction în query time pentru repetitive requests
```
**Output Verificabil:**
- [ ] All tests pass: `pytest shared/tests/ -v`
- [ ] Performance benchmark shows >60% improvement
- [ ] Manual testing checklist complet
- [ ] Documentation updated
- [ ] Ready for code review
---
## 📊 Cache Strategy
### Resource TTLs
| Resource | TTL | Motivație |
|----------|-----|-----------|
| `dashboard_summary` | 300s (5 min) | Date agregate, se schimbă rar |
| `dashboard_trends` | 180s (3 min) | Trends calculation expensive |
| `dashboard_detailed_data` | 60s (1 min) | Tabel interactiv, refresh frecvent |
| `dashboard_performance` | 180s (3 min) | Performance metrics stabile |
| `dashboard_cashflow` | 180s (3 min) | Forecast calculation expensive |
| `dashboard_maturity` | 180s (3 min) | Maturity analysis complex |
| `invoices_list` | 60s (1 min) | Listing cu filtre, refresh frecvent |
| `invoices_summary` | 180s (3 min) | Summary stats stabile |
| `companies_list` | 600s (10 min) | Lista rareori se schimbă |
| `treasury_data` | 120s (2 min) | Trezorerie moderate changes |
**Raționament TTL:**
- Scurt (60s): Date interactive, tabel listings
- Mediu (180-300s): Calculații expensive, agregări
- Lung (600s+): Date aproape statice (companies, permissions)
### Cache Keys Pattern
**Format:** `cache:{tenant_id}:{resource}:{params_hash}`
**Exemplu concret:**
```
cache:default:dashboard_summary:company-123:abc456def789
cache:default:invoices_list:company-123:partner-CLIENTI:unpaid-true:xyz890
cache:default:dashboard_trends:company-456:period-30d:compare-true:def123
```
**Componente:**
- `cache:` - Prefix constant (pentru separare de alte Redis keys)
- `{tenant_id}` - Tenant ID (deocamdată "default", viitor: din JWT token)
- `{resource}` - Resource name (dashboard_summary, invoices_list, etc.)
- `{params_hash}` - MD5 hash (primele 12 caractere) al parametrilor sortați JSON
**Multi-Tenant Ready:**
Când se implementează multi-tenant:
1. Modifică `extract_tenant_id()` în `utils.py` să citească din `request.state.tenant_id`
2. JWT token va include `tenant_id` field
3. Cache keys automat vor fi per-tenant
4. Invalidation per-tenant: `await invalidate_cache(tenant_id="client-a")`
### Invalidation Rules
**Trigger:** Când se schimbă date în Oracle DB
| Mutation | Invalidate Resources |
|----------|---------------------|
| Invoice created/updated | `invoices_list`, `invoices_summary`, `dashboard_summary`, `dashboard_trends` |
| Payment recorded | `invoices_list`, `dashboard_summary`, `treasury_data`, `dashboard_cashflow` |
| Treasury transaction | `treasury_data`, `dashboard_summary`, `dashboard_cashflow` |
| Company settings changed | `companies_list`, `dashboard_*` (pentru acea companie) |
**Implementare:**
```python
# În router după mutation
from shared.cache import invalidate_cache
@router.post("/invoices/{invoice_id}/pay")
async def mark_invoice_paid(...):
# ... update DB ...
# Invalidate affected caches
await invalidate_cache(resource="invoices_list")
await invalidate_cache(resource="invoices_summary")
await invalidate_cache(resource="dashboard_summary")
await invalidate_cache(resource="treasury_data")
return {"status": "ok"}
```
**Pattern Matching:**
```python
# Invalidate toate cache-urile pentru dashboard
await invalidate_cache(resource="dashboard") # matches dashboard_*
# Invalidate tot pentru un tenant
await invalidate_cache(tenant_id="client-a") # matches cache:client-a:*
```
---
## 🧪 Testing Plan
### Unit Tests
**File:** `shared/tests/test_redis_client.py`
- [ ] `test_redis_connection_success()` - Verify successful connection
- [ ] `test_redis_connection_failure_graceful()` - Redis down, no exception thrown
- [ ] `test_redis_get_set_delete()` - Basic operations
- [ ] `test_redis_delete_pattern()` - Pattern matching deletion
- [ ] `test_redis_ttl_expiration()` - Verify TTL works
- [ ] `test_redis_connection_retry()` - Exponential backoff retry
**File:** `shared/tests/test_cache_decorators.py`
- [ ] `test_cached_decorator_hit()` - Second call returns cached value
- [ ] `test_cached_decorator_miss()` - First call queries function
- [ ] `test_cache_key_generation()` - Keys format correct
- [ ] `test_cache_key_unique_params()` - Different params = different keys
- [ ] `test_invalidate_cache_pattern()` - Invalidation works
- [ ] `test_cached_disabled()` - Works when REDIS_ENABLED=false
### Integration Tests
**Setup:** `docker-compose up -d`
**Test Scenarios:**
1. **Full Stack Test:**
- Start backend + Redis
- Call `/api/dashboard/summary?company=123`
- Verify: Oracle query executed (check logs)
- Call again same endpoint
- Verify: Cache hit (no Oracle query)
2. **Cache Invalidation Test:**
- Call endpoint (cache populated)
- Invalidate via `redis-cli KEYS "cache:*"` + `DEL`
- Call endpoint again
- Verify: Oracle query executed (cache miss)
3. **Redis Failure Test:**
- `docker-compose stop roa-redis`
- Call endpoint
- Verify: App works (fallback to Oracle)
- No error thrown
- Logs show warning: "Redis unavailable, fallback to DB"
### Performance Benchmarks
**Tool:** Apache Bench or Python script
**Baseline (No Cache):**
```bash
# Stop Redis or set REDIS_ENABLED=false
ab -n 100 -c 10 http://localhost:8001/api/dashboard/summary?company=123
# Expected: ~800ms avg response time, 80s total
```
**With Cache:**
```bash
# Start Redis and set REDIS_ENABLED=true
ab -n 100 -c 10 http://localhost:8001/api/dashboard/summary?company=123
# Expected: ~15ms avg (after first request), ~2s total
```
**Target Metrics:**
- Cache hit rate: >90% (după warmup)
- Avg response time reduction: >60%
- Total time reduction: >75%
- Memory usage: +50-200MB (Redis)
**Save Results:** `shared/docs/REDIS_PERFORMANCE_BENCHMARK.md`
### Manual Testing Checklist
- [ ] **Dashboard Summary:**
- [ ] First load → check logs for "Cache MISS"
- [ ] Refresh page → check logs for "Cache HIT"
- [ ] Change company → new cache key, "Cache MISS"
- [ ] **Invoices List:**
- [ ] Filter: toate facturile → "Cache MISS" first time
- [ ] Refresh → "Cache HIT"
- [ ] Filter: doar neplatite → new key, "Cache MISS"
- [ ] Refresh → "Cache HIT"
- [ ] **Cache Invalidation:**
- [ ] Load dashboard (cached)
- [ ] Redis CLI: `redis-cli KEYS "cache:*"` → see keys
- [ ] Delete: `redis-cli DEL cache:default:dashboard_summary:*`
- [ ] Refresh dashboard → "Cache MISS" (re-queries Oracle)
- [ ] **Redis Failure Graceful:**
- [ ] Stop Redis: `docker-compose stop roa-redis`
- [ ] Access dashboard → works (no crash)
- [ ] Check logs: "Redis unavailable, using direct DB query"
- [ ] Start Redis: `docker-compose start roa-redis`
- [ ] Access dashboard → caching resume
- [ ] **Multi-Tenant Simulation:**
- [ ] Load dashboard company=123 (tenant=default)
- [ ] Load dashboard company=456 (tenant=default)
- [ ] Verify different cache keys in Redis
---
## 🔧 Configurare Env Variables
**File:** `backend/.env`
```bash
# ============================================================================
# REDIS CONFIGURATION
# ============================================================================
# Redis Connection URL
# Development: redis://roa-redis:6379/0 (Docker network)
# Production: redis://redis-host:6379/0 or redis://localhost:6379/0
REDIS_URL=redis://roa-redis:6379/0
# Redis Password (from docker-compose secrets)
# Match with REDIS_PASSWORD in docker-compose.yml
REDIS_PASSWORD=roa2web_redis_password
# Enable/Disable Redis Caching
# Set to 'false' to disable caching (fallback to direct DB queries)
REDIS_ENABLED=true
# Default Cache TTL (seconds)
# Used when no specific TTL provided to @cached decorator
CACHE_DEFAULT_TTL=300
# Redis Connection Pool Settings (optional, defaults shown)
REDIS_MAX_CONNECTIONS=50
REDIS_SOCKET_CONNECT_TIMEOUT=5
REDIS_SOCKET_KEEPALIVE=true
```
**Docker Compose Integration:**
No changes needed! Redis container already configured in `docker-compose.yml:147-163`.
**Verify:**
```bash
docker-compose exec roa-backend env | grep REDIS
# Should show REDIS_URL, REDIS_PASSWORD, REDIS_ENABLED
```
---
## 📝 Checklist Final
### Pre-Implementation
- [ ] Read și înțeles planul complet
- [ ] Backup codebase: `git commit -am "Backup before Redis implementation"`
- [ ] Redis container rulează: `docker-compose up -d roa-redis`
- [ ] Test connection: `docker-compose exec roa-redis redis-cli ping` → PONG
### Faza 1 (Setup)
- [ ] Dependency added: `redis>=5.0.0` în requirements.txt
- [ ] Package created: `shared/cache/__init__.py`
- [ ] Redis client: `shared/cache/redis_client.py`
- [ ] Env vars added: `backend/.env.example`
- [ ] Main.py updated: Redis initialize at startup
- [ ] Test: `python -c "import asyncio; from shared.cache import redis_cache; asyncio.run(redis_cache.initialize())"`
### Faza 2 (Decorators)
- [ ] Utils created: `shared/cache/utils.py`
- [ ] Decorator created: `shared/cache/decorators.py`
- [ ] Unit tests: `shared/tests/test_cache_decorators.py`
- [ ] Test: `pytest shared/tests/test_cache_decorators.py -v`
### Faza 3 (Integration)
- [ ] Cached applied: DashboardService.get_complete_summary
- [ ] Cached applied: DashboardService.get_trends
- [ ] Cached applied: DashboardService.get_detailed_data
- [ ] Cached applied: InvoiceService.get_invoices
- [ ] Cached applied: InvoiceService.get_invoice_summary
- [ ] Backend starts: `uvicorn app.main:app --reload`
- [ ] Test: First request slow, second fast
### Faza 4 (Validation)
- [ ] Unit tests pass: `pytest shared/tests/ -v`
- [ ] Integration tests pass (Docker stack)
- [ ] Performance benchmark run (save results)
- [ ] Manual testing checklist completed
- [ ] Documentation updated: `CLAUDE.md`
- [ ] Git commit: `git add . && git commit -m "feat: implement Redis caching layer"`
### Ready for Production
- [ ] All tests green
- [ ] Performance improvement >60%
- [ ] Graceful degradation tested (Redis failure)
- [ ] Code review requested
- [ ] Merge to main branch
---
## 📚 Referințe
### Documentație Existentă
- **Docker Compose Redis Config:** `docker-compose.yml:147-163`
- **Oracle Pool Pattern:** `shared/database/oracle_pool.py` (reference for singleton pattern)
- **Backend Services:** `backend/app/services/` (where to apply caching)
- **Backend Routers:** `backend/app/routers/` (where to invalidate cache)
### Documentație Externă
- Redis Python Client: https://redis.readthedocs.io/en/stable/
- Redis Commands: https://redis.io/commands/
- FastAPI Async: https://fastapi.tiangolo.com/async/
### Debugging
**Redis CLI Access:**
```bash
docker-compose exec roa-redis redis-cli -a roa2web_redis_password
> KEYS cache:*
> GET cache:default:dashboard_summary:abc123
> DEL cache:default:dashboard_summary:abc123
> FLUSHDB # Delete all keys (WARNING: destructive)
```
**Monitor Redis Operations:**
```bash
docker-compose exec roa-redis redis-cli -a roa2web_redis_password MONITOR
```
**Check Cache Stats:**
```bash
docker-compose exec roa-redis redis-cli -a roa2web_redis_password INFO stats
```
---
## 🎯 Code Templates
### Template: RedisCache Client (`shared/cache/redis_client.py`)
```python
"""
Redis Cache Client - Singleton pattern similar to OraclePool
Provides async Redis operations with graceful degradation
"""
import redis.asyncio as redis
import logging
import json
from typing import Optional, Any
import os
logger = logging.getLogger(__name__)
class RedisCache:
"""Singleton Redis cache client with connection pooling"""
_instance: Optional['RedisCache'] = None
_client: Optional[redis.Redis] = None
_enabled: bool = False
def __new__(cls):
if cls._instance is None:
cls._instance = super(RedisCache, cls).__new__(cls)
return cls._instance
async def initialize(
self,
url: str = None,
password: str = None,
max_connections: int = 50
):
"""Initialize Redis connection pool"""
if self._client is not None:
return
try:
url = url or os.getenv('REDIS_URL', 'redis://localhost:6379/0')
password = password or os.getenv('REDIS_PASSWORD')
self._client = await redis.from_url(
url,
password=password,
encoding="utf-8",
decode_responses=True,
max_connections=max_connections,
socket_connect_timeout=5,
socket_keepalive=True
)
# Test connection
await self._client.ping()
self._enabled = True
logger.info("✅ Redis cache initialized successfully")
except Exception as e:
logger.warning(f"⚠️ Redis initialization failed: {e}. Caching disabled.")
self._enabled = False
self._client = None
def is_enabled(self) -> bool:
"""Check if Redis caching is enabled"""
return self._enabled and self._client is not None
async def get(self, key: str) -> Optional[Any]:
"""Get value from cache"""
if not self.is_enabled():
return None
try:
value = await self._client.get(key)
if value:
return json.loads(value)
return None
except Exception as e:
logger.error(f"Redis GET error for key {key}: {e}")
return None
async def set(self, key: str, value: Any, ttl: int = 300):
"""Set value in cache with TTL"""
if not self.is_enabled():
return
try:
value_json = json.dumps(value, default=str)
await self._client.setex(key, ttl, value_json)
except Exception as e:
logger.error(f"Redis SET error for key {key}: {e}")
async def delete(self, key: str):
"""Delete single key"""
if not self.is_enabled():
return
try:
await self._client.delete(key)
except Exception as e:
logger.error(f"Redis DELETE error for key {key}: {e}")
async def delete_pattern(self, pattern: str):
"""Delete all keys matching pattern (e.g., 'cache:default:dashboard*')"""
if not self.is_enabled():
return
try:
async for key in self._client.scan_iter(match=pattern):
await self._client.delete(key)
logger.debug(f"Deleted keys matching pattern: {pattern}")
except Exception as e:
logger.error(f"Redis DELETE_PATTERN error for {pattern}: {e}")
async def close(self):
"""Close Redis connection"""
if self._client:
await self._client.close()
self._client = None
self._enabled = False
logger.info("✅ Redis connection closed")
# Global singleton instance
redis_cache = RedisCache()
```
---
## ⚠️ Known Limitations & Future Work
### Current Limitations
1. **No Cache Warming:** Cache is cold on startup (first requests slow)
- Future: Implement background task to pre-populate hot keys
2. **Manual Invalidation:** Invalidation must be coded manually in routers
- Future: Auto-invalidation via database triggers or event system
3. **Single Tenant:** All cache keys use `tenant_id="default"`
- Future: Extract tenant_id from JWT token when multi-tenant implemented
4. **No Cache Monitoring:** No dashboard/metrics for cache performance
- Future: Integrate Prometheus metrics (hit/miss rate, latency, memory)
5. **Simple Serialization:** Uses JSON (no support for binary data, datetime needs str conversion)
- Future: Consider msgpack for faster serialization
### Future Enhancements
- [ ] **Cache Warming:** Background task to pre-load hot keys at startup
- [ ] **Smart Invalidation:** Event-driven invalidation based on DB changes
- [ ] **Cache Monitoring Dashboard:** Redis metrics + hit/miss rates
- [ ] **Cache Compression:** Compress large values (>10KB) before storing
- [ ] **Multi-Level Cache:** L1 (in-memory LRU) + L2 (Redis) for ultra-fast access
- [ ] **Cache Tagging:** Tag-based invalidation instead of pattern matching
---
## 📞 Support & Questions
**Dacă întâmpini probleme:**
1. **Redis nu pornește:** Check `docker-compose logs roa-redis`
2. **Connection failed:** Verify REDIS_URL și REDIS_PASSWORD în .env
3. **Cache nu funcționează:** Verify REDIS_ENABLED=true și logs pentru errors
4. **Performance nu se îmbunătățește:** Check cache hit rate în logs
**Contact:** Claude Code Implementation Team
---
**Planul este gata pentru implementare! Începe cu FAZA 1 și urmează pașii exact cum sunt descriși.**

View File

@@ -0,0 +1,577 @@
<template>
<div :class="selectorClass" ref="dropdownContainer">
<div class="company-dropdown" ref="dropdown">
<button
class="company-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Selectare firma"
title="Alt+Q pentru selectare rapida"
>
<div class="company-info">
<span class="company-name">{{ selectedCompanyName }}</span>
<span v-if="showFiscalCode" class="company-code">{{ selectedCompanyCode }}</span>
</div>
<i
class="pi pi-chevron-down"
:class="{ 'rotate-180': dropdownOpen }"
></i>
</button>
<div
v-show="dropdownOpen"
class="company-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="dropdown-search">
<div class="search-wrapper">
<i class="pi pi-search search-icon"></i>
<input
ref="searchInput"
type="text"
v-model="searchQuery"
placeholder="Cauta firma..."
class="search-input"
@keydown="handleKeyDown"
/>
</div>
</div>
<div class="company-list">
<div
v-for="(company, index) in filteredCompanies"
:key="company.id_firma"
class="company-item"
:class="{
active: company.id_firma === selectedCompany?.id_firma,
'keyboard-highlighted': isHighlighted(index),
}"
@click="selectCompany(company)"
@mouseenter="highlightedIndex = index"
>
<div class="company-details">
<div class="company-main-name">{{ company.name }}</div>
<div v-if="showFiscalCode" class="company-sub-info">
<span class="company-cui">CUI: {{ company.fiscal_code || '-' }}</span>
</div>
</div>
<i
v-if="company.id_firma === selectedCompany?.id_firma"
class="pi pi-check company-selected-icon"
></i>
</div>
</div>
<div v-if="filteredCompanies.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>Nu s-au gasit firme</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
export default {
name: "CompanySelector",
props: {
// The companies store instance
companiesStore: {
type: Object,
required: true,
},
// Optional v-model binding
modelValue: {
type: Object,
default: null,
},
// Show fiscal code in display
showFiscalCode: {
type: Boolean,
default: true,
},
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
variant: {
type: String,
default: "default",
validator: (value) => ['default', 'header'].includes(value),
},
},
emits: ["update:modelValue", "company-changed"],
setup(props, { emit }) {
const dropdown = ref(null);
const dropdownContainer = ref(null);
const searchInput = ref(null);
const dropdownOpen = ref(false);
const searchQuery = ref("");
const highlightedIndex = ref(-1);
const selectedCompany = computed({
get: () => props.modelValue || props.companiesStore.selectedCompany,
set: (value) => {
emit("update:modelValue", value);
props.companiesStore.setSelectedCompany(value);
},
});
const selectedCompanyName = computed(() => {
return selectedCompany.value?.name || "Selectare firma";
});
const selectedCompanyCode = computed(() => {
return selectedCompany.value?.fiscal_code
? `CUI: ${selectedCompany.value.fiscal_code}`
: "";
});
const selectorClass = computed(() => ({
'company-selector': true,
'company-selector--header': props.variant === 'header'
}));
const filteredCompanies = computed(() => {
const companies = props.companiesStore.companies || [];
if (!searchQuery.value || searchQuery.value.trim() === "") {
return companies;
}
const query = searchQuery.value.toLowerCase().trim();
return companies.filter(
(company) =>
company.name?.toLowerCase().includes(query) ||
company.fiscal_code?.toLowerCase().includes(query)
);
});
const toggleDropdown = async () => {
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
searchQuery.value = "";
highlightedIndex.value = -1;
await nextTick();
searchInput.value?.focus();
}
};
const closeDropdown = () => {
dropdownOpen.value = false;
searchQuery.value = "";
};
const selectCompany = (company) => {
selectedCompany.value = company;
emit("company-changed", company);
closeDropdown();
};
const scrollToHighlighted = () => {
nextTick(() => {
const highlightedElement = document.querySelector(
".company-item.keyboard-highlighted"
);
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
});
};
const handleKeyDown = (event) => {
if (!dropdownOpen.value || filteredCompanies.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value =
(highlightedIndex.value + 1) % filteredCompanies.value.length;
scrollToHighlighted();
break;
case "ArrowUp":
event.preventDefault();
if (highlightedIndex.value <= 0) {
highlightedIndex.value = filteredCompanies.value.length - 1;
} else {
highlightedIndex.value--;
}
scrollToHighlighted();
break;
case "Enter":
event.preventDefault();
if (
highlightedIndex.value >= 0 &&
highlightedIndex.value < filteredCompanies.value.length
) {
selectCompany(filteredCompanies.value[highlightedIndex.value]);
}
break;
case "Escape":
closeDropdown();
break;
}
};
const isHighlighted = (index) => {
return index === highlightedIndex.value;
};
const openWithShortcut = async () => {
if (dropdownContainer.value) {
dropdownContainer.value.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
await new Promise((resolve) => setTimeout(resolve, 300));
if (!dropdownOpen.value) {
dropdownOpen.value = true;
highlightedIndex.value = -1;
searchQuery.value = "";
await nextTick();
searchInput.value?.focus();
} else {
searchInput.value?.focus();
}
};
const handleGlobalKeyDown = (event) => {
if (event.altKey && event.key === "q") {
event.preventDefault();
openWithShortcut();
}
};
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown();
}
};
watch(searchQuery, () => {
highlightedIndex.value = -1;
});
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleGlobalKeyDown);
// Load companies if not already loaded
if (props.companiesStore.companies.length === 0) {
props.companiesStore.loadCompanies();
}
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleGlobalKeyDown);
});
return {
dropdown,
dropdownContainer,
searchInput,
dropdownOpen,
searchQuery,
highlightedIndex,
selectedCompany,
selectedCompanyName,
selectedCompanyCode,
selectorClass,
filteredCompanies,
toggleDropdown,
closeDropdown,
selectCompany,
handleKeyDown,
isHighlighted,
};
},
};
</script>
<style scoped>
.company-selector {
position: relative;
max-width: 450px;
}
.company-dropdown {
position: relative;
}
.company-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
text-align: left;
min-width: 300px;
}
.company-trigger:hover {
border-color: var(--color-primary, #2563eb);
background: var(--color-bg-secondary, #f9fafb);
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
display: block;
font-size: var(--text-sm, 14px);
font-weight: 500;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-code {
display: block;
font-size: var(--text-xs, 12px);
color: var(--color-text-secondary, #6b7280);
margin-top: 2px;
}
.pi-chevron-down {
transition: transform 0.15s ease;
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-xs, 12px);
}
.rotate-180 {
transform: rotate(180deg);
}
.company-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all 0.15s ease;
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.dropdown-search {
padding: var(--space-sm, 8px);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-sm, 8px);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-sm, 8px) var(--space-sm, 8px) var(--space-sm, 8px) var(--space-xl, 32px);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: var(--text-sm, 14px);
background: var(--color-bg, #fff);
color: var(--color-text, #111827);
transition: border-color 0.15s ease;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
}
.company-list {
max-height: 200px;
overflow-y: auto;
}
.company-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md, 12px);
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.company-item:last-child {
border-bottom: none;
}
.company-item:hover {
background: var(--color-bg-secondary, #f9fafb);
}
.company-item.active {
background: var(--color-primary, #2563eb);
color: #fff;
}
.company-item.keyboard-highlighted {
background: var(--color-bg-secondary, #f9fafb);
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
}
.company-item.active.keyboard-highlighted {
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.company-details {
flex: 1;
min-width: 0;
}
.company-main-name {
font-size: var(--text-sm, 14px);
font-weight: 500;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.company-sub-info {
display: flex;
align-items: center;
gap: var(--space-xs, 4px);
font-size: var(--text-xs, 12px);
opacity: 0.8;
}
.company-selected-icon {
color: inherit;
font-size: var(--text-sm, 14px);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
padding: var(--space-xl, 24px);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.company-selector {
max-width: 200px;
width: auto;
}
.company-trigger {
min-width: auto;
max-width: 200px;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.company-info {
max-width: 140px;
}
.company-name {
font-size: var(--text-xs, 12px);
max-width: 140px;
}
.company-code {
font-size: 10px;
}
.company-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
/* Header variant - transparent background for header integration */
.company-selector--header .company-trigger {
background: transparent;
border-color: var(--color-border, rgba(0, 0, 0, 0.2));
}
.company-selector--header .company-trigger:hover {
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
border-color: var(--color-primary, #2563eb);
}
.company-selector--header .company-name {
color: var(--color-text, #111827);
}
.company-selector--header .company-code {
color: var(--color-text-secondary, #6b7280);
}
.company-selector--header .pi-chevron-down {
color: var(--color-text-secondary, #6b7280);
}
/* Gradient header variant - white text for dark/gradient headers */
.header-container--gradient .company-selector--header .company-trigger {
border-color: rgba(255, 255, 255, 0.3);
}
.header-container--gradient .company-selector--header .company-trigger:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.header-container--gradient .company-selector--header .company-name {
color: white;
}
.header-container--gradient .company-selector--header .company-code {
color: rgba(255, 255, 255, 0.8);
}
.header-container--gradient .company-selector--header .pi-chevron-down {
color: rgba(255, 255, 255, 0.8);
}
/* Dropdown panel keeps default styling (white background) */
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="login-container">
<div class="login-wrapper">
<Card class="login-card">
<template #header>
<div class="login-header">
<i :class="['pi', appIcon, 'text-primary', 'text-6xl']"></i>
<h1 class="login-title">{{ appTitle }}</h1>
<p class="login-subtitle">{{ appSubtitle }}</p>
</div>
</template>
<template #content>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username" class="form-label required">Utilizator</label>
<InputText
id="username"
v-model="credentials.username"
placeholder="Introduceți numele de utilizator"
:class="{ invalid: formErrors.username }"
class="w-full"
autocomplete="username"
@blur="validateField('username')"
/>
<span v-if="formErrors.username" class="form-error">
{{ formErrors.username }}
</span>
</div>
<div class="form-group">
<label for="password" class="form-label required">Parolă</label>
<Password
id="password"
v-model="credentials.password"
placeholder="Introduceți parola"
:class="{ invalid: formErrors.password }"
class="w-full"
:feedback="false"
toggle-mask
autocomplete="current-password"
@blur="validateField('password')"
/>
<span v-if="formErrors.password" class="form-error">
{{ formErrors.password }}
</span>
</div>
<div v-if="authStore.error" class="login-error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<Button
type="submit"
label="Conectare"
class="w-full login-button"
:loading="authStore.isLoading"
:disabled="!isFormValid"
/>
</form>
</template>
<template #footer>
<div class="login-footer">
<small class="text-color-secondary">
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
</small>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useToast } from "primevue/usetoast";
// Props for app-specific customization
const props = defineProps({
appTitle: {
type: String,
required: true,
},
appSubtitle: {
type: String,
required: true,
},
appIcon: {
type: String,
required: true,
},
redirectPath: {
type: String,
default: "/",
},
authStore: {
type: Object,
required: true,
},
});
const router = useRouter();
const toast = useToast();
// Form data
const credentials = ref({
username: "",
password: "",
});
const formErrors = ref({
username: "",
password: "",
});
// Computed properties
const currentYear = computed(() => new Date().getFullYear());
const isFormValid = computed(() => {
return (
credentials.value.username.trim() !== "" &&
credentials.value.password.trim() !== "" &&
!formErrors.value.username &&
!formErrors.value.password
);
});
// Methods
const validateField = (field) => {
switch (field) {
case "username":
formErrors.value.username =
credentials.value.username.trim() === ""
? "Numele de utilizator este obligatoriu"
: "";
break;
case "password":
formErrors.value.password =
credentials.value.password.trim() === ""
? "Parola este obligatorie"
: "";
break;
}
};
const validateForm = () => {
validateField("username");
validateField("password");
return isFormValid.value;
};
const handleLogin = async () => {
if (!validateForm()) {
return;
}
try {
const result = await props.authStore.login(credentials.value);
if (result.success) {
router.push(props.redirectPath);
} else {
toast.add({
severity: "error",
summary: "Eroare de conectare",
detail: result.error || "Date de conectare incorecte",
life: 5000,
});
}
} catch (error) {
console.error("Login error:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "A apărut o eroare neașteptată",
life: 5000,
});
}
};
// Clear errors when user starts typing
const clearErrors = () => {
props.authStore.clearError();
formErrors.value = {
username: "",
password: "",
};
};
// Lifecycle hooks
onMounted(() => {
// Clear any previous errors
clearErrors();
// Focus on username field
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.focus();
}
});
onUnmounted(() => {
clearErrors();
});
</script>
<style>
@import "../styles/login.css";
</style>

View File

@@ -0,0 +1,467 @@
<template>
<div :class="selectorClass" ref="dropdownContainer">
<div class="period-dropdown" ref="dropdown">
<button
class="period-trigger"
@click="toggleDropdown"
:disabled="!hasSelectedCompany"
:aria-expanded="dropdownOpen"
aria-label="Selectare perioada contabila"
>
<div class="period-info">
<span class="period-label">Perioada:</span>
<span class="period-name">{{ selectedPeriodDisplay }}</span>
</div>
<i
class="pi pi-chevron-down"
:class="{ 'rotate-180': dropdownOpen }"
></i>
</button>
<div
v-show="dropdownOpen"
class="period-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="period-list">
<div
v-for="(period, index) in periods"
:key="`${period.an}-${period.luna}`"
class="period-item"
:class="{
active: isSelected(period),
'keyboard-highlighted': isHighlighted(index),
}"
@click="selectPeriod(period)"
@mouseenter="highlightedIndex = index"
>
<div class="period-details">
{{ period.display_name }}
</div>
<i v-if="isSelected(period)" class="pi pi-check period-selected-icon"></i>
</div>
</div>
<div v-if="periods.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>Nu sunt perioade disponibile</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
export default {
name: "PeriodSelector",
props: {
// The accounting period store instance
periodStore: {
type: Object,
required: true,
},
// The company store instance (to check if company is selected)
companiesStore: {
type: Object,
required: true,
},
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
variant: {
type: String,
default: "default",
validator: (value) => ['default', 'header'].includes(value),
},
},
emits: ["period-changed"],
setup(props, { emit }) {
const dropdown = ref(null);
const dropdownContainer = ref(null);
const dropdownOpen = ref(false);
const highlightedIndex = ref(-1);
const hasSelectedCompany = computed(() => {
return !!props.companiesStore.selectedCompany;
});
const periods = computed(() => {
return props.periodStore.periods || [];
});
const selectedPeriodDisplay = computed(() => {
return props.periodStore.selectedPeriod?.display_name || "Selectare perioada";
});
const selectorClass = computed(() => ({
'period-selector': true,
'period-selector--header': props.variant === 'header'
}));
const isSelected = (period) => {
if (!props.periodStore.selectedPeriod) return false;
return (
period.an === props.periodStore.selectedPeriod.an &&
period.luna === props.periodStore.selectedPeriod.luna
);
};
const isHighlighted = (index) => {
return index === highlightedIndex.value;
};
const toggleDropdown = async () => {
if (!hasSelectedCompany.value) return;
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
highlightedIndex.value = -1;
}
};
const closeDropdown = () => {
dropdownOpen.value = false;
};
const selectPeriod = (period) => {
props.periodStore.setSelectedPeriod(period);
emit("period-changed", period);
closeDropdown();
};
const scrollToHighlighted = () => {
nextTick(() => {
const highlightedElement = document.querySelector(
".period-item.keyboard-highlighted"
);
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
});
};
const handleKeyDown = (event) => {
if (!dropdownOpen.value || periods.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value =
(highlightedIndex.value + 1) % periods.value.length;
scrollToHighlighted();
break;
case "ArrowUp":
event.preventDefault();
if (highlightedIndex.value <= 0) {
highlightedIndex.value = periods.value.length - 1;
} else {
highlightedIndex.value--;
}
scrollToHighlighted();
break;
case "Enter":
event.preventDefault();
if (
highlightedIndex.value >= 0 &&
highlightedIndex.value < periods.value.length
) {
selectPeriod(periods.value[highlightedIndex.value]);
}
break;
case "Escape":
closeDropdown();
break;
}
};
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown();
}
};
// Watch for company changes - load periods and reset
watch(
() => props.companiesStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await props.periodStore.loadPeriods(newCompany.id_firma);
} else {
props.periodStore.reset();
}
},
{ immediate: true }
);
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
});
return {
dropdown,
dropdownContainer,
dropdownOpen,
highlightedIndex,
hasSelectedCompany,
periods,
selectedPeriodDisplay,
selectorClass,
isSelected,
isHighlighted,
toggleDropdown,
closeDropdown,
selectPeriod,
};
},
};
</script>
<style scoped>
.period-selector {
position: relative;
max-width: 220px;
}
.period-dropdown {
position: relative;
}
.period-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
text-align: left;
min-width: 200px;
}
.period-trigger:hover:not(:disabled) {
border-color: var(--color-primary, #2563eb);
background: var(--color-bg-secondary, #f9fafb);
}
.period-trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.period-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.period-label {
font-size: var(--text-xs, 12px);
color: var(--color-text-secondary, #6b7280);
}
.period-name {
font-size: var(--text-sm, 14px);
font-weight: 500;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pi-chevron-down {
transition: transform 0.15s ease;
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-xs, 12px);
}
.rotate-180 {
transform: rotate(180deg);
}
.period-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all 0.15s ease;
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.period-list {
max-height: 280px;
overflow-y: auto;
}
.period-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm, 8px) var(--space-md, 12px);
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.period-item:last-child {
border-bottom: none;
}
.period-item:hover {
background: var(--color-bg-secondary, #f9fafb);
}
.period-item.active {
background: var(--color-primary, #2563eb);
color: #fff;
}
.period-item.keyboard-highlighted {
background: var(--color-bg-secondary, #f9fafb);
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
}
.period-item.active.keyboard-highlighted {
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.period-details {
flex: 1;
font-size: var(--text-sm, 14px);
}
.period-selected-icon {
color: inherit;
font-size: var(--text-sm, 14px);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
padding: var(--space-xl, 24px);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.period-selector {
max-width: 140px;
width: auto;
}
.period-trigger {
min-width: auto;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.period-info {
flex-direction: row;
align-items: center;
gap: var(--space-xs, 4px);
}
.period-label {
display: none;
}
.period-name {
font-size: var(--text-xs, 12px);
}
.period-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
/* Header variant - transparent background for header integration */
.period-selector--header .period-trigger {
background: transparent;
border-color: var(--color-border, rgba(0, 0, 0, 0.2));
}
.period-selector--header .period-trigger:hover:not(:disabled) {
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
border-color: var(--color-primary, #2563eb);
}
.period-selector--header .period-trigger:disabled {
border-color: var(--color-border, rgba(0, 0, 0, 0.1));
}
.period-selector--header .period-label {
color: var(--color-text-secondary, #6b7280);
}
.period-selector--header .period-name {
color: var(--color-text, #111827);
}
.period-selector--header .pi-chevron-down {
color: var(--color-text-secondary, #6b7280);
}
/* Gradient header variant - white text for dark/gradient headers */
.header-container--gradient .period-selector--header .period-trigger {
border-color: rgba(255, 255, 255, 0.3);
}
.header-container--gradient .period-selector--header .period-trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.header-container--gradient .period-selector--header .period-trigger:disabled {
border-color: rgba(255, 255, 255, 0.15);
}
.header-container--gradient .period-selector--header .period-label {
color: rgba(255, 255, 255, 0.7);
}
.header-container--gradient .period-selector--header .period-name {
color: white;
}
.header-container--gradient .period-selector--header .pi-chevron-down {
color: rgba(255, 255, 255, 0.8);
}
/* Dropdown panel keeps default styling (white background) */
</style>

View File

@@ -0,0 +1,132 @@
<template>
<header class="header-container" :class="headerClass">
<nav class="header-nav">
<!-- Left side: Hamburger + Brand -->
<div class="header-left">
<button
class="hamburger-btn"
:class="{ active: menuOpen }"
@click="$emit('menu-toggle')"
aria-label="Toggle navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<router-link :to="brandLink" class="header-brand">
<slot name="brand">
<span>{{ title }}</span>
</slot>
</router-link>
</div>
<!-- Right side: Period + Company + User -->
<div class="header-actions">
<PeriodSelector
v-if="showPeriod && selectedCompany"
:period-store="periodStore"
:companies-store="companiesStore"
variant="header"
@period-changed="onPeriodChanged"
/>
<CompanySelector
v-if="showCompany"
:companies-store="companiesStore"
:show-fiscal-code="true"
variant="header"
@company-changed="onCompanyChanged"
/>
<slot name="user-menu">
<div v-if="showUser && currentUser" class="header-user" @click="$emit('user-menu-toggle')">
<i class="pi pi-user"></i>
<span class="desktop-only">{{ currentUser?.username || 'User' }}</span>
</div>
</slot>
</div>
</nav>
</header>
</template>
<script>
import { computed } from "vue";
import CompanySelector from "../CompanySelector.vue";
import PeriodSelector from "../PeriodSelector.vue";
export default {
name: "AppHeader",
components: {
CompanySelector,
PeriodSelector,
},
props: {
// Header title/brand text
title: {
type: String,
default: "ROA2WEB",
},
// Router link for brand click
brandLink: {
type: String,
default: "/",
},
// Additional CSS class for header (e.g., 'header-container--gradient')
headerClass: {
type: String,
default: "",
},
// Is hamburger menu open?
menuOpen: {
type: Boolean,
default: false,
},
// Companies store instance (required for selectors)
companiesStore: {
type: Object,
required: true,
},
// Period store instance (required for period selector)
periodStore: {
type: Object,
required: true,
},
// Current user object for display
currentUser: {
type: Object,
default: null,
},
// Show/hide period selector
showPeriod: {
type: Boolean,
default: true,
},
// Show/hide company selector
showCompany: {
type: Boolean,
default: true,
},
// Show/hide user info
showUser: {
type: Boolean,
default: true,
},
},
emits: ["menu-toggle", "company-changed", "period-changed", "user-menu-toggle"],
setup(props, { emit }) {
const selectedCompany = computed(() => props.companiesStore.selectedCompany);
const onCompanyChanged = (company) => {
emit("company-changed", company);
};
const onPeriodChanged = (period) => {
emit("period-changed", period);
};
return {
selectedCompany,
onCompanyChanged,
onPeriodChanged,
};
},
};
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<!-- Menu Overlay -->
<div
class="slide-menu-overlay"
:class="{ open: isOpen }"
@click="$emit('close')"
></div>
<!-- Slide Menu -->
<nav class="slide-menu" :class="{ open: isOpen }">
<!-- Dynamic Menu Sections -->
<div
v-for="section in menuItems"
:key="section.title"
class="menu-section"
>
<h3 class="menu-title">{{ section.title }}</h3>
<ul class="menu-list">
<li
v-for="item in section.items"
:key="item.to"
class="menu-item"
>
<router-link
:to="item.to"
class="menu-link"
:class="{ active: isRouteActive(item.to) }"
@click="$emit('close')"
>
<i :class="['menu-icon', item.icon]"></i>
<span>{{ item.label }}</span>
<span v-if="item.badge" class="menu-badge">{{ item.badge }}</span>
</router-link>
</li>
</ul>
</div>
<!-- Profile Section (at bottom) -->
<div class="menu-section menu-profile">
<div class="profile-info">
<i class="pi pi-user"></i>
<span>{{ currentUser?.username || 'Utilizator' }}</span>
</div>
<ul class="menu-list">
<slot name="profile-items"></slot>
<li class="menu-item">
<a href="#" class="menu-link" @click.prevent="handleLogout">
<i class="menu-icon pi pi-sign-out"></i>
<span>Deconectare</span>
</a>
</li>
</ul>
</div>
</nav>
</div>
</template>
<script>
import { useRoute } from "vue-router";
export default {
name: "SlideMenu",
props: {
// Is menu open?
isOpen: {
type: Boolean,
default: false,
},
// Menu items configuration
// Format: [{ title: 'Section', items: [{ to: '/path', icon: 'pi pi-icon', label: 'Label', badge: null }] }]
menuItems: {
type: Array,
default: () => [],
},
// Current user object
currentUser: {
type: Object,
default: null,
},
},
emits: ["close", "logout"],
setup(props, { emit }) {
const route = useRoute();
const isRouteActive = (path) => {
return route.path === path;
};
const handleLogout = () => {
emit("logout");
emit("close");
};
return {
isRouteActive,
handleLogout,
};
},
};
</script>

View File

@@ -0,0 +1,158 @@
/**
* Shared Accounting Period Store Factory
*
* Creates a Pinia store for accounting period selection that can be used by any ROA2WEB application.
* Each app passes its own apiService and store references.
*
* Usage:
* import { createAccountingPeriodStore } from '@shared/frontend/stores/accountingPeriod';
* import { apiService } from '../services/api';
* import { useAuthStore } from './auth';
* import { useCompanyStore } from './companies';
* export const useAccountingPeriodStore = createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore);
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Factory function to create an accounting period store
* @param {Object} apiService - Axios instance configured for the app's API
* @param {Function} useAuthStore - Reference to the auth store function
* @param {Function} useCompanyStore - Reference to the company store function
* @returns {Function} Pinia store definition
*/
export function createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore) {
return defineStore("accountingPeriod", () => {
// State
const periods = ref([]);
const selectedPeriod = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Getters
const hasPeriods = computed(() => periods.value.length > 0);
const currentPeriod = computed(() => selectedPeriod.value);
// Computed date range for current period (first/last day of month)
const dateRange = computed(() => {
if (!selectedPeriod.value) return { dateFrom: null, dateTo: null };
const { an, luna } = selectedPeriod.value;
const firstDay = new Date(an, luna - 1, 1);
const lastDay = new Date(an, luna, 0);
return {
dateFrom: firstDay,
dateTo: lastDay,
};
});
// localStorage helpers
const getStorageKey = () => {
const authStore = useAuthStore();
const companyStore = useCompanyStore();
const username = authStore.user?.username;
const companyId = companyStore.selectedCompany?.id_firma;
if (!username || !companyId) return null;
return `selected_period_${username}_${companyId}`;
};
const initializeSelectedPeriod = () => {
const key = getStorageKey();
if (!key) return null;
const saved = localStorage.getItem(key);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
localStorage.removeItem(key);
}
}
return null;
};
const persistSelectedPeriod = (period) => {
const key = getStorageKey();
if (key && period) {
localStorage.setItem(key, JSON.stringify(period));
}
};
// Actions
const loadPeriods = async (companyId) => {
if (!companyId) return { success: false };
isLoading.value = true;
error.value = null;
try {
const response = await apiService.get("/calendar/periods", {
params: { company: companyId },
});
periods.value = response.data.periods || [];
// Try to restore saved period or use most recent
const saved = initializeSelectedPeriod();
if (saved) {
const exists = periods.value.find(
(p) => p.an === saved.an && p.luna === saved.luna
);
if (exists) {
selectedPeriod.value = exists;
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load periods";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedPeriod = (period) => {
selectedPeriod.value = period;
persistSelectedPeriod(period);
};
const resetToLatest = () => {
if (periods.value.length > 0) {
setSelectedPeriod(periods.value[0]);
}
};
const reset = () => {
periods.value = [];
selectedPeriod.value = null;
isLoading.value = false;
error.value = null;
};
return {
// State
periods,
selectedPeriod,
isLoading,
error,
// Getters
hasPeriods,
currentPeriod,
dateRange,
// Actions
loadPeriods,
setSelectedPeriod,
resetToLatest,
reset,
};
});
}

View File

@@ -0,0 +1,133 @@
/**
* Shared Auth Store Factory
*
* Creates a Pinia auth store that can be used by any ROA2WEB application.
* Each app passes its own apiService instance configured with the correct baseURL.
*
* Usage:
* import { createAuthStore } from '@shared/frontend/stores/auth';
* import { apiService } from '../services/api';
* export const useAuthStore = createAuthStore(apiService);
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Factory function to create an auth store with the provided API service
* @param {Object} apiService - Axios instance configured for the app's API
* @returns {Function} Pinia store definition
*/
export function createAuthStore(apiService) {
return defineStore("auth", () => {
// State
const accessToken = ref(localStorage.getItem("access_token"));
const refreshToken = ref(localStorage.getItem("refresh_token"));
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
const isLoading = ref(false);
const error = ref(null);
// Getters
const isAuthenticated = computed(() => !!accessToken.value);
const currentUser = computed(() => user.value);
// Actions
const login = async (credentials) => {
isLoading.value = true;
error.value = null;
try {
const response = await apiService.post("/auth/login", {
username: credentials.username,
password: credentials.password,
});
const { access_token, refresh_token, user: userData } = response.data;
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
localStorage.setItem("user", JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Login failed";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const logout = () => {
accessToken.value = null;
refreshToken.value = null;
user.value = null;
error.value = null;
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
delete apiService.defaults.headers.common["Authorization"];
};
const refreshAccessToken = async () => {
if (!refreshToken.value) {
logout();
return false;
}
try {
const response = await apiService.post("/auth/refresh", {
refresh_token: refreshToken.value,
});
const { access_token } = response.data;
accessToken.value = access_token;
localStorage.setItem("access_token", access_token);
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
return true;
} catch (err) {
console.error("Token refresh failed:", err);
logout();
return false;
}
};
const initializeAuth = () => {
if (accessToken.value) {
apiService.defaults.headers.common["Authorization"] = `Bearer ${accessToken.value}`;
}
};
const clearError = () => {
error.value = null;
};
// Initialize on store creation
initializeAuth();
return {
// State
accessToken,
refreshToken,
user,
isLoading,
error,
// Getters
isAuthenticated,
currentUser,
// Actions
login,
logout,
refreshAccessToken,
initializeAuth,
clearError,
};
});
}

View File

@@ -0,0 +1,196 @@
/**
* Shared Companies Store Factory
*
* Creates a Pinia store for company selection that can be used by any ROA2WEB application.
* Each app passes its own apiService and auth store instances.
*
* Usage:
* import { createCompaniesStore } from '@shared/frontend/stores/companies';
* import { apiService } from '../services/api';
* import { useAuthStore } from './auth';
* export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);
*/
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
/**
* Factory function to create a companies store
* @param {Object} apiService - Axios instance configured for the app's API
* @param {Function} useAuthStore - Reference to the auth store function
* @returns {Function} Pinia store definition
*/
export function createCompaniesStore(apiService, useAuthStore) {
return defineStore("companies", () => {
// State
const companies = ref([]);
const selectedCompany = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Initialize from localStorage - per user
const initializeSelectedCompany = () => {
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.log("[Companies] No username available for initialization");
return null;
}
const key = `selected_company_${username}`;
const saved = localStorage.getItem(key);
if (saved) {
try {
const company = JSON.parse(saved);
console.log(`[Companies] Loaded saved company for ${username}:`, company.name);
return company;
} catch (e) {
console.error("Failed to parse saved company", e);
localStorage.removeItem(key);
}
}
return null;
};
// Watch for auth user changes to restore selected company
const authStore = useAuthStore();
watch(
() => authStore.user,
(newUser) => {
if (newUser && newUser.username && !selectedCompany.value) {
const restoredCompany = initializeSelectedCompany();
if (restoredCompany) {
selectedCompany.value = restoredCompany;
console.log("[Companies] Restored selected company:", restoredCompany.name);
}
}
},
{ immediate: true }
);
// Getters
const companyList = computed(() => companies.value);
const hasCompanies = computed(() => companies.value.length > 0);
const selectedCompanyId = computed(() => selectedCompany.value?.id_firma || null);
const companyListFormatted = computed(() => {
return companies.value.map((company) => ({
...company,
displayName: company.fiscal_code
? `${company.name} (${company.fiscal_code})`
: company.name,
}));
});
// Actions
const loadCompanies = async () => {
isLoading.value = true;
error.value = null;
try {
console.log("[Companies] Loading companies...");
const response = await apiService.get("/companies");
companies.value = response.data.companies || [];
console.log("[Companies] Loaded", companies.value.length, "companies");
// Validate saved company is still accessible
if (selectedCompany.value) {
const exists = companies.value.find(
(c) => c.id_firma === selectedCompany.value.id_firma
);
if (!exists) {
console.warn("[Companies] Saved company not accessible, clearing");
clearSelectedCompany();
}
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load companies";
console.error("Failed to load companies:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedCompany = (company) => {
selectedCompany.value = company;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.warn("[Companies] Cannot save - no username");
return;
}
const key = `selected_company_${username}`;
if (company) {
localStorage.setItem(key, JSON.stringify(company));
console.log(`[Companies] Saved company for ${username}:`, company.name);
} else {
localStorage.removeItem(key);
}
};
const clearSelectedCompany = () => {
selectedCompany.value = null;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
const getCompanyById = (id_firma) => {
return companies.value.find(
(company) => company.id_firma === parseInt(id_firma)
);
};
const clearError = () => {
error.value = null;
};
const reset = () => {
companies.value = [];
selectedCompany.value = null;
isLoading.value = false;
error.value = null;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
return {
// State
companies,
selectedCompany,
isLoading,
error,
// Getters
companyList,
companyListFormatted,
hasCompanies,
selectedCompanyId,
// Actions
loadCompanies,
setSelectedCompany,
clearSelectedCompany,
getCompanyById,
clearError,
reset,
};
});
}

View File

@@ -0,0 +1,167 @@
/* Shared Header Styles - ROA2WEB */
/* Header Container */
.header-container {
position: sticky;
top: 0;
z-index: var(--z-header, 100);
background: var(--color-bg, #fff);
border-bottom: 1px solid var(--color-border, #e5e7eb);
height: var(--header-height, 60px);
padding: 0 var(--space-lg, 24px);
}
/* Gradient Header Variant */
.header-container--gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-bottom: none;
}
.header-container--gradient .header-brand {
color: white;
}
.header-container--gradient .hamburger-line {
background-color: white;
}
/* Header Navigation */
.header-nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
max-width: 1600px;
margin: 0 auto;
}
/* Header Left Section */
.header-left {
display: flex;
align-items: center;
gap: var(--space-md, 16px);
}
/* Brand/Logo */
.header-brand {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
font-size: var(--text-lg, 18px);
font-weight: var(--font-semibold, 600);
color: var(--color-primary, #2563eb);
text-decoration: none;
white-space: nowrap;
}
.header-brand:hover {
opacity: 0.9;
}
/* Header Actions (right side) */
.header-actions {
display: flex;
align-items: center;
gap: var(--space-md, 16px);
}
/* Hamburger Button */
.hamburger-btn {
display: flex;
flex-direction: column;
justify-content: space-around;
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
z-index: 10;
transition: all 0.3s ease;
}
.hamburger-btn:hover {
opacity: 0.7;
}
.hamburger-line {
width: 100%;
height: 3px;
background-color: var(--color-primary, #2563eb);
border-radius: 2px;
transition: all 0.3s ease;
transform-origin: center;
}
/* Hamburger Animation - X state */
.hamburger-btn.active .hamburger-line:nth-child(1) {
transform: translateY(9px) rotate(45deg);
}
.hamburger-btn.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-btn.active .hamburger-line:nth-child(3) {
transform: translateY(-9px) rotate(-45deg);
}
/* Header User Menu */
.header-user {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: background-color 0.15s ease;
color: var(--color-text, #111827);
}
.header-user:hover {
background-color: var(--color-bg-secondary, #f9fafb);
}
/* Gradient header user menu */
.header-container--gradient .header-user {
color: white;
}
.header-container--gradient .header-user:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.header-container {
padding: 0 var(--space-md, 12px);
}
.header-left {
gap: var(--space-sm, 8px);
}
.header-actions {
gap: var(--space-sm, 8px);
}
.header-brand {
font-size: var(--text-base, 16px);
}
/* Hide text-only elements on mobile */
.desktop-only {
display: none;
}
}
@media (max-width: 480px) {
.header-brand span {
display: none;
}
.header-brand i {
font-size: 1.5rem;
}
}

View File

@@ -0,0 +1,151 @@
/* Shared Navigation Styles - ROA2WEB */
/* Slide-out Menu */
.slide-menu {
position: fixed;
top: var(--header-height, 60px);
left: 0;
width: var(--sidebar-width, 280px);
height: calc(100vh - var(--header-height, 60px));
background: var(--color-bg, #fff);
border-right: 1px solid var(--color-border, #e5e7eb);
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: var(--z-modal, 1000);
overflow-y: auto;
/* Flex container for profile section at bottom */
display: flex;
flex-direction: column;
}
.slide-menu.open {
transform: translateX(0);
}
/* Menu Overlay */
.slide-menu-overlay {
position: fixed;
top: var(--header-height, 60px);
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: var(--z-modal-backdrop, 999);
}
.slide-menu-overlay.open {
opacity: 1;
visibility: visible;
}
/* Menu Sections */
.menu-section {
padding: var(--space-lg, 24px);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.menu-section:last-child {
border-bottom: none;
}
/* Profile section at bottom */
.menu-section.menu-profile {
margin-top: auto;
border-top: 1px solid var(--color-border, #e5e7eb);
border-bottom: none;
}
.menu-title {
font-size: var(--text-sm, 14px);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md, 12px);
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
}
.menu-item {
margin-bottom: var(--space-xs, 4px);
}
.menu-link {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
color: var(--color-text, #111827);
text-decoration: none;
border-radius: var(--radius-md, 6px);
transition: all 0.15s ease;
font-size: var(--text-sm, 14px);
}
.menu-link:hover,
.menu-link.active {
background-color: var(--color-bg-secondary, #f9fafb);
color: var(--color-primary, #2563eb);
}
.menu-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
font-size: 16px;
}
/* Profile Info */
.profile-info {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
margin-bottom: var(--space-sm, 8px);
font-weight: var(--font-medium, 500);
color: var(--color-text, #111827);
}
.profile-info i {
font-size: 1.25rem;
color: var(--color-primary, #2563eb);
}
/* Badge for menu items */
.menu-badge {
margin-left: auto;
background: var(--color-danger, #ef4444);
color: white;
font-size: var(--text-xs, 12px);
font-weight: var(--font-semibold, 600);
padding: 2px 6px;
border-radius: var(--radius-full, 9999px);
min-width: 20px;
text-align: center;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.slide-menu {
width: 280px;
}
.menu-section {
padding: var(--space-md, 12px);
}
}
@media (max-width: 480px) {
.slide-menu {
width: 100vw;
max-width: 320px;
}
}

View File

@@ -0,0 +1,179 @@
/* Shared Login Page Styles */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--color-primary-light) 0%,
var(--color-primary) 100%
);
padding: 1rem;
}
.login-wrapper {
width: 100%;
max-width: 400px;
}
.login-card {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--surface-card);
}
.login-header {
text-align: center;
padding: 2rem 2rem 1rem 2rem;
background: var(--surface-card);
}
.login-title {
margin: 1rem 0 0.5rem 0;
color: var(--primary-color);
font-size: 2rem;
font-weight: 700;
}
.login-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.95rem;
}
.login-form {
padding: 0 2rem 2rem 2rem;
background: var(--surface-card);
}
.login-button {
margin-top: 1rem;
padding: 0.75rem;
font-size: 1.1rem;
font-weight: 600;
background: var(--color-primary-light) !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.login-button:hover {
background: var(--color-primary) !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.login-error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: var(--red-50);
color: var(--red-800);
border: 1px solid var(--red-200);
border-radius: 6px;
font-size: 0.9rem;
}
.login-footer {
text-align: center;
padding: 1rem 2rem;
background-color: var(--surface-50);
border-top: 1px solid var(--surface-200);
}
/* Responsive design */
@media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.login-card {
border-radius: 8px;
}
.login-header {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-form {
padding: 0 1rem 1.5rem 1rem;
}
/* Ensure inputs are touch-friendly */
.login-container .p-inputtext,
.login-container .p-password input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.login-footer {
padding: 1rem;
}
}
@media (max-width: 480px) {
.login-container {
padding: 0.25rem;
}
.login-card {
margin: 0;
}
.login-header {
padding: 1rem 0.5rem;
}
.login-title {
font-size: 1.25rem;
}
.login-subtitle {
font-size: 0.875rem;
}
.login-form {
padding: 0 0.5rem 1rem 0.5rem;
}
.login-footer {
padding: 0.75rem 0.5rem;
}
}
/* Animation for smooth transitions */
.login-card {
animation: loginFadeInUp 0.6s ease-out;
}
@keyframes loginFadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,11 @@
"""Shared Pydantic models for ROA2WEB applications."""
from .company import Company, CompanyListResponse
from .calendar import CalendarPeriod, CalendarPeriodsResponse
__all__ = [
"Company",
"CompanyListResponse",
"CalendarPeriod",
"CalendarPeriodsResponse",
]

View File

@@ -0,0 +1,18 @@
"""Calendar/accounting period models for ROA2WEB applications."""
from typing import List, Optional
from pydantic import BaseModel
class CalendarPeriod(BaseModel):
"""Model for an accounting period."""
an: int # Year
luna: int # Month (1-12)
display_name: str # Format: "Decembrie 2025"
class CalendarPeriodsResponse(BaseModel):
"""Response model for calendar periods list."""
periods: List[CalendarPeriod]
current_period: Optional[CalendarPeriod] = None
total_count: int

View File

@@ -0,0 +1,19 @@
"""Company models for ROA2WEB applications."""
from typing import List, Optional
from pydantic import BaseModel
class Company(BaseModel):
"""Model for a company/firma."""
id_firma: int
name: str
schema_name: str
fiscal_code: Optional[str] = None
is_active: bool = True
class CompanyListResponse(BaseModel):
"""Response model for list of companies."""
companies: List[Company]
total_count: int

View File

@@ -0,0 +1,21 @@
"""
Shared Routes for ROA2WEB Applications
This module provides factory functions for creating common API routers
that can be mounted in both the unified monolith backend.
Usage:
from shared.routes import create_companies_router, create_calendar_router
# In main.py
companies_router = create_companies_router(oracle_pool)
app.include_router(companies_router, prefix="/api/companies")
"""
from .companies import create_companies_router
from .calendar import create_calendar_router
__all__ = [
"create_companies_router",
"create_calendar_router",
]

View File

@@ -0,0 +1,151 @@
"""
Shared Calendar Router Factory for ROA2WEB Applications
Creates a FastAPI router for /api/calendar endpoints that can be used
by both the unified monolith backend.
Usage:
from shared.routes.calendar import create_calendar_router
calendar_router = create_calendar_router(oracle_pool, cache_decorator=cached)
app.include_router(calendar_router, prefix="/api/calendar")
"""
import logging
from typing import Optional, Callable, List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from models.calendar import CalendarPeriod, CalendarPeriodsResponse
logger = logging.getLogger(__name__)
# Romanian month names
MONTH_NAMES_RO = [
"Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie",
"Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"
]
def create_calendar_router(
oracle_pool,
cache_decorator: Optional[Callable] = None,
tags: Optional[List[str]] = None
) -> APIRouter:
"""
Factory function to create a calendar router.
Args:
oracle_pool: The Oracle connection pool instance
cache_decorator: Optional caching decorator (e.g., @cached)
tags: OpenAPI tags for the router
Returns:
Configured FastAPI router for calendar endpoints
"""
router = APIRouter(
redirect_slashes=False,
tags=tags or ["calendar"]
)
# Helper to get schema for company
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
WHERE ID_FIRMA = :company_id
""", {'company_id': company_id})
result = cursor.fetchone()
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', 'server_id']
)(_get_schema_for_company)
# Helper to get periods - can be cached
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(server_id) as connection:
with connection.cursor() as cursor:
cursor.execute(f"""
SELECT ANUL, LUNA
FROM {schema}.CALENDAR
ORDER BY ANUL DESC, LUNA DESC
""")
rows = cursor.fetchall()
periods = []
for row in rows:
an, luna = row[0], row[1]
month_name = MONTH_NAMES_RO[luna - 1]
periods.append(CalendarPeriod(
an=an,
luna=luna,
display_name=f"{month_name} {an}"
))
current_period = periods[0] if periods else None
logger.info(f"Loaded {len(periods)} periods for company {company_id}")
return CalendarPeriodsResponse(
periods=periods,
current_period=current_period,
total_count=len(periods)
)
except Exception as e:
logger.error(f"Error fetching periods for company {company_id}: {e}")
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', '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:
"""
Get available accounting periods for a company.
Returns periods ordered by year DESC, month DESC with Romanian month names.
"""
# Validate company access
if str(company) not in current_user.companies:
raise HTTPException(403, f"Nu aveți acces la firma {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

View File

@@ -0,0 +1,185 @@
"""
Shared Companies Router Factory for ROA2WEB Applications
Creates a FastAPI router for /api/companies endpoints that can be used
by both the unified monolith backend.
Usage:
from shared.routes.companies import create_companies_router
companies_router = create_companies_router(oracle_pool, cache_decorator=cached)
app.include_router(companies_router, prefix="/api/companies")
"""
import logging
from typing import Optional, Callable, List
from fastapi import APIRouter, Depends, HTTPException, Request
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from models.company import Company, CompanyListResponse
logger = logging.getLogger(__name__)
def create_companies_router(
oracle_pool,
cache_decorator: Optional[Callable] = None,
tags: Optional[List[str]] = None
) -> APIRouter:
"""
Factory function to create a companies router.
Args:
oracle_pool: The Oracle connection pool instance
cache_decorator: Optional caching decorator (e.g., @cached)
tags: OpenAPI tags for the router
Returns:
Configured FastAPI router for company endpoints
"""
router = APIRouter(
redirect_slashes=False,
tags=tags or ["companies"]
)
# Helper function to get companies - can be cached
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(server_id) as connection:
with connection.cursor() as cursor:
try:
# Get user ID
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")
return []
user_id = user_row[0]
# Get companies for user (program 2 = data entry/reports)
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA, A.SCHEMA, A.COD_FISCAL
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})
for row in cursor.fetchall():
companies.append(Company(
id_firma=row[0],
name=row[1],
schema_name=row[2],
fiscal_code=row[3],
is_active=True
))
logger.info(f"Found {len(companies)} companies for user {username}")
except Exception as e:
logger.error(f"Error fetching companies: {e}")
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', 'server_id']
)(_get_user_companies_data)
@router.get("", response_model=CompanyListResponse)
@router.get("/", response_model=CompanyListResponse)
async def get_user_companies(
request: Request,
current_user: CurrentUser = Depends(get_current_user)
):
"""Get list of companies the user has access to."""
try:
# 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,
total_count=len(companies)
)
except Exception as e:
logger.error(f"Error in get_user_companies: {e}")
raise HTTPException(500, f"Eroare la obținerea listei de firme: {str(e)}")
@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."""
# Validate access
if company_id not in current_user.companies:
raise HTTPException(403, f"Nu aveți acces la firma {company_id}")
try:
# 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
FROM V_NOM_FIRME
WHERE ID_FIRMA = :company_id
""", {'company_id': int(company_id)})
row = cursor.fetchone()
if not row:
raise HTTPException(404, f"Firma {company_id} nu a fost găsită")
return Company(
id_firma=row[0],
name=row[1],
schema_name=row[2],
fiscal_code=row[3] or "",
is_active=True
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, f"Eroare la obținerea detaliilor firmei: {str(e)}")
@router.get("/{company_id}/validate")
async def validate_company_access(
company_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""Validate if user has access to a company."""
has_access = company_id in current_user.companies
return {
"company_id": company_id,
"has_access": has_access,
"user": current_user.username,
"message": "Acces validat" if has_access else "Acces refuzat"
}
return router

View File

@@ -0,0 +1,192 @@
"""
System routes for server monitoring and logs.
"""
import os
from pathlib import Path
from typing import Optional
from collections import deque
from fastapi import APIRouter, Depends, Query, HTTPException
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
level: Optional[str] = None
class LogsResponse(BaseModel):
"""Response with log entries."""
file: str
lines: list[str]
total_lines: int
showing: int
logs_path: Optional[str] = None
file_exists: bool = True
file_size_kb: Optional[float] = None
def create_system_router() -> APIRouter:
"""
Create system router for logs and monitoring.
"""
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: ANY servers configured via ORACLE_SERVERS
# Shows server dropdown even with 1 server (explicit server selection)
if servers and len(servers) >= 1:
return AuthModeResponse(
mode="multi-server",
supports_email_login=len(servers) > 1 # Email lookup only for 2+ servers
)
# Single-server mode: legacy config (no ORACLE_SERVERS, uses env vars)
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
# Development: backend/logs or ./logs
if os.name == 'nt': # Windows
prod_path = Path(r"C:\inetpub\wwwroot\roa2web\logs")
if prod_path.exists():
return prod_path
# Development fallback
dev_paths = [
Path(__file__).parent.parent.parent / "backend" / "logs",
Path(__file__).parent.parent.parent / "logs",
Path("./logs"),
]
for path in dev_paths:
if path.exists():
return path
return Path("./logs")
@router.get("/logs", response_model=LogsResponse)
async def get_logs(
file: str = Query(default="backend-stderr", description="Log file: backend-stderr or backend-stdout"),
lines: int = Query(default=100, ge=10, le=1000, description="Number of lines to return"),
filter: Optional[str] = Query(default=None, description="Filter text (case-insensitive)"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Get server log entries.
Args:
file: Log file name (backend-stderr or backend-stdout)
lines: Number of lines to return (10-1000)
filter: Optional filter text
Returns:
LogsResponse with log lines
"""
# Validate file name to prevent path traversal
allowed_files = ["backend-stderr", "backend-stdout"]
if file not in allowed_files:
raise HTTPException(status_code=400, detail=f"Invalid file. Allowed: {allowed_files}")
logs_path = get_logs_path()
log_file = logs_path / f"{file}.log"
logs_path_str = str(logs_path.resolve())
if not log_file.exists():
return LogsResponse(
file=file,
lines=[f"Log file not found: {log_file}"],
total_lines=0,
showing=0,
logs_path=logs_path_str,
file_exists=False,
file_size_kb=0
)
try:
# Get file size
file_size_kb = round(log_file.stat().st_size / 1024, 2)
# Read file and get last N lines efficiently
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
# Use deque for efficient tail operation
all_lines = deque(f, maxlen=lines * 2 if filter else lines)
# Apply filter if provided
if filter:
filter_lower = filter.lower()
filtered_lines = [line.rstrip() for line in all_lines if filter_lower in line.lower()]
result_lines = list(filtered_lines)[-lines:]
else:
result_lines = [line.rstrip() for line in all_lines][-lines:]
return LogsResponse(
file=file,
lines=result_lines,
total_lines=len(result_lines),
showing=len(result_lines),
logs_path=logs_path_str,
file_exists=True,
file_size_kb=file_size_kb
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading logs: {str(e)}")
@router.get("/logs/available")
async def get_available_logs(
current_user: CurrentUser = Depends(get_current_user)
):
"""
Get list of available log files.
"""
logs_path = get_logs_path()
if not logs_path.exists():
return {"logs_path": str(logs_path), "files": [], "exists": False}
log_files = []
for f in logs_path.glob("*.log"):
stat = f.stat()
log_files.append({
"name": f.stem,
"size_kb": round(stat.st_size / 1024, 1),
"modified": stat.st_mtime
})
return {
"logs_path": str(logs_path),
"files": sorted(log_files, key=lambda x: x["name"]),
"exists": True
}
return router

View File

@@ -0,0 +1,39 @@
"""
Configurări comune pentru toate aplicațiile ROA2WEB
"""
import os
from typing import List, Dict, Any
from pydantic import BaseSettings
class SharedConfig(BaseSettings):
"""Configurări partajate între microservicii"""
# Database
oracle_user: str = os.getenv('ORACLE_USER', '')
oracle_password: str = os.getenv('ORACLE_PASSWORD', '')
oracle_dsn: str = os.getenv('ORACLE_DSN', '')
# Database Pool
db_min_connections: int = int(os.getenv('DB_MIN_CONNECTIONS', 2))
db_max_connections: int = int(os.getenv('DB_MAX_CONNECTIONS', 10))
db_connection_increment: int = int(os.getenv('DB_CONNECTION_INCREMENT', 1))
# JWT Authentication
jwt_secret_key: str = os.getenv('JWT_SECRET_KEY', 'your-super-secret-jwt-key-change-in-production')
jwt_algorithm: str = os.getenv('JWT_ALGORITHM', 'HS256')
access_token_expire_minutes: int = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', 30))
refresh_token_expire_days: int = int(os.getenv('REFRESH_TOKEN_EXPIRE_DAYS', 7))
# Authentication Settings
auth_cache_ttl_minutes: int = int(os.getenv('AUTH_CACHE_TTL_MINUTES', 15))
rate_limit_max_requests: int = int(os.getenv('RATE_LIMIT_MAX_REQUESTS', 5))
rate_limit_time_window: int = int(os.getenv('RATE_LIMIT_TIME_WINDOW', 300))
# Logging
log_level: str = os.getenv('LOG_LEVEL', 'INFO')
class Config:
env_file = '.env'
# Instance globală
shared_config = SharedConfig()

View File

@@ -0,0 +1,27 @@
"""
Exception handlers comune pentru ROA2WEB
"""
from typing import Any, Dict, Optional
class ROAException(Exception):
"""Exception de bază pentru aplicațiile ROA"""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
self.message = message
self.details = details or {}
super().__init__(self.message)
class DatabaseException(ROAException):
"""Excepții legate de baza de date"""
pass
class AuthenticationException(ROAException):
"""Excepții legate de autentificare"""
pass
class AuthorizationException(ROAException):
"""Excepții legate de autorizare"""
pass
class ValidationException(ROAException):
"""Excepții legate de validare date"""
pass