Fix .gitignore and add missing authentication source files

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 <noreply@anthropic.com>
This commit is contained in:
2025-10-25 15:02:28 +03:00
parent 6b13ffa183
commit f42eff71a6
19 changed files with 5035 additions and 21 deletions

55
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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,
};
});

View File

@@ -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');
});
});

View File

@@ -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'
}
}
};

View File

@@ -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'
]

333
security/secrets_scanner.py Normal file
View File

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

649
shared/auth/README.md Normal file
View File

@@ -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*

23
shared/auth/__init__.py Normal file
View File

@@ -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'
]

395
shared/auth/auth_service.py Normal file
View File

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

540
shared/auth/demo_app.py Normal file
View File

@@ -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 = """
<!DOCTYPE html>
<html>
<head>
<title>ROA2WEB Authentication Demo</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #34495e; margin-top: 30px; }
.endpoint { background: #ecf0f1; padding: 15px; margin: 10px 0; border-radius: 5px; border-left: 4px solid #3498db; }
.method { font-weight: bold; color: #e74c3c; }
.protected { border-left-color: #f39c12; }
.public { border-left-color: #27ae60; }
code { background: #34495e; color: white; padding: 2px 6px; border-radius: 3px; }
.status { padding: 10px; margin: 15px 0; border-radius: 5px; }
.success { background: #d5edda; border: 1px solid #c3e6cb; color: #155724; }
.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
</style>
</head>
<body>
<div class="container">
<h1>🔐 ROA2WEB Authentication Demo</h1>
<div class="status info">
<strong>Status:</strong> Demo aplicație ROA2WEB Authentication System<br>
<strong>Versiune:</strong> 1.0.0<br>
<strong>Timp:</strong> """ + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + """
</div>
<h2>📋 Endpoints Disponibile</h2>
<div class="endpoint public">
<div class="method">GET</div>
<strong>/docs</strong> - Swagger UI pentru testarea API-ului
</div>
<div class="endpoint public">
<div class="method">GET</div>
<strong>/health</strong> - Health check pentru aplicație și database
</div>
<div class="endpoint public">
<div class="method">POST</div>
<strong>/auth/login</strong> - Autentificare utilizator cu username/password
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/auth/me</strong> - Informații utilizator curent (protejat)
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/demo/protected</strong> - Endpoint protejat simplu
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/demo/company/{company_code}</strong> - Endpoint cu verificare acces firmă
</div>
<div class="endpoint protected">
<div class="method">GET</div>
<strong>/demo/admin</strong> - Endpoint cu verificare admin permissions
</div>
<h2>🧪 Cum să testezi</h2>
<ol>
<li>Accesează <a href="/docs">/docs</a> pentru Swagger UI</li>
<li>Folosește <code>POST /auth/login</code> cu credențiale valide</li>
<li>Copiază <code>access_token</code> din răspuns</li>
<li>Click pe "Authorize" în Swagger UI și introdu: <code>Bearer YOUR_TOKEN</code></li>
<li>Testează endpoint-urile protejate</li>
</ol>
<h2>🔧 Configurare</h2>
<p>Pentru a funcționa complet, demo-ul necesită:</p>
<ul>
<li>Variabile de mediu configurate în <code>.env</code></li>
<li>Conexiune la Oracle Database</li>
<li>Utilizatori valizi în sistemul Oracle</li>
</ul>
<div class="status success">
<strong>💡 Tip:</strong> Pentru dezvoltare rapidă, vezi <code>demo_app.py</code>
pentru exemple de integrare a autentificării în aplicațiile tale FastAPI.
</div>
</div>
</body>
</html>
"""
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()

412
shared/auth/dependencies.py Normal file
View File

@@ -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

239
shared/auth/jwt_handler.py Normal file
View File

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

373
shared/auth/middleware.py Normal file
View File

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

231
shared/auth/models.py Normal file
View File

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

433
shared/auth/routes.py Normal file
View File

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