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 = """ + + +
+POST /auth/login cu credențiale valideaccess_token din răspunsBearer YOUR_TOKENPentru a funcționa complet, demo-ul necesită:
+.envdemo_app.py
+ pentru exemple de integrare a autentificării în aplicațiile tale FastAPI.
+