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