feat: multi-Oracle server support with runtime switching
Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,10 @@ pentru autentificarea utilizatorilor în ecosistemul ROA2WEB.
|
||||
Payload structure:
|
||||
{
|
||||
"username": "string",
|
||||
"user_id": "integer",
|
||||
"user_id": "integer",
|
||||
"companies": ["schema1", "schema2"],
|
||||
"permissions": ["read", "write", "admin"],
|
||||
"server_id": "string|null", // ID-ul serverului Oracle (multi-server mode)
|
||||
"exp": "timestamp",
|
||||
"iat": "timestamp",
|
||||
"type": "access|refresh"
|
||||
@@ -31,6 +32,7 @@ class TokenData(BaseModel):
|
||||
user_id: Optional[int] = Field(default=None, description="ID-ul utilizatorului")
|
||||
companies: List[str] = Field(default_factory=list, description="Lista firmelor accesibile")
|
||||
permissions: List[str] = Field(default_factory=list, description="Lista permisiunilor")
|
||||
server_id: Optional[str] = Field(default=None, description="ID-ul serverului Oracle (pentru multi-server mode)")
|
||||
exp: datetime = Field(description="Data expirării")
|
||||
iat: datetime = Field(description="Data creării")
|
||||
token_type: str = Field(alias="type", description="Tipul token-ului (access/refresh)")
|
||||
@@ -72,67 +74,77 @@ class JWTHandler:
|
||||
logger.warning("Using default JWT secret key! Change JWT_SECRET_KEY in production!")
|
||||
|
||||
def create_access_token(
|
||||
self,
|
||||
username: str,
|
||||
companies: List[str],
|
||||
self,
|
||||
username: str,
|
||||
companies: List[str],
|
||||
user_id: Optional[int] = None,
|
||||
permissions: Optional[List[str]] = None
|
||||
permissions: Optional[List[str]] = None,
|
||||
server_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Creează un JWT access token
|
||||
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
companies: Lista firmelor la care utilizatorul are acces
|
||||
user_id: ID-ul utilizatorului în baza de date
|
||||
permissions: Lista permisiunilor utilizatorului
|
||||
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Token JWT ca string
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
expire = now + timedelta(minutes=self.access_token_expire_minutes)
|
||||
|
||||
|
||||
payload = {
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"companies": companies or [],
|
||||
"permissions": permissions or ["read"],
|
||||
"server_id": server_id,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
|
||||
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
||||
logger.debug(f"Created access token for user {username} with companies: {companies}")
|
||||
|
||||
logger.debug(f"Created access token for user {username} on server {server_id or 'default'} with companies: {companies}")
|
||||
|
||||
return token
|
||||
|
||||
def create_refresh_token(self, username: str, user_id: Optional[int] = None) -> str:
|
||||
def create_refresh_token(
|
||||
self,
|
||||
username: str,
|
||||
user_id: Optional[int] = None,
|
||||
server_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Creează un refresh token cu durată mai mare
|
||||
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
user_id: ID-ul utilizatorului
|
||||
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Refresh token JWT ca string
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
expire = now + timedelta(days=self.refresh_token_expire_days)
|
||||
|
||||
|
||||
payload = {
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"server_id": server_id,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "refresh"
|
||||
}
|
||||
|
||||
|
||||
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
||||
logger.debug(f"Created refresh token for user {username}")
|
||||
|
||||
logger.debug(f"Created refresh token for user {username} on server {server_id or 'default'}")
|
||||
|
||||
return token
|
||||
|
||||
def verify_token(self, token: str) -> Optional[TokenData]:
|
||||
@@ -159,56 +171,69 @@ class JWTHandler:
|
||||
logger.debug(f"Token that failed verification: {token[:50]}...")
|
||||
return None
|
||||
|
||||
def refresh_access_token(self, refresh_token: str, companies: List[str], permissions: Optional[List[str]] = None) -> Optional[str]:
|
||||
def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
companies: List[str],
|
||||
permissions: Optional[List[str]] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Creează un nou access token folosind refresh token-ul
|
||||
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh token-ul valid
|
||||
companies: Lista actualizată a firmelor (poate fi modificată între refresh-uri)
|
||||
permissions: Lista actualizată a permisiunilor
|
||||
|
||||
|
||||
Returns:
|
||||
Noul access token sau None dacă refresh token-ul e invalid
|
||||
"""
|
||||
token_data = self.verify_token(refresh_token)
|
||||
|
||||
|
||||
if not token_data or token_data.token_type != "refresh":
|
||||
logger.warning("Invalid refresh token")
|
||||
return None
|
||||
|
||||
|
||||
# Creează nou access token cu datele din refresh token
|
||||
# Păstrează server_id din refresh token pentru consistență multi-server
|
||||
return self.create_access_token(
|
||||
username=token_data.username,
|
||||
companies=companies,
|
||||
user_id=token_data.user_id,
|
||||
permissions=permissions
|
||||
permissions=permissions,
|
||||
server_id=token_data.server_id
|
||||
)
|
||||
|
||||
def create_token_response(
|
||||
self,
|
||||
username: str,
|
||||
companies: List[str],
|
||||
self,
|
||||
username: str,
|
||||
companies: List[str],
|
||||
user_id: Optional[int] = None,
|
||||
permissions: Optional[List[str]] = None,
|
||||
include_refresh: bool = True
|
||||
include_refresh: bool = True,
|
||||
server_id: Optional[str] = None
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Creează un răspuns complet cu access și refresh token
|
||||
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
companies: Lista firmelor accesibile
|
||||
user_id: ID-ul utilizatorului
|
||||
permissions: Lista permisiunilor
|
||||
include_refresh: Dacă să includă și refresh token
|
||||
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
TokenResponse cu toate token-urile
|
||||
"""
|
||||
access_token = self.create_access_token(username, companies, user_id, permissions)
|
||||
refresh_token = self.create_refresh_token(username, user_id) if include_refresh else None
|
||||
|
||||
access_token = self.create_access_token(
|
||||
username, companies, user_id, permissions, server_id
|
||||
)
|
||||
refresh_token = self.create_refresh_token(
|
||||
username, user_id, server_id
|
||||
) if include_refresh else None
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
|
||||
Reference in New Issue
Block a user