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
|
||||
*_temp_*
|
||||
*_tmp_*
|
||||
*auth*
|
||||
*cleanup*.json
|
||||
*cleanup*.json
|
||||
*connection*
|
||||
*credential*
|
||||
*dsn*
|
||||
# Sensitive configuration files (specific patterns only)
|
||||
*passwd*
|
||||
*password*
|
||||
*password*.txt
|
||||
*password*.json
|
||||
*password*.yml
|
||||
*password*.yaml
|
||||
*.env.prod
|
||||
*.env.production
|
||||
*.env.staging
|
||||
*prod.env*
|
||||
*production.env*
|
||||
*report*.json
|
||||
*report*.json
|
||||
*scan*.json
|
||||
*scan*.json
|
||||
*secret*
|
||||
*security*.json
|
||||
*security*.json
|
||||
*ssh_test*
|
||||
*staging.env*
|
||||
*credentials*.json
|
||||
*credentials*.txt
|
||||
*credentials*.yml
|
||||
*credentials*.yaml
|
||||
*secret*.txt
|
||||
*secret*.json
|
||||
*secret*.yml
|
||||
*secret*.yaml
|
||||
*secrets*.txt
|
||||
*secrets*.json
|
||||
*secrets*.yml
|
||||
*secrets*.yaml
|
||||
*token*.txt
|
||||
*token*.json
|
||||
*dsn*.txt
|
||||
*dsn*.json
|
||||
# Security scan and cleanup reports
|
||||
*cleanup*.json
|
||||
*report*.json
|
||||
*scan*.json
|
||||
*security*.json
|
||||
# Temporary test scripts (but allow proper test files)
|
||||
*ssh_test*.sh
|
||||
*tunnel_test*.sh
|
||||
temp_test.*
|
||||
quick_test.*
|
||||
*test_*.bat
|
||||
*test_*.py
|
||||
*test_*.sh
|
||||
*test_report*
|
||||
*test_results*
|
||||
*token*
|
||||
*tunnel_test*
|
||||
*~
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
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