From f42eff71a6c5a75f4a47c684568cf9b9b13228f2 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Sat, 25 Oct 2025 15:02:28 +0300 Subject: [PATCH] Fix .gitignore and add missing authentication source files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes overly broad .gitignore patterns that were excluding important source code files from version control. Previously, wildcard patterns like *auth*, *token*, *secret*, *connection*, and *credential* were excluding ALL files containing these words, including critical application code. Changes: - Updated .gitignore with specific patterns for sensitive config files (*.json, *.txt, *.yml, *.yaml extensions only) - Removed broad wildcards that excluded source code files Added missing source files: - shared/auth/ (9 files): Complete authentication system - JWT handler, middleware, auth service, models, routes - reports-app/backend/app/routers/auth.py: Authentication API router - reports-app/backend/app/auth_middleware_wrapper.py: Middleware wrapper - reports-app/frontend/src/stores/auth.js: Vue.js auth store - reports-app/frontend/tests/: E2E tests and fixtures for auth - reports-app/telegram-bot/app/auth/: Telegram auth linking module - deployment/windows/scripts/Setup-ClaudeAuth.ps1: Windows deployment script - security/secrets_scanner.py: Security scanning utility These files are essential for the application to function and should have been included in the initial commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 55 +- .../windows/scripts/Setup-ClaudeAuth.ps1 | 437 ++++++++++++ .../backend/app/auth_middleware_wrapper.py | 78 +++ reports-app/backend/app/routers/auth.py | 109 +++ reports-app/frontend/src/stores/auth.js | 116 ++++ .../frontend/tests/e2e/auth/login.spec.js | 223 ++++++ reports-app/frontend/tests/fixtures/auth.js | 60 ++ reports-app/telegram-bot/app/auth/__init__.py | 0 reports-app/telegram-bot/app/auth/linking.py | 350 ++++++++++ security/secrets_scanner.py | 333 +++++++++ shared/auth/README.md | 649 ++++++++++++++++++ shared/auth/__init__.py | 23 + shared/auth/auth_service.py | 395 +++++++++++ shared/auth/demo_app.py | 540 +++++++++++++++ shared/auth/dependencies.py | 412 +++++++++++ shared/auth/jwt_handler.py | 239 +++++++ shared/auth/middleware.py | 373 ++++++++++ shared/auth/models.py | 231 +++++++ shared/auth/routes.py | 433 ++++++++++++ 19 files changed, 5035 insertions(+), 21 deletions(-) create mode 100644 deployment/windows/scripts/Setup-ClaudeAuth.ps1 create mode 100644 reports-app/backend/app/auth_middleware_wrapper.py create mode 100644 reports-app/backend/app/routers/auth.py create mode 100644 reports-app/frontend/src/stores/auth.js create mode 100644 reports-app/frontend/tests/e2e/auth/login.spec.js create mode 100644 reports-app/frontend/tests/fixtures/auth.js create mode 100644 reports-app/telegram-bot/app/auth/__init__.py create mode 100644 reports-app/telegram-bot/app/auth/linking.py create mode 100644 security/secrets_scanner.py create mode 100644 shared/auth/README.md create mode 100644 shared/auth/__init__.py create mode 100644 shared/auth/auth_service.py create mode 100644 shared/auth/demo_app.py create mode 100644 shared/auth/dependencies.py create mode 100644 shared/auth/jwt_handler.py create mode 100644 shared/auth/middleware.py create mode 100644 shared/auth/models.py create mode 100644 shared/auth/routes.py diff --git a/.gitignore b/.gitignore index 06cfc6e..44d5656 100644 --- a/.gitignore +++ b/.gitignore @@ -180,32 +180,45 @@ *_rsa.pub *_temp_* *_tmp_* -*auth* -*cleanup*.json -*cleanup*.json -*connection* -*credential* -*dsn* +# Sensitive configuration files (specific patterns only) *passwd* -*password* +*password*.txt +*password*.json +*password*.yml +*password*.yaml +*.env.prod +*.env.production +*.env.staging *prod.env* *production.env* -*report*.json -*report*.json -*scan*.json -*scan*.json -*secret* -*security*.json -*security*.json -*ssh_test* *staging.env* +*credentials*.json +*credentials*.txt +*credentials*.yml +*credentials*.yaml +*secret*.txt +*secret*.json +*secret*.yml +*secret*.yaml +*secrets*.txt +*secrets*.json +*secrets*.yml +*secrets*.yaml +*token*.txt +*token*.json +*dsn*.txt +*dsn*.json +# Security scan and cleanup reports +*cleanup*.json +*report*.json +*scan*.json +*security*.json +# Temporary test scripts (but allow proper test files) +*ssh_test*.sh +*tunnel_test*.sh +temp_test.* +quick_test.* *test_*.bat -*test_*.py -*test_*.sh -*test_report* -*test_results* -*token* -*tunnel_test* *~ *~ .DS_Store diff --git a/deployment/windows/scripts/Setup-ClaudeAuth.ps1 b/deployment/windows/scripts/Setup-ClaudeAuth.ps1 new file mode 100644 index 0000000..a9cfaf8 --- /dev/null +++ b/deployment/windows/scripts/Setup-ClaudeAuth.ps1 @@ -0,0 +1,437 @@ +<# +.SYNOPSIS + Setup Claude Authentication on Windows Server using Claude Pro subscription + +.DESCRIPTION + This script helps authenticate Claude Agent SDK using Claude Pro/Max subscription. + Two methods are supported: + 1. Direct login on server (opens browser for authentication) + 2. Copy credentials from development machine + +.PARAMETER Method + Authentication method: 'login' or 'copy' (default: login) + +.PARAMETER CredentialsPath + Path to credentials file (for 'copy' method) + +.EXAMPLE + .\Setup-ClaudeAuth.ps1 + Interactive login on server (opens browser) + +.EXAMPLE + .\Setup-ClaudeAuth.ps1 -Method copy -CredentialsPath "C:\path\to\credentials.json" + Copy credentials from file + +.NOTES + Author: ROA2WEB Team + Requires: Claude Pro/Max subscription, Python 3.11+ +#> + +[CmdletBinding()] +param( + [ValidateSet('login', 'copy')] + [string]$Method = 'login', + [string]$CredentialsPath = "" +) + +$ErrorActionPreference = "Stop" + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +function Write-Step { + param([string]$Message) + Write-Host "`n[*] $Message" -ForegroundColor Cyan +} + +function Write-Success { + param([string]$Message) + Write-Host " [OK] $Message" -ForegroundColor Green +} + +function Write-Error { + param([string]$Message) + Write-Host " [ERROR] $Message" -ForegroundColor Red +} + +function Write-Warning { + param([string]$Message) + Write-Host " [WARN] $Message" -ForegroundColor Yellow +} + +function Test-ClaudeInstalled { + Write-Step "Checking for Claude Code installation..." + + try { + $result = & claude-code --version 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Success "Claude Code is installed: $result" + return $true + } + } catch { + Write-Warning "Claude Code CLI not found" + return $false + } + + return $false +} + +function Install-ClaudeCode { + Write-Step "Installing Claude Code CLI..." + + try { + # Check if npm is available + $npmVersion = & npm --version 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "npm is not installed. Please install Node.js first." + Write-Host " Download from: https://nodejs.org/" -ForegroundColor Yellow + throw "npm not found" + } + + Write-Success "npm found: v$npmVersion" + + # Install claude-code globally + Write-Step "Installing @anthropic-ai/claude-code via npm..." + & npm install -g @anthropic-ai/claude-code + + if ($LASTEXITCODE -eq 0) { + Write-Success "Claude Code CLI installed successfully" + return $true + } else { + throw "npm install failed" + } + } catch { + Write-Error "Failed to install Claude Code CLI: $_" + return $false + } +} + +function Invoke-ClaudeLogin { + Write-Step "Initiating Claude authentication..." + + Write-Host "`n" + ("=" * 60) -ForegroundColor Yellow + Write-Host " IMPORTANT: Browser Authentication Required" -ForegroundColor Yellow + Write-Host ("=" * 60) -ForegroundColor Yellow + Write-Host "" + Write-Host " 1. A browser window will open" -ForegroundColor White + Write-Host " 2. Log in with your Claude Pro/Max account" -ForegroundColor White + Write-Host " 3. Authorize the application" -ForegroundColor White + Write-Host " 4. Return to this window after authentication" -ForegroundColor White + Write-Host "" + Write-Host ("=" * 60) -ForegroundColor Yellow + Write-Host "" + + $response = Read-Host "Press ENTER to open browser and continue (or Ctrl+C to cancel)" + + try { + Write-Step "Opening browser for authentication..." + & claude-code login + + if ($LASTEXITCODE -eq 0) { + Write-Success "Authentication successful!" + return $true + } else { + Write-Error "Authentication failed or was cancelled" + return $false + } + } catch { + Write-Error "Failed to authenticate: $_" + return $false + } +} + +function Find-CredentialsInPackage { + Write-Step "Searching for credentials in deployment package..." + + # Try to find credentials in common locations + $searchPaths = @( + # If running from scripts/ subdirectory + (Join-Path $PSScriptRoot "..\claude-credentials.json"), + # If running from package root + (Join-Path $PSScriptRoot "claude-credentials.json"), + # If in temp deployment location + "C:\Temp\telegram-bot-deploy\claude-credentials.json", + "C:\Temp\telegram-bot-updated\claude-credentials.json", + # If already in installation directory + "C:\inetpub\wwwroot\roa2web\telegram-bot\claude-credentials.json" + ) + + foreach ($path in $searchPaths) { + $resolved = [System.IO.Path]::GetFullPath($path) + if (Test-Path $resolved) { + Write-Success "Found credentials at: $resolved" + return $resolved + } + } + + Write-Warning "No credentials file found in deployment package" + return $null +} + +function Copy-CredentialsFile { + param([string]$SourcePath) + + Write-Step "Copying credentials from: $SourcePath" + + if (-not (Test-Path $SourcePath)) { + Write-Error "Credentials file not found: $SourcePath" + return $false + } + + try { + # Determine credentials directory (correct location: %USERPROFILE%\.claude\) + $credentialsDir = Join-Path $env:USERPROFILE ".claude" + $credentialsFile = Join-Path $credentialsDir ".credentials.json" + + # Create directory if needed + if (-not (Test-Path $credentialsDir)) { + New-Item -ItemType Directory -Path $credentialsDir -Force | Out-Null + Write-Success "Created credentials directory: $credentialsDir" + } + + # Copy credentials file + Copy-Item -Path $SourcePath -Destination $credentialsFile -Force + Write-Success "Credentials copied successfully" + Write-Success "Location: $credentialsFile" + + return $true + } catch { + Write-Error "Failed to copy credentials: $_" + return $false + } +} + +function Test-ClaudeAuth { + Write-Step "Testing Claude authentication..." + + # Check both possible locations + $possibleLocations = @( + (Join-Path $env:USERPROFILE ".claude\.credentials.json"), # Correct location + (Join-Path $env:APPDATA "claude\credentials.json") # Alternative location + ) + + $credentialsFile = $null + foreach ($location in $possibleLocations) { + if (Test-Path $location) { + $credentialsFile = $location + break + } + } + + if (-not $credentialsFile) { + Write-Warning "Credentials file not found at any expected location" + Write-Host " Checked: $($possibleLocations -join ', ')" -ForegroundColor Gray + return $false + } + + try { + # Read credentials file + $credentials = Get-Content $credentialsFile -Raw | ConvertFrom-Json + + if ($credentials -and $credentials.sessionKey) { + Write-Success "Credentials file found and valid" + Write-Success "Location: $credentialsFile" + Write-Success "Session key: $($credentials.sessionKey.Substring(0, 20))..." + return $true + } else { + Write-Warning "Credentials file exists but appears invalid" + return $false + } + } catch { + Write-Warning "Could not validate credentials: $_" + return $false + } +} + +function Update-EnvFile { + Write-Step "Updating .env file..." + + $envPath = "C:\inetpub\wwwroot\roa2web\telegram-bot\.env" + + if (-not (Test-Path $envPath)) { + Write-Warning ".env file not found at: $envPath" + Write-Host " Please create it manually or run Install-TelegramBot.ps1 first" -ForegroundColor Yellow + return + } + + try { + $envContent = Get-Content $envPath -Raw + + # Check if CLAUDE_API_KEY is set + if ($envContent -match "^CLAUDE_API_KEY=.+$" -and $envContent -notmatch "^CLAUDE_API_KEY=\s*$") { + Write-Success ".env already has CLAUDE_API_KEY set" + Write-Host " Using API key authentication (takes precedence over browser login)" -ForegroundColor Gray + } else { + Write-Success ".env will use Claude Pro subscription (browser login)" + Write-Host " No CLAUDE_API_KEY needed!" -ForegroundColor Green + } + } catch { + Write-Warning "Could not read .env file: $_" + } +} + +function Show-Summary { + Write-Host "`n" + ("=" * 60) -ForegroundColor Cyan + Write-Host " CLAUDE AUTHENTICATION SETUP COMPLETE" -ForegroundColor Green + Write-Host ("=" * 60) -ForegroundColor Cyan + + # Check both possible locations + $possibleLocations = @( + (Join-Path $env:USERPROFILE ".claude\.credentials.json"), + (Join-Path $env:APPDATA "claude\credentials.json") + ) + + $credentialsFile = $null + foreach ($location in $possibleLocations) { + if (Test-Path $location) { + $credentialsFile = $location + break + } + } + + if (-not $credentialsFile) { + $credentialsFile = Join-Path $env:USERPROFILE ".claude\.credentials.json" # Default expected location + } + + Write-Host "`nAuthentication Details:" -ForegroundColor Yellow + Write-Host " Method: Claude Pro/Max Subscription (Browser Login)" + Write-Host " Credentials File: $credentialsFile" + Write-Host " Status: $(if (Test-Path $credentialsFile) { 'Authenticated ✓' } else { 'Not Found' })" + + Write-Host "`nNext Steps:" -ForegroundColor Yellow + Write-Host " 1. Verify .env file: C:\inetpub\wwwroot\roa2web\telegram-bot\.env" + Write-Host " - Remove or leave empty: CLAUDE_API_KEY=" + Write-Host " 2. Restart Telegram bot service:" + Write-Host " cd C:\inetpub\wwwroot\roa2web\telegram-bot\scripts" + Write-Host " .\Restart-TelegramBot.ps1" + Write-Host " 3. Check logs for 'Using claude-code login' message:" + Write-Host " Get-Content C:\inetpub\wwwroot\roa2web\telegram-bot\logs\stdout.log -Tail 50" + + Write-Host "`nTroubleshooting:" -ForegroundColor Yellow + Write-Host " - If authentication fails, re-run: .\Setup-ClaudeAuth.ps1" + Write-Host " - Check credentials: Get-Content '$credentialsFile'" + Write-Host " - Credentials expire after ~30 days (re-authenticate when needed)" + Write-Host " - Expected location: %USERPROFILE%\.claude\.credentials.json" + + Write-Host "`n" + ("=" * 60) -ForegroundColor Cyan +} + +# ============================================================================= +# MAIN SETUP FLOW +# ============================================================================= + +function Main { + Write-Host @" + + ==================================================================== + ROA2WEB Telegram Bot - Claude Authentication Setup + Configure Claude Pro/Max subscription authentication + ==================================================================== + +"@ -ForegroundColor Cyan + + try { + # First, check if credentials exist in deployment package + $packageCredentials = Find-CredentialsInPackage + + if ($packageCredentials -and $Method -eq 'login') { + Write-Host "`n" + ("=" * 60) -ForegroundColor Green + Write-Host " CREDENTIALS FOUND IN DEPLOYMENT PACKAGE!" -ForegroundColor Green + Write-Host ("=" * 60) -ForegroundColor Green + Write-Host "" + Write-Host "Found credentials at: $packageCredentials" -ForegroundColor Cyan + Write-Host "" + + $usePackage = Read-Host "Use these credentials? (Y/N)" + + if ($usePackage -eq "Y" -or $usePackage -eq "y") { + Write-Host "`nUsing credentials from deployment package..." -ForegroundColor Yellow + $copySuccess = Copy-CredentialsFile -SourcePath $packageCredentials + + if (-not $copySuccess) { + throw "Failed to copy credentials from package" + } + + # Skip other methods + $Method = 'package' + } else { + Write-Host "Proceeding with browser login..." -ForegroundColor Gray + } + } + + if ($Method -eq 'login') { + # Method 1: Direct login on server + Write-Host "Method: Direct Browser Login" -ForegroundColor Yellow + + # Check if claude-code is installed + $isInstalled = Test-ClaudeInstalled + + if (-not $isInstalled) { + Write-Step "Claude Code CLI not found. Installing..." + $installed = Install-ClaudeCode + + if (-not $installed) { + throw "Failed to install Claude Code CLI" + } + } + + # Perform login + $loginSuccess = Invoke-ClaudeLogin + + if (-not $loginSuccess) { + throw "Authentication failed" + } + + } elseif ($Method -eq 'copy') { + # Method 2: Copy credentials from file + Write-Host "Method: Copy Credentials from File" -ForegroundColor Yellow + + # If no path provided, try to find automatically + if (-not $CredentialsPath) { + $autoFound = Find-CredentialsInPackage + if ($autoFound) { + Write-Host "`nFound credentials in package: $autoFound" -ForegroundColor Green + $useAuto = Read-Host "Use this file? (Y/N)" + + if ($useAuto -eq "Y" -or $useAuto -eq "y") { + $CredentialsPath = $autoFound + } else { + $CredentialsPath = Read-Host "Enter full path to credentials.json" + } + } else { + $CredentialsPath = Read-Host "Enter full path to credentials.json" + } + } + + $copySuccess = Copy-CredentialsFile -SourcePath $CredentialsPath + + if (-not $copySuccess) { + throw "Failed to copy credentials" + } + } + + # Test authentication + $authValid = Test-ClaudeAuth + + if (-not $authValid) { + Write-Warning "Could not validate authentication. Service may still work." + } + + # Update .env file + Update-EnvFile + + # Show summary + Show-Summary + + Write-Host "`nSetup completed successfully!" -ForegroundColor Green + + } catch { + Write-Host "`n[SETUP FAILED] $_" -ForegroundColor Red + Write-Host $_.ScriptStackTrace -ForegroundColor Red + exit 1 + } +} + +# Run main setup +Main diff --git a/reports-app/backend/app/auth_middleware_wrapper.py b/reports-app/backend/app/auth_middleware_wrapper.py new file mode 100644 index 0000000..ad5a40a --- /dev/null +++ b/reports-app/backend/app/auth_middleware_wrapper.py @@ -0,0 +1,78 @@ +""" +Wrapper pentru AuthenticationMiddleware cu fix pentru endpoint-urile protejate +""" +from fastapi import Request, status +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +import sys +import os + +sys.path.append(os.path.join(os.path.dirname(__file__), '../../../shared')) + +from auth.middleware import AuthenticationMiddleware +from auth.models import AuthError + + +class FixedAuthenticationMiddleware(BaseHTTPMiddleware): + """ + Wrapper pentru AuthenticationMiddleware care aplică fix-ul pentru endpoint-urile protejate + """ + + def __init__(self, app, **kwargs): + super().__init__(app) + # Create the original middleware instance without wrapping in BaseHTTPMiddleware + self.auth_middleware = AuthenticationMiddleware(app, **kwargs) + print("[FIXED MIDDLEWARE] FixedAuthenticationMiddleware initialized") + print(f"[FIXED MIDDLEWARE] Original middleware type: {type(self.auth_middleware)}") + + async def dispatch(self, request: Request, call_next): + """ + Aplică fix-ul pentru endpoint-urile protejate: + - Returnează 401 pentru căile protejate fără token în loc să seteze request.state + """ + path = request.url.path + print(f"[FIXED MIDDLEWARE] Processing path: {path}") + + # Verifică dacă path-ul trebuie exclus + excluded_paths = ["/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json"] + is_excluded = (path == "/" or any(path.startswith(excluded) for excluded in excluded_paths)) + print(f"[FIXED MIDDLEWARE] Checking exclusions for {path}") + print(f"[FIXED MIDDLEWARE] Excluded paths: {excluded_paths}") + print(f"[FIXED MIDDLEWARE] Is excluded: {is_excluded}") + + if is_excluded: + print(f"[FIXED MIDDLEWARE] Path {path} is excluded, skipping auth") + request.state.user = None + request.state.is_authenticated = False + response = await call_next(request) + return response + + # Extrage token-ul + authorization = request.headers.get("Authorization") + print(f"[FIXED MIDDLEWARE] Authorization header: {authorization}") + + if not authorization or not authorization.startswith("Bearer "): + print(f"[FIXED MIDDLEWARE] No valid token for protected path {path}, returning 401") + + 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"} + ) + + # Token există, să îl validez prin middleware-ul original + print(f"[FIXED MIDDLEWARE] Token found, delegating to original middleware") + try: + result = await self.auth_middleware.dispatch(request, call_next) + print(f"[FIXED MIDDLEWARE] Original middleware returned: {type(result)}") + print(f"[FIXED MIDDLEWARE] Request state after middleware: user={getattr(request.state, 'user', 'MISSING')}, is_authenticated={getattr(request.state, 'is_authenticated', 'MISSING')}") + return result + except Exception as e: + print(f"[FIXED MIDDLEWARE] Exception in original middleware: {str(e)}") + raise \ No newline at end of file diff --git a/reports-app/backend/app/routers/auth.py b/reports-app/backend/app/routers/auth.py new file mode 100644 index 0000000..e4cfbf7 --- /dev/null +++ b/reports-app/backend/app/routers/auth.py @@ -0,0 +1,109 @@ +""" +API Router pentru autentificare - Wrapper peste shared auth +""" +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared')) + +from auth.dependencies import get_current_user +from auth.models import LoginRequest, TokenResponse, CurrentUser +from auth.auth_service import auth_service +from pydantic import BaseModel + +router = APIRouter() +security = HTTPBearer() + +class LogoutResponse(BaseModel): + """Răspuns pentru logout""" + message: str + success: bool + +@router.post("/login", response_model=TokenResponse) +async def login(login_request: LoginRequest): + """ + Autentificare utilizator cu username și parola + + Folosește shared auth service pentru validarea credențialelor + și generarea token-urilor JWT + """ + try: + # Folosește shared auth service pentru autentificare + success, token_response, error_message = await auth_service.authenticate_and_create_tokens( + username=login_request.username, + password=login_request.password + ) + + if not success: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=error_message or "Authentication failed", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return token_response + + except HTTPException: + raise # Re-raise HTTP exceptions as-is + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal authentication error" + ) + +@router.post("/logout", response_model=LogoutResponse) +async def logout(current_user: CurrentUser = Depends(get_current_user)): + """ + Logout utilizator + + Pentru moment doar confirmă logout-ul (token-urile JWT nu sunt invalidate server-side) + În viitor poate fi extins cu blacklist de token-uri + """ + try: + return LogoutResponse( + message=f"Utilizatorul {current_user.username} a fost deconectat cu succes", + success=True + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Eroare la logout: {str(e)}" + ) + +@router.get("/me", response_model=CurrentUser) +async def get_current_user_info(current_user: CurrentUser = Depends(get_current_user)): + """ + Obține informațiile utilizatorului curent + + Returnează datele utilizatorului din token-ul JWT + """ + return current_user + +@router.post("/refresh") +async def refresh_token(refresh_token: str): + """ + Reîmprospătează token-ul de acces folosind refresh token-ul + + Această funcție va fi implementată în viitor pentru gestionarea + completă a ciclului de viață al token-urilor + """ + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Refresh token nu este încă implementat" + ) + +@router.get("/validate") +async def validate_token(current_user: CurrentUser = Depends(get_current_user)): + """ + Validează token-ul curent + + Endpoint util pentru frontend să verifice dacă token-ul este încă valid + """ + return { + "valid": True, + "user": current_user.username, + "companies": current_user.companies, + "message": "Token valid" + } \ No newline at end of file diff --git a/reports-app/frontend/src/stores/auth.js b/reports-app/frontend/src/stores/auth.js new file mode 100644 index 0000000..e1bc4d1 --- /dev/null +++ b/reports-app/frontend/src/stores/auth.js @@ -0,0 +1,116 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import { apiService } from "../services/api"; + +export const useAuthStore = 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 loginData = { + username: credentials.username, + password: credentials.password, + }; + + const response = await apiService.post("/auth/login", loginData); + 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"); + // Note: selected_company is now per-user and persists across logout/login + // It's stored as 'selected_company_${username}' in localStorage + + 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; + }; + + initializeAuth(); + + return { + accessToken, + refreshToken, + user, + isLoading, + error, + isAuthenticated, + currentUser, + login, + logout, + refreshAccessToken, + initializeAuth, + clearError, + }; +}); \ No newline at end of file diff --git a/reports-app/frontend/tests/e2e/auth/login.spec.js b/reports-app/frontend/tests/e2e/auth/login.spec.js new file mode 100644 index 0000000..83aa7ce --- /dev/null +++ b/reports-app/frontend/tests/e2e/auth/login.spec.js @@ -0,0 +1,223 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../page-objects/LoginPage.js'; +import { testCredentials } from '../../fixtures/auth.js'; + +test.describe('Authentication - Login Flow', () => { + let loginPage; + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + await loginPage.navigate(); + }); + + test('should display login page correctly', async ({ page }) => { + // Check page title and main elements + await expect(page).toHaveTitle(/ROA Reports/); + + // Check login form elements are visible + await expect(page.locator(loginPage.usernameInput)).toBeVisible(); + await expect(page.locator(loginPage.passwordInput)).toBeVisible(); + await expect(page.locator(loginPage.loginButton)).toBeVisible(); + + // Check page title + const title = await loginPage.getPageTitle(); + expect(title).toContain('ROA Reports'); + }); + + test('should show validation errors for empty fields', async ({ page }) => { + // Check initial state - button should be disabled + expect(await loginPage.isLoginButtonDisabled()).toBe(true); + + // Clear any existing content and verify empty state + await page.fill(loginPage.usernameInput, ''); + await page.fill(loginPage.passwordInput, ''); + await page.click(loginPage.loginCard); // Click outside to trigger validation + + // Wait for Vue reactivity + await page.waitForTimeout(100); + + // Button should remain disabled with empty fields + expect(await loginPage.isLoginButtonDisabled()).toBe(true); + + // Verify form validation classes are applied + const hasInvalidFields = await loginPage.hasInvalidField(); + // Note: validation might not show invalid state until user interaction + }); + + test('should show validation error for empty username', async ({ page }) => { + // Fill only password + await loginPage.fillCredentials('', 'password123'); + + // Trigger validation by clicking outside + await page.click(loginPage.loginCard); + + // Check that login button is disabled + expect(await loginPage.isLoginButtonDisabled()).toBe(true); + }); + + test('should show validation error for empty password', async ({ page }) => { + // Fill only username + await loginPage.fillCredentials('username', ''); + + // Trigger validation by clicking outside + await page.click(loginPage.loginCard); + + // Check that login button is disabled + expect(await loginPage.isLoginButtonDisabled()).toBe(true); + }); + + test('should enable login button with valid input', async ({ page: _page }) => { + // Fill both fields + await loginPage.fillCredentials('testuser', 'testpass'); + + // Check that login button is enabled + expect(await loginPage.isLoginButtonDisabled()).toBe(false); + }); + + test('should handle invalid credentials', async ({ page }) => { + // Mock the API response for invalid login + await page.route('**/api/auth/login', async route => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + detail: 'Invalid credentials' + }), + }); + }); + + // Attempt login with invalid credentials + await loginPage.login(testCredentials.invalid.username, testCredentials.invalid.password); + + // Wait for response + await loginPage.waitForLoginResult(); + + // Check that we're still on login page + expect(await loginPage.isOnLoginPage()).toBe(true); + + // Check that error message appears (via toast or error div) + // Note: Error might be shown via PrimeVue Toast, so we need to check for toast messages + const toastError = page.locator('.p-toast-message-error'); + if (await toastError.isVisible()) { + const errorText = await toastError.textContent(); + expect(errorText).toContain('Eroare'); + } + }); + + test('should handle successful login', async ({ page }) => { + // Mock the API response for successful login + await page.route('**/api/auth/login', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + access_token: 'mock_access_token', + refresh_token: 'mock_refresh_token', + user: { + id: 1, + username: 'testuser', + full_name: 'Test User' + } + }), + }); + }); + + // Mock companies endpoint for dashboard + await page.route('**/api/companies', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { code: 'COMP1', name: 'Company 1' }, + { code: 'COMP2', name: 'Company 2' } + ]), + }); + }); + + // Attempt login with valid credentials + await loginPage.login(testCredentials.valid.username, testCredentials.valid.password); + + // Wait for redirect to dashboard + await page.waitForURL('/dashboard', { timeout: 10000 }); + + // Check that we're on dashboard page + expect(page.url()).toContain('/dashboard'); + }); + + test('should show loading state during login', async ({ page }) => { + // Mock slow API response + await page.route('**/api/auth/login', async route => { + // Delay the response to see loading state + await new Promise(resolve => setTimeout(resolve, 1000)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + access_token: 'mock_access_token', + refresh_token: 'mock_refresh_token', + user: { id: 1, username: 'testuser' } + }), + }); + }); + + // Fill credentials and submit + await loginPage.fillCredentials(testCredentials.valid.username, testCredentials.valid.password); + await loginPage.clickLogin(); + + // Check loading state appears + await expect(page.locator(loginPage.loadingSpinner)).toBeVisible(); + + // Wait for loading to finish + await page.waitForLoadState('networkidle'); + }); + + test('should handle network errors gracefully', async ({ page }) => { + // Mock network error + await page.route('**/api/auth/login', async route => { + await route.abort('failed'); + }); + + // Attempt login + await loginPage.login(testCredentials.valid.username, testCredentials.valid.password); + + // Wait a bit for error handling + await page.waitForTimeout(2000); + + // Should still be on login page + expect(await loginPage.isOnLoginPage()).toBe(true); + + // Check for error message in toast summary or detail + const toastSummary = page.locator('.p-toast-summary'); + const toastDetail = page.locator('.p-toast-detail'); + + // Check if either summary or detail contains error text + const summaryVisible = await toastSummary.isVisible(); + const detailVisible = await toastDetail.isVisible(); + + if (summaryVisible || detailVisible) { + let errorFound = false; + + if (summaryVisible) { + const summaryText = await toastSummary.textContent(); + if (summaryText && summaryText.toLowerCase().includes('eroare')) { + errorFound = true; + } + } + + if (detailVisible) { + const detailText = await toastDetail.textContent(); + if (detailText && detailText.toLowerCase().includes('eroare')) { + errorFound = true; + } + } + + expect(errorFound).toBe(true); + } + }); + + test('should focus username field on page load', async ({ page }) => { + // Check that username field is focused + const focusedElement = await page.locator(':focus'); + await expect(focusedElement).toHaveAttribute('id', 'username'); + }); +}); \ No newline at end of file diff --git a/reports-app/frontend/tests/fixtures/auth.js b/reports-app/frontend/tests/fixtures/auth.js new file mode 100644 index 0000000..3440541 --- /dev/null +++ b/reports-app/frontend/tests/fixtures/auth.js @@ -0,0 +1,60 @@ +export const testCredentials = { + valid: { + username: 'testuser', + password: 'testpass123' + }, + invalid: { + username: 'wronguser', + password: 'wrongpass' + }, + empty: { + username: '', + password: '' + }, + partialValid: { + username: 'testuser', + password: '' + } +}; + +export const expectedMessages = { + loginSuccess: 'Conectare reușită', + loginError: 'Eroare de conectare', + usernameRequired: 'Numele de utilizator este obligatoriu', + passwordRequired: 'Parola este obligatorie', + invalidCredentials: 'Date de conectare incorecte' +}; + +export const apiEndpoints = { + login: '/api/auth/login', + logout: '/api/auth/logout', + refresh: '/api/auth/refresh', + user: '/api/auth/user' +}; + +export const mockApiResponses = { + loginSuccess: { + status: 200, + body: { + access_token: 'mock_access_token', + refresh_token: 'mock_refresh_token', + user: { + id: 1, + username: 'testuser', + full_name: 'Test User' + } + } + }, + loginError: { + status: 401, + body: { + detail: 'Invalid credentials' + } + }, + unauthorized: { + status: 401, + body: { + detail: 'Not authenticated' + } + } +}; \ No newline at end of file diff --git a/reports-app/telegram-bot/app/auth/__init__.py b/reports-app/telegram-bot/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports-app/telegram-bot/app/auth/linking.py b/reports-app/telegram-bot/app/auth/linking.py new file mode 100644 index 0000000..8a8f050 --- /dev/null +++ b/reports-app/telegram-bot/app/auth/linking.py @@ -0,0 +1,350 @@ +""" +Authentication and User Linking Logic + +This module handles the linking process between Telegram users and Oracle ERP accounts. +It manages authentication codes, verifies users through the backend API, and maintains +user sessions with JWT tokens. +""" + +import logging +from typing import Optional, Dict, Any +from datetime import datetime, timedelta + +from telegram import User as TelegramUser + +from app.db.operations import ( + get_user, + create_or_update_user, + link_user_to_oracle, + update_user_tokens, + verify_and_use_auth_code, + is_user_linked +) +from app.api.client import get_backend_client + +logger = logging.getLogger(__name__) + + +async def link_telegram_account( + telegram_user: TelegramUser, + auth_code: str +) -> Optional[Dict[str, Any]]: + """ + Link a Telegram account to an Oracle ERP account using an authentication code. + + Flow: + 1. Verify auth code in database (check exists, not used, not expired) + 2. Extract oracle_username from code + 3. Call backend API to verify user in Oracle and get JWT token + 4. Create/update Telegram user record + 5. Link user to Oracle account with JWT tokens + 6. Return success with user data + + Args: + telegram_user: Telegram User object from python-telegram-bot + auth_code: 8-character authentication code from web frontend + + Returns: + Dict with: + - success: True if linking succeeded + - username: Oracle username + - jwt_token: JWT access token + - companies: List of companies user has access to + OR None if linking failed + + Example: + result = await link_telegram_account(telegram_user, "ABC12345") + if result: + print(f"Linked to {result['username']}") + else: + print("Linking failed") + """ + try: + telegram_user_id = telegram_user.id + telegram_username = telegram_user.username + first_name = telegram_user.first_name + last_name = telegram_user.last_name + + logger.info( + f"Attempting to link Telegram user {telegram_user_id} " + f"(@{telegram_username}) with code {auth_code}" + ) + + # Step 1: Verify auth code + code_data = await verify_and_use_auth_code(auth_code) + + if not code_data: + logger.warning(f"Invalid, expired, or already used auth code: {auth_code}") + return None + + oracle_username = code_data.get('oracle_username') + logger.info(f"Auth code valid for Oracle user: {oracle_username}") + + # Step 2: Create/update Telegram user record (basic info) + user_created = await create_or_update_user( + telegram_user_id=telegram_user_id, + username=telegram_username, + first_name=first_name, + last_name=last_name + ) + + if not user_created: + logger.error(f"Failed to create/update Telegram user {telegram_user_id}") + return None + + # Step 3: Verify user in Oracle and get JWT token via backend API (auto-linking flow) + backend_client = get_backend_client() + async with backend_client: + user_data = await backend_client.verify_user( + oracle_username=oracle_username, + linking_code=auth_code + ) + + if not user_data or not user_data.get('success'): + logger.error(f"Failed to verify Oracle user {oracle_username} via backend") + return None + + # Extract tokens and user info from response + jwt_token = user_data.get('access_token') + jwt_refresh_token = user_data.get('refresh_token', jwt_token) + user_info = user_data.get('user', {}) + companies = user_info.get('companies', []) + permissions = user_info.get('permissions', []) + + # Token expiration (typically 30 minutes for access token) + token_expires_at = datetime.now() + timedelta(minutes=30) + + # Step 4: Link Telegram user to Oracle account + linked = await link_user_to_oracle( + telegram_user_id=telegram_user_id, + oracle_username=oracle_username, + jwt_token=jwt_token, + jwt_refresh_token=jwt_refresh_token, + token_expires_at=token_expires_at + ) + + if not linked: + logger.error(f"Failed to link user {telegram_user_id} to Oracle account") + return None + + logger.info( + f"Successfully linked Telegram user {telegram_user_id} " + f"to Oracle user {oracle_username}" + ) + + return { + "success": True, + "telegram_user_id": telegram_user_id, + "username": oracle_username, + "jwt_token": jwt_token, + "jwt_refresh_token": jwt_refresh_token, + "companies": companies, + "permissions": permissions, + "linked_at": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Error linking Telegram account: {e}", exc_info=True) + return None + + +async def get_user_auth_data(telegram_user_id: int) -> Optional[Dict[str, Any]]: + """ + Get authentication data for a linked Telegram user. + + This function retrieves the user's Oracle account information and JWT tokens. + If the token is expired, it automatically refreshes it. + + Args: + telegram_user_id: Telegram user ID + + Returns: + Dict with: + - telegram_user_id: Telegram user ID + - username: Oracle username + - jwt_token: Valid JWT access token (refreshed if needed) + - jwt_refresh_token: JWT refresh token + - companies: List of companies (fetched if not cached) + OR None if user is not linked or error occurred + + Example: + auth_data = await get_user_auth_data(12345) + if auth_data: + jwt = auth_data['jwt_token'] + # Use JWT for API calls + """ + try: + # Get user from database + user_data = await get_user(telegram_user_id) + + if not user_data: + logger.warning(f"User {telegram_user_id} not found in database") + return None + + if not user_data.get('oracle_username'): + logger.warning(f"User {telegram_user_id} is not linked to Oracle account") + return None + + oracle_username = user_data['oracle_username'] + jwt_token = user_data['jwt_token'] + jwt_refresh_token = user_data['jwt_refresh_token'] + token_expires_at_str = user_data['token_expires_at'] + + # Parse token expiration + token_expires_at = datetime.fromisoformat(token_expires_at_str) if token_expires_at_str else None + + # Check if token is expired or about to expire (< 5 minutes remaining) + token_expired = ( + token_expires_at is None or + datetime.now() >= token_expires_at - timedelta(minutes=5) + ) + + if token_expired: + logger.info(f"Token expired for user {telegram_user_id}, refreshing...") + + # Refresh token via backend API + backend_client = get_backend_client() + async with backend_client: + new_token = await backend_client.refresh_token(jwt_refresh_token) + + if new_token: + # Update token in database + new_expires_at = datetime.now() + timedelta(minutes=30) + await update_user_tokens( + telegram_user_id=telegram_user_id, + jwt_token=new_token, + jwt_refresh_token=jwt_refresh_token, # Keep same refresh token + token_expires_at=new_expires_at + ) + + jwt_token = new_token + logger.info(f"Token refreshed for user {telegram_user_id}") + else: + logger.error(f"Failed to refresh token for user {telegram_user_id}") + return None + + # Fetch user companies (fresh from backend) + backend_client = get_backend_client() + async with backend_client: + companies = await backend_client.get_user_companies(jwt_token) + + return { + "telegram_user_id": telegram_user_id, + "username": oracle_username, + "jwt_token": jwt_token, + "jwt_refresh_token": jwt_refresh_token, + "companies": companies + } + + except Exception as e: + logger.error(f"Error getting user auth data: {e}", exc_info=True) + return None + + +async def check_user_linked(telegram_user_id: int) -> bool: + """ + Check if a Telegram user is linked to an Oracle account. + + Args: + telegram_user_id: Telegram user ID + + Returns: + bool: True if user is linked, False otherwise + + Example: + if await check_user_linked(12345): + print("User is linked") + else: + print("User needs to link account") + """ + try: + return await is_user_linked(telegram_user_id) + except Exception as e: + logger.error(f"Error checking if user is linked: {e}") + return False + + +async def get_user_companies(telegram_user_id: int) -> Optional[list]: + """ + Get list of companies a user has access to. + + This is a convenience function that fetches user auth data and returns + just the companies list. + + Args: + telegram_user_id: Telegram user ID + + Returns: + List of company dicts, or None if user not linked + + Example: + companies = await get_user_companies(12345) + if companies: + for company in companies: + print(f"{company['id']}: {company['nume_firma']}") + """ + try: + auth_data = await get_user_auth_data(telegram_user_id) + + if auth_data: + return auth_data.get('companies', []) + + return None + + except Exception as e: + logger.error(f"Error getting user companies: {e}") + return None + + +async def unlink_user(telegram_user_id: int) -> bool: + """ + Unlink a Telegram user from their Oracle account. + + This removes the linking but keeps the Telegram user record. + Used for account disconnection or security purposes. + + Args: + telegram_user_id: Telegram user ID + + Returns: + bool: True if successfully unlinked + + Example: + if await unlink_user(12345): + print("Account unlinked") + """ + try: + # Set Oracle username and tokens to NULL + from app.db.database import DB_PATH + import aiosqlite + + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.execute(""" + UPDATE telegram_users + SET oracle_username = NULL, + jwt_token = NULL, + jwt_refresh_token = NULL, + token_expires_at = NULL, + linked_at = NULL + WHERE telegram_user_id = ? + """, (telegram_user_id,)) + + await db.commit() + + logger.info(f"User {telegram_user_id} unlinked from Oracle account") + return True + + except Exception as e: + logger.error(f"Error unlinking user {telegram_user_id}: {e}") + return False + + +# Export main functions +__all__ = [ + 'link_telegram_account', + 'get_user_auth_data', + 'check_user_linked', + 'get_user_companies', + 'unlink_user' +] diff --git a/security/secrets_scanner.py b/security/secrets_scanner.py new file mode 100644 index 0000000..008d6e2 --- /dev/null +++ b/security/secrets_scanner.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +🔒 ROA2WEB Secrets Scanner +Advanced secrets detection tool for preventing credential leaks in git repositories. + +Usage: + python security/secrets_scanner.py [--scan-git-history] [--fix-gitignore] [--verbose] + +Features: +- Scans current files for secrets and credentials +- Optional git history scanning for historical leaks +- Automated .gitignore fixes +- Pattern-based detection with high accuracy +- Integration ready for git hooks +""" + +import os +import re +import sys +import subprocess +import argparse +import json +from pathlib import Path +from typing import List, Dict, Set, Tuple +from dataclasses import dataclass, asdict +from datetime import datetime + +@dataclass +class SecurityViolation: + """Represents a detected security violation""" + file_path: str + line_number: int + content: str + pattern_name: str + severity: str + commit_hash: str = "" + +class SecretsScanner: + """Advanced secrets detection scanner""" + + # Critical patterns for secrets detection + CRITICAL_PATTERNS = { + 'oracle_password': r'ORACLE_PASSWORD\s*=\s*[\'"]([^\'"\s]+)[\'"]', + 'user_passwords': r'VALID_USERS\s*=\s*[\'"](\{[^}]*password[^}]*\})[\'"]', + 'jwt_secret': r'JWT_SECRET[_KEY]*\s*=\s*[\'"]([^\'"\s]+)[\'"]', + 'database_dsn': r'DSN\s*=\s*[\'"]([^\'"\s]+)[\'"]', + 'api_key': r'API[_-]?KEY\s*=\s*[\'"]([^\'"\s]{20,})[\'"]', + 'ssh_private_key': r'-----BEGIN [A-Z ]*PRIVATE KEY-----', + 'aws_access_key': r'AKIA[0-9A-Z]{16}', + 'generic_password': r'(?i)(password|passwd|pwd)\s*[:=]\s*[\'"]([^\'"\s]{4,})[\'"]', + 'connection_string': r'(?i)(server|host|endpoint)=[^;]+;.*password=[^;]+', + 'bearer_token': r'Bearer\s+[A-Za-z0-9\-._~+/]+=*', + } + + # Suspicious file patterns + SUSPICIOUS_FILES = { + r'.*\.env(?!\.example)$': 'Environment file', + r'.*_rsa$': 'SSH private key', + r'.*\.pem$': 'PEM certificate/key', + r'.*\.key$': 'Key file', + r'.*secret.*': 'Secret file', + r'.*credential.*': 'Credential file', + r'.*password.*': 'Password file', + r'.*config\.prod.*': 'Production config', + } + + # Safe file extensions to skip + SAFE_EXTENSIONS = { + '.md', '.txt', '.rst', '.pdf', '.png', '.jpg', '.jpeg', '.gif', + '.svg', '.ico', '.mp4', '.avi', '.zip', '.tar', '.gz', '.json', + '.xml', '.css', '.scss', '.less', '.html', '.js', '.ts' + } + + def __init__(self, repo_path: str = "."): + self.repo_path = Path(repo_path) + self.violations: List[SecurityViolation] = [] + self.scanned_files = 0 + self.start_time = datetime.now() + + def scan_file_content(self, file_path: Path) -> List[SecurityViolation]: + """Scan file content for secrets patterns""" + violations = [] + + try: + # Skip binary files and safe extensions + if file_path.suffix.lower() in self.SAFE_EXTENSIONS: + return violations + + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + for line_num, line in enumerate(lines, 1): + for pattern_name, pattern in self.CRITICAL_PATTERNS.items(): + if re.search(pattern, line, re.IGNORECASE): + violations.append(SecurityViolation( + file_path=str(file_path.relative_to(self.repo_path)), + line_number=line_num, + content=line.strip()[:100] + "..." if len(line.strip()) > 100 else line.strip(), + pattern_name=pattern_name, + severity="CRITICAL" if pattern_name in ['oracle_password', 'user_passwords', 'ssh_private_key'] else "HIGH" + )) + + except (UnicodeDecodeError, PermissionError, FileNotFoundError): + pass # Skip files that can't be read + + return violations + + def scan_file_names(self) -> List[SecurityViolation]: + """Scan for suspicious file names""" + violations = [] + + for root, dirs, files in os.walk(self.repo_path): + # Skip .git directory and other VCS + dirs[:] = [d for d in dirs if not d.startswith('.git')] + + for file in files: + file_path = Path(root) / file + rel_path = file_path.relative_to(self.repo_path) + + for pattern, description in self.SUSPICIOUS_FILES.items(): + if re.match(pattern, str(rel_path), re.IGNORECASE): + violations.append(SecurityViolation( + file_path=str(rel_path), + line_number=0, + content=f"Suspicious file: {description}", + pattern_name="suspicious_filename", + severity="HIGH" + )) + + return violations + + def scan_current_files(self) -> None: + """Scan all current files in repository""" + print("🔍 Scanning current files for secrets...") + + # Scan file names first + self.violations.extend(self.scan_file_names()) + + # Scan file contents + for root, dirs, files in os.walk(self.repo_path): + # Skip .git and other VCS directories + dirs[:] = [d for d in dirs if not d.startswith(('.git', '.svn', '.hg'))] + + for file in files: + file_path = Path(root) / file + self.violations.extend(self.scan_file_content(file_path)) + self.scanned_files += 1 + + print(f"✅ Scanned {self.scanned_files} files") + + def scan_git_history(self) -> None: + """Scan git history for secrets (WARNING: can be slow on large repos)""" + print("🕐 Scanning git history for secrets...") + + try: + # Get all commits + result = subprocess.run( + ['git', 'log', '--pretty=format:%H', '--all'], + cwd=self.repo_path, + capture_output=True, + text=True, + check=True + ) + + commits = result.stdout.strip().split('\n')[:50] # Limit to recent 50 commits + + for commit in commits: + if not commit: + continue + + # Get diff for commit + diff_result = subprocess.run( + ['git', 'show', commit, '--pretty=format:', '--name-only'], + cwd=self.repo_path, + capture_output=True, + text=True + ) + + if diff_result.returncode == 0: + # Check diff content + content_result = subprocess.run( + ['git', 'show', commit], + cwd=self.repo_path, + capture_output=True, + text=True + ) + + if content_result.returncode == 0: + lines = content_result.stdout.split('\n') + for line_num, line in enumerate(lines, 1): + if line.startswith(('+', '-')): # Only check added/removed lines + for pattern_name, pattern in self.CRITICAL_PATTERNS.items(): + if re.search(pattern, line, re.IGNORECASE): + self.violations.append(SecurityViolation( + file_path="git_history", + line_number=line_num, + content=line[:100] + "..." if len(line) > 100 else line, + pattern_name=pattern_name, + severity="CRITICAL", + commit_hash=commit + )) + + except subprocess.CalledProcessError: + print("⚠️ Could not scan git history (not a git repo or git not available)") + + def generate_report(self) -> Dict: + """Generate comprehensive security report""" + report = { + 'scan_timestamp': self.start_time.isoformat(), + 'repository_path': str(self.repo_path), + 'summary': { + 'total_violations': len(self.violations), + 'critical_violations': len([v for v in self.violations if v.severity == "CRITICAL"]), + 'high_violations': len([v for v in self.violations if v.severity == "HIGH"]), + 'files_scanned': self.scanned_files + }, + 'violations_by_type': {}, + 'violations': [asdict(v) for v in self.violations] + } + + # Group violations by pattern + for violation in self.violations: + pattern = violation.pattern_name + if pattern not in report['violations_by_type']: + report['violations_by_type'][pattern] = 0 + report['violations_by_type'][pattern] += 1 + + return report + + def print_report(self) -> None: + """Print formatted security report""" + report = self.generate_report() + + print("\n" + "="*80) + print("🔒 ROA2WEB SECURITY SCAN REPORT") + print("="*80) + print(f"📅 Scan Date: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"📁 Repository: {self.repo_path}") + print(f"📊 Files Scanned: {self.scanned_files}") + print("\n📈 SUMMARY:") + print(f" 🚨 Total Violations: {report['summary']['total_violations']}") + print(f" 💀 Critical: {report['summary']['critical_violations']}") + print(f" ⚠️ High: {report['summary']['high_violations']}") + + if report['summary']['total_violations'] == 0: + print("\n✅ NO SECURITY VIOLATIONS FOUND!") + return + + print(f"\n🔍 VIOLATIONS BY PATTERN:") + for pattern, count in report['violations_by_type'].items(): + print(f" {pattern}: {count}") + + print(f"\n📋 DETAILED VIOLATIONS:") + print("-" * 80) + + # Group by severity + critical = [v for v in self.violations if v.severity == "CRITICAL"] + high = [v for v in self.violations if v.severity == "HIGH"] + + if critical: + print("\n💀 CRITICAL VIOLATIONS:") + for v in critical: + print(f" File: {v.file_path}:{v.line_number}") + print(f" Type: {v.pattern_name}") + print(f" Content: {v.content}") + if v.commit_hash: + print(f" Commit: {v.commit_hash}") + print() + + if high: + print("\n⚠️ HIGH VIOLATIONS:") + for v in high: + print(f" File: {v.file_path}:{v.line_number}") + print(f" Type: {v.pattern_name}") + print(f" Content: {v.content}") + if v.commit_hash: + print(f" Commit: {v.commit_hash}") + print() + + def save_report(self, output_file: str = "security_scan_report.json") -> None: + """Save report to JSON file""" + report = self.generate_report() + + with open(output_file, 'w') as f: + json.dump(report, f, indent=2) + + print(f"💾 Report saved to: {output_file}") + +def main(): + parser = argparse.ArgumentParser(description="ROA2WEB Secrets Scanner") + parser.add_argument('--scan-git-history', action='store_true', + help='Scan git history for secrets (slow)') + parser.add_argument('--save-report', metavar='FILE', + help='Save report to JSON file') + parser.add_argument('--repo-path', default='.', + help='Repository path to scan') + parser.add_argument('--verbose', action='store_true', + help='Verbose output') + + args = parser.parse_args() + + scanner = SecretsScanner(args.repo_path) + + # Scan current files + scanner.scan_current_files() + + # Optionally scan git history + if args.scan_git_history: + scanner.scan_git_history() + + # Print report + scanner.print_report() + + # Save report if requested + if args.save_report: + scanner.save_report(args.save_report) + + # Exit with error code if violations found + critical_count = len([v for v in scanner.violations if v.severity == "CRITICAL"]) + if critical_count > 0: + print(f"\n❌ CRITICAL VIOLATIONS FOUND: {critical_count}") + print("🔧 Action Required: Remove secrets and regenerate credentials!") + sys.exit(1) + elif len(scanner.violations) > 0: + print(f"\n⚠️ SECURITY WARNINGS: {len(scanner.violations)}") + print("🔧 Recommended: Review and fix violations") + sys.exit(2) + else: + print("\n✅ Security scan passed!") + sys.exit(0) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/shared/auth/README.md b/shared/auth/README.md new file mode 100644 index 0000000..293b0f2 --- /dev/null +++ b/shared/auth/README.md @@ -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* \ No newline at end of file diff --git a/shared/auth/__init__.py b/shared/auth/__init__.py new file mode 100644 index 0000000..05979fd --- /dev/null +++ b/shared/auth/__init__.py @@ -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' +] \ No newline at end of file diff --git a/shared/auth/auth_service.py b/shared/auth/auth_service.py new file mode 100644 index 0000000..6f1a0dc --- /dev/null +++ b/shared/auth/auth_service.py @@ -0,0 +1,395 @@ +""" +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 +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from 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 verify_user_credentials(self, username: str, password: str) -> bool: + """ + Verifică credențialele utilizatorului folosind pack_drepturi.verificautilizator + + Args: + username: Numele utilizatorului + password: Parola utilizatorului + + 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() 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 + + # 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) -> 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 + + 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 + cached_data = self._get_cached_user_data(username) + if cached_data and 'companies' in cached_data: + return cached_data['companies'] + + try: + async with oracle_pool.get_connection() 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 + self._cache_user_data(username, {'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) -> List[str]: + """ + Obține permisiunile utilizatorului pentru o anumită firmă + + Args: + username: Numele utilizatorului + company: Codul firmei + + Returns: + Lista permisiunilor pentru firma specificată + """ + # Implementare de bază - poate fi extinsă în viitor + companies = await self.get_user_companies(username) + + # 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 + ) -> Tuple[bool, Optional[TokenResponse], Optional[str]]: + """ + Autentifică utilizatorul și creează token-urile JWT + + Args: + username: Numele utilizatorului + password: Parola utilizatorului + + Returns: + Tuple cu (success, token_response, error_message) + """ + try: + # Verifică credențialele + is_valid = await self.verify_user_credentials(username, password) + + if not is_valid: + return False, None, "Invalid username or password" + + # Obține firmele utilizatorului + companies = await self.get_user_companies(username) + + # 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 {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(username, companies[0] if companies else "") + + # Creează token-urile folosind jwt_handler + jwt_tokens = jwt_handler.create_token_response( + username=username, + companies=companies, + user_id=None, # Poate fi adăugat în viitor dacă avem user_id în DB + permissions=permissions + ) + + # Creează obiectul CurrentUser + current_user = CurrentUser( + username=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 {username}") + 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() \ No newline at end of file diff --git a/shared/auth/demo_app.py b/shared/auth/demo_app.py new file mode 100644 index 0000000..829ba1b --- /dev/null +++ b/shared/auth/demo_app.py @@ -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 = """ + + + + ROA2WEB Authentication Demo + + + +
+

🔐 ROA2WEB Authentication Demo

+ +
+ Status: Demo aplicație ROA2WEB Authentication System
+ Versiune: 1.0.0
+ Timp: """ + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + """ +
+ +

📋 Endpoints Disponibile

+ +
+
GET
+ /docs - Swagger UI pentru testarea API-ului +
+ +
+
GET
+ /health - Health check pentru aplicație și database +
+ +
+
POST
+ /auth/login - Autentificare utilizator cu username/password +
+ +
+
GET
+ /auth/me - Informații utilizator curent (protejat) +
+ +
+
GET
+ /demo/protected - Endpoint protejat simplu +
+ +
+
GET
+ /demo/company/{company_code} - Endpoint cu verificare acces firmă +
+ +
+
GET
+ /demo/admin - Endpoint cu verificare admin permissions +
+ +

🧪 Cum să testezi

+ +
    +
  1. Accesează /docs pentru Swagger UI
  2. +
  3. Folosește POST /auth/login cu credențiale valide
  4. +
  5. Copiază access_token din răspuns
  6. +
  7. Click pe "Authorize" în Swagger UI și introdu: Bearer YOUR_TOKEN
  8. +
  9. Testează endpoint-urile protejate
  10. +
+ +

🔧 Configurare

+

Pentru a funcționa complet, demo-ul necesită:

+
    +
  • Variabile de mediu configurate în .env
  • +
  • Conexiune la Oracle Database
  • +
  • Utilizatori valizi în sistemul Oracle
  • +
+ +
+ 💡 Tip: Pentru dezvoltare rapidă, vezi demo_app.py + pentru exemple de integrare a autentificării în aplicațiile tale FastAPI. +
+
+ + + """ + 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() \ No newline at end of file diff --git a/shared/auth/dependencies.py b/shared/auth/dependencies.py new file mode 100644 index 0000000..2a8ae9c --- /dev/null +++ b/shared/auth/dependencies.py @@ -0,0 +1,412 @@ +""" +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 + """ + print(f"[DEPENDENCY DEBUG] get_current_user_from_request called") + print(f"[DEPENDENCY DEBUG] request.state attributes: {dir(request.state)}") + print(f"[DEPENDENCY DEBUG] has is_authenticated: {hasattr(request.state, 'is_authenticated')}") + print(f"[DEPENDENCY DEBUG] is_authenticated value: {getattr(request.state, 'is_authenticated', 'NOT_SET')}") + print(f"[DEPENDENCY DEBUG] has user: {hasattr(request.state, 'user')}") + print(f"[DEPENDENCY DEBUG] user value: {getattr(request.state, 'user', 'NOT_SET')}") + + if not hasattr(request.state, 'is_authenticated') or not request.state.is_authenticated: + print(f"[DEPENDENCY DEBUG] Returning 401: Authentication required") + 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: + print(f"[DEPENDENCY DEBUG] Returning 401: User not found in request") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found in request", + headers={"WWW-Authenticate": "Bearer"}, + ) + + print(f"[DEPENDENCY DEBUG] Returning user: {request.state.user}") + 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 \ No newline at end of file diff --git a/shared/auth/jwt_handler.py b/shared/auth/jwt_handler.py new file mode 100644 index 0000000..f4742b3 --- /dev/null +++ b/shared/auth/jwt_handler.py @@ -0,0 +1,239 @@ +""" +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"], + "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") + 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 + ) -> 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 + + 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"], + "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}") + + return token + + def create_refresh_token(self, username: str, user_id: Optional[int] = None) -> str: + """ + Creează un refresh token cu durată mai mare + + Args: + username: Numele utilizatorului + user_id: ID-ul utilizatorului + + 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, + "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}") + + 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 + return self.create_access_token( + username=token_data.username, + companies=companies, + user_id=token_data.user_id, + permissions=permissions + ) + + def create_token_response( + self, + username: str, + companies: List[str], + user_id: Optional[int] = None, + permissions: Optional[List[str]] = None, + include_refresh: bool = True + ) -> 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 + + 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 + + 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() \ No newline at end of file diff --git a/shared/auth/middleware.py b/shared/auth/middleware.py new file mode 100644 index 0000000..a9b0d9c --- /dev/null +++ b/shared/auth/middleware.py @@ -0,0 +1,373 @@ +""" +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 + """ + print(f"[ORIGINAL MIDDLEWARE] dispatch called for path: {request.url.path}") + start_time = time.time() + path = request.url.path + + # 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 + print(f"[MIDDLEWARE DEBUG] Extracting token for path: {path}") + token = self._extract_token_from_header(request) + print(f"[MIDDLEWARE DEBUG] Extracted token: {token[:30] if token else 'None'}...") + + 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 + print(f"[MIDDLEWARE DEBUG] Validating token: {token[:30]}...") + token_data = jwt_handler.verify_token(token) + print(f"[MIDDLEWARE DEBUG] Token validation result: {token_data}") + + 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 + + logger.debug(f"User {current_user.username} authenticated successfully for path {path}") + + 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) \ No newline at end of file diff --git a/shared/auth/models.py b/shared/auth/models.py new file mode 100644 index 0000000..7be3cdf --- /dev/null +++ b/shared/auth/models.py @@ -0,0 +1,231 @@ +""" +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" + ) + + @validator('username') + def username_alphanumeric(cls, v): + """Validează că username-ul conține doar caractere permise (inclusiv spații)""" + # Permitem litere, cifre, spații, _, și - + allowed_chars = v.replace(' ', '').replace('_', '').replace('-', '') + if not allowed_chars.isalnum(): + raise ValueError('Username-ul poate conține doar litere, cifre, spații, _ și -') + return v.upper() # Convertim la uppercase pentru consistență cu Oracle + + +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ă" + ) + + +# Update la forward references pentru TokenResponse +TokenResponse.model_rebuild() \ No newline at end of file diff --git a/shared/auth/routes.py b/shared/auth/routes.py new file mode 100644 index 0000000..ec6311e --- /dev/null +++ b/shared/auth/routes.py @@ -0,0 +1,433 @@ +""" +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 +) +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 + +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"]) + + @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 + + Args: + login_data: Datele de autentificare (username, password) + request: Request-ul HTTP (pentru rate limiting) + response: Response-ul HTTP (pentru header-e) + + Returns: + Token-urile JWT și informațiile utilizatorului + + Raises: + HTTPException: Pentru credențiale invalide sau erori de sistem + """ + try: + # Log tentativa de autentificare + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Login attempt for user {login_data.username} from IP {client_ip}") + + # Autentifică și creează token-urile + success, token_response, error_message = await auth_service.authenticate_and_create_tokens( + login_data.username, + login_data.password + ) + + if not success: + logger.warning(f"Failed login attempt for user {login_data.username}: {error_message}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=error_message or "Authentication failed" + ) + + # Adaugă informațiile utilizatorului în răspuns + companies = await auth_service.get_user_companies(login_data.username) + current_user = CurrentUser( + username=login_data.username, + companies=companies, + permissions=["read", "reports"], # Permisiuni de bază + last_login=datetime.now() + ) + + token_response.user = current_user + + # 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}") + return token_response + + 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("/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) \ No newline at end of file