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:
55
.gitignore
vendored
55
.gitignore
vendored
@@ -180,32 +180,45 @@
|
|||||||
*_rsa.pub
|
*_rsa.pub
|
||||||
*_temp_*
|
*_temp_*
|
||||||
*_tmp_*
|
*_tmp_*
|
||||||
*auth*
|
# Sensitive configuration files (specific patterns only)
|
||||||
*cleanup*.json
|
|
||||||
*cleanup*.json
|
|
||||||
*connection*
|
|
||||||
*credential*
|
|
||||||
*dsn*
|
|
||||||
*passwd*
|
*passwd*
|
||||||
*password*
|
*password*.txt
|
||||||
|
*password*.json
|
||||||
|
*password*.yml
|
||||||
|
*password*.yaml
|
||||||
|
*.env.prod
|
||||||
|
*.env.production
|
||||||
|
*.env.staging
|
||||||
*prod.env*
|
*prod.env*
|
||||||
*production.env*
|
*production.env*
|
||||||
*report*.json
|
|
||||||
*report*.json
|
|
||||||
*scan*.json
|
|
||||||
*scan*.json
|
|
||||||
*secret*
|
|
||||||
*security*.json
|
|
||||||
*security*.json
|
|
||||||
*ssh_test*
|
|
||||||
*staging.env*
|
*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_*.bat
|
||||||
*test_*.py
|
|
||||||
*test_*.sh
|
|
||||||
*test_report*
|
|
||||||
*test_results*
|
|
||||||
*token*
|
|
||||||
*tunnel_test*
|
|
||||||
*~
|
*~
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
437
deployment/windows/scripts/Setup-ClaudeAuth.ps1
Normal file
437
deployment/windows/scripts/Setup-ClaudeAuth.ps1
Normal 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
|
||||||
78
reports-app/backend/app/auth_middleware_wrapper.py
Normal file
78
reports-app/backend/app/auth_middleware_wrapper.py
Normal 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
|
||||||
109
reports-app/backend/app/routers/auth.py
Normal file
109
reports-app/backend/app/routers/auth.py
Normal 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"
|
||||||
|
}
|
||||||
116
reports-app/frontend/src/stores/auth.js
Normal file
116
reports-app/frontend/src/stores/auth.js
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
223
reports-app/frontend/tests/e2e/auth/login.spec.js
Normal file
223
reports-app/frontend/tests/e2e/auth/login.spec.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
60
reports-app/frontend/tests/fixtures/auth.js
vendored
Normal file
60
reports-app/frontend/tests/fixtures/auth.js
vendored
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
0
reports-app/telegram-bot/app/auth/__init__.py
Normal file
0
reports-app/telegram-bot/app/auth/__init__.py
Normal file
350
reports-app/telegram-bot/app/auth/linking.py
Normal file
350
reports-app/telegram-bot/app/auth/linking.py
Normal 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
333
security/secrets_scanner.py
Normal 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
649
shared/auth/README.md
Normal 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
23
shared/auth/__init__.py
Normal 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
395
shared/auth/auth_service.py
Normal 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
540
shared/auth/demo_app.py
Normal 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
412
shared/auth/dependencies.py
Normal 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
239
shared/auth/jwt_handler.py
Normal 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
373
shared/auth/middleware.py
Normal 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
231
shared/auth/models.py
Normal 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
433
shared/auth/routes.py
Normal 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)
|
||||||
Reference in New Issue
Block a user