mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
feat: Fase 1 – Fondamenta complete (backend FastAPI + auth + permessi)
- docker-compose.yml: PostgreSQL 16, Redis 7, MinIO, Nginx - backend FastAPI: struttura monorepo, config pydantic-settings - modelli SQLAlchemy: tutti i modelli (tenants, users, mailboxes, messages, archival, permissions, labels, audit_log) - migrazione Alembic 0001: schema completo in pure SQL - auth API: login JWT, refresh token rotation, logout, 2FA TOTP (setup/verify/disable) - CRUD utenti: lista, crea, modifica, reset password, soft delete - permessi granulari (Fase 1-A): mailbox_permissions, assegna/revoca/lista - CRUD tenant: gestione super-admin - sicurezza: AES-256-GCM cifratura credenziali IMAP/SMTP, bcrypt password - RLS PostgreSQL: isolamento multi-tenant per request - seed sviluppo: tenant demo + admin + operator - test unit: security (bcrypt, JWT, AES), auth_service - test integration: auth endpoints, users endpoints - CI GitHub Actions: lint (ruff), test (pytest), build Docker, security scan - infra: nginx.conf, redis.conf - Makefile con comandi make dev/test/migrate/seed Definition of Done: ✅ Login, refresh token e TOTP funzionanti ✅ make dev porta in piedi tutto lo stack locale ✅ CI configurata
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Core utilities
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Eccezioni applicative custom per PecFlow.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
# ─── Autenticazione ───────────────────────────────────────────────────────────
|
||||
|
||||
class InvalidCredentialsError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenziali non valide",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class TokenExpiredError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token scaduto",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class TokenInvalidError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token non valido",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class AccountLockedError(HTTPException):
|
||||
def __init__(self, locked_until: str = "") -> None:
|
||||
detail = "Account temporaneamente bloccato per troppi tentativi falliti"
|
||||
if locked_until:
|
||||
detail += f" fino a {locked_until}"
|
||||
super().__init__(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
class AccountDisabledError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account disabilitato",
|
||||
)
|
||||
|
||||
|
||||
class TOTPRequiredError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Autenticazione a due fattori richiesta",
|
||||
)
|
||||
|
||||
|
||||
class TOTPInvalidError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Codice TOTP non valido o scaduto",
|
||||
)
|
||||
|
||||
|
||||
# ─── Autorizzazione ───────────────────────────────────────────────────────────
|
||||
|
||||
class ForbiddenError(HTTPException):
|
||||
def __init__(self, detail: str = "Accesso non autorizzato") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
class PermissionDeniedError(HTTPException):
|
||||
def __init__(self, resource: str = "risorsa") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permessi insufficienti per accedere a questa {resource}",
|
||||
)
|
||||
|
||||
|
||||
# ─── Risorse ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class NotFoundError(HTTPException):
|
||||
def __init__(self, resource: str = "risorsa") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"{resource.capitalize()} non trovata",
|
||||
)
|
||||
|
||||
|
||||
class ConflictError(HTTPException):
|
||||
def __init__(self, detail: str = "Conflitto: risorsa già esistente") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
class TenantLimitExceededError(HTTPException):
|
||||
def __init__(self, resource: str, limit: int) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f"Limite del piano raggiunto: massimo {limit} {resource} per questo tenant",
|
||||
)
|
||||
|
||||
|
||||
# ─── Validazione ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ValidationError(HTTPException):
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=detail,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Structured logging per PecFlow.
|
||||
In produzione (LOG_JSON=true) emette log JSON per aggregatori (Loki, ELK).
|
||||
In sviluppo emette log leggibili colorati.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _build_handler() -> logging.Handler:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
|
||||
if settings.log_json:
|
||||
try:
|
||||
import json
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict[str, Any] = {
|
||||
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
handler.setFormatter(JsonFormatter())
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
fmt = "%(asctime)s %(levelname)-8s %(name)s – %(message)s"
|
||||
handler.setFormatter(logging.Formatter(fmt, datefmt="%H:%M:%S"))
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configura il logging applicativo. Da chiamare all'avvio dell'app."""
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# Rimuovi handler esistenti per evitare duplicati
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(_build_handler())
|
||||
|
||||
# Riduci verbosità librerie rumorose
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(
|
||||
logging.INFO if settings.app_debug else logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Restituisce un logger con il nome specificato."""
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Utility per paginazione standardizzata nelle API.
|
||||
"""
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEFAULT_PAGE_SIZE = 25
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Numero di pagina (1-based)")
|
||||
page_size: int = Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Elementi per pagina (max {MAX_PAGE_SIZE})",
|
||||
)
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
return (self.page - 1) * self.page_size
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
return self.page_size
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Risposta paginata generica."""
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
items: list[T],
|
||||
total: int,
|
||||
params: PaginationParams,
|
||||
) -> "PaginatedResponse[T]":
|
||||
import math
|
||||
pages = math.ceil(total / params.page_size) if params.page_size > 0 else 0
|
||||
return cls(
|
||||
items=items,
|
||||
total=total,
|
||||
page=params.page,
|
||||
page_size=params.page_size,
|
||||
pages=pages,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Modulo sicurezza – cifratura AES-256-GCM, hashing password, JWT utilities.
|
||||
|
||||
ADR-002: Le credenziali IMAP/SMTP vengono cifrate con AES-256-GCM prima di
|
||||
essere scritte in DB. La chiave è in variabile d'ambiente (ENCRYPTION_KEY).
|
||||
Formato storage: base64(nonce_12byte || ciphertext || tag_16byte)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import bcrypt as _bcrypt
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# ─── Password hashing (bcrypt diretto, compatibile con bcrypt 4.x/5.x) ───────
|
||||
_BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Genera hash bcrypt della password (work factor 12)."""
|
||||
pwd_bytes = password.encode("utf-8")
|
||||
salt = _bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
|
||||
return _bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verifica password contro il suo hash."""
|
||||
try:
|
||||
return _bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ─── JWT ──────────────────────────────────────────────────────────────────────
|
||||
def create_access_token(
|
||||
subject: str | UUID,
|
||||
tenant_id: str | UUID,
|
||||
role: str,
|
||||
extra_claims: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Crea un JWT access token con scadenza configurabile.
|
||||
|
||||
Claims standard:
|
||||
- sub: user_id (string)
|
||||
- tid: tenant_id
|
||||
- role: ruolo utente
|
||||
- exp: scadenza
|
||||
- iat: emesso a
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"tid": str(tenant_id),
|
||||
"role": role,
|
||||
"iat": now,
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
if extra_claims:
|
||||
payload.update(extra_claims)
|
||||
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str | UUID, tenant_id: str | UUID) -> str:
|
||||
"""
|
||||
Crea un JWT refresh token con scadenza lunga (30 giorni default).
|
||||
Non contiene il ruolo – viene rivalutato a ogni refresh.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
expire = now + timedelta(days=settings.refresh_token_expire_days)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"tid": str(tenant_id),
|
||||
"iat": now,
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
Decodifica e valida un JWT token.
|
||||
Solleva JWTError se il token è invalido o scaduto.
|
||||
"""
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
|
||||
|
||||
def is_token_valid(token: str, expected_type: str = "access") -> bool:
|
||||
"""Verifica rapidamente la validità del token senza sollevare eccezioni."""
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
return payload.get("type") == expected_type
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
|
||||
# ─── AES-256-GCM cifratura credenziali (ADR-002) ─────────────────────────────
|
||||
def encrypt_credential(plaintext: str) -> str:
|
||||
"""
|
||||
Cifra una stringa con AES-256-GCM usando la chiave applicativa.
|
||||
|
||||
Formato output: base64(nonce_12byte || ciphertext || tag_16byte)
|
||||
Il tag GCM (16 byte) è automaticamente concatenato al ciphertext da AESGCM.
|
||||
"""
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12) # 12 byte nonce raccomandato per GCM
|
||||
|
||||
# AESGCM.encrypt() restituisce ciphertext + tag concatenati
|
||||
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
|
||||
# Concatena nonce + ciphertext_with_tag e codifica in base64
|
||||
raw = nonce + ciphertext_with_tag
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_credential(encrypted: str) -> str:
|
||||
"""
|
||||
Decifra una stringa cifrata con encrypt_credential().
|
||||
Solleva ValueError se la decifratura fallisce (chiave errata o dati corrotti).
|
||||
"""
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(encrypted.encode("ascii"))
|
||||
nonce = raw[:12]
|
||||
ciphertext_with_tag = raw[12:]
|
||||
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
|
||||
return plaintext_bytes.decode("utf-8")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Decifratura fallita: {e}") from e
|
||||
|
||||
|
||||
# ─── Hash sicuro per refresh token storage ────────────────────────────────────
|
||||
import hashlib
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""SHA-256 del token raw per storage sicuro in DB."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
Reference in New Issue
Block a user