Files
roa2web-service-auto/shared/auth/models.py
Claude Agent b137e80b71 feat: multi-Oracle server support with runtime switching
Complete implementation of multi-server Oracle database support:

Backend:
- Multi-pool Oracle with lazy loading per server
- Email-to-server cache for automatic server discovery
- JWT tokens include server_id claim
- /auth/check-identity and /auth/check-email endpoints
- /auth/my-servers endpoint for listing user's accessible servers
- Server switch with password re-authentication

Frontend:
- New ServerSelector component for header dropdown
- Multi-step login flow (identity → server → password)
- Server switching from header with password modal
- Mobile drawer menu with server selection
- Dark mode support for all new components
- URL bookmark support with ?server= query param

Scripts:
- Unified start.sh replacing start-prod.sh/start-test.sh
- Unified ssh-tunnel.sh with multi-server support
- Updated status.sh for new architecture

Tests:
- E2E tests for multi-server and single-server login flows
- Backend unit tests for all new endpoints
- Oracle multi-pool integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:39:06 +00:00

344 lines
12 KiB
Python

"""
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()