mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 20:55:41 +02:00
58a233236c
- 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
303 lines
9.6 KiB
Python
303 lines
9.6 KiB
Python
"""
|
||
Servizio autenticazione – login, JWT, TOTP 2FA, refresh token.
|
||
"""
|
||
|
||
import base64
|
||
import io
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import pyotp
|
||
import qrcode
|
||
from jose import JWTError
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import get_settings
|
||
from app.core.exceptions import (
|
||
AccountDisabledError,
|
||
AccountLockedError,
|
||
InvalidCredentialsError,
|
||
TOTPInvalidError,
|
||
TOTPRequiredError,
|
||
TokenInvalidError,
|
||
)
|
||
from app.core.security import (
|
||
create_access_token,
|
||
create_refresh_token,
|
||
decode_token,
|
||
encrypt_credential,
|
||
decrypt_credential,
|
||
hash_password,
|
||
hash_token,
|
||
verify_password,
|
||
)
|
||
from app.models.audit_log import AuditLog
|
||
from app.models.user import RefreshToken, User
|
||
|
||
settings = get_settings()
|
||
|
||
# Numero massimo di tentativi falliti prima del blocco
|
||
MAX_FAILED_ATTEMPTS = 5
|
||
LOCK_DURATION_MINUTES = 15
|
||
|
||
|
||
class AuthService:
|
||
def __init__(self, db: AsyncSession) -> None:
|
||
self.db = db
|
||
|
||
async def login(
|
||
self,
|
||
email: str,
|
||
password: str,
|
||
totp_code: str | None,
|
||
ip_address: str | None = None,
|
||
user_agent: str | None = None,
|
||
) -> tuple[str, str]:
|
||
"""
|
||
Autentica l'utente con email + password (+ TOTP se abilitato).
|
||
Restituisce (access_token, refresh_token).
|
||
"""
|
||
# 1. Trova utente per email (non filtrare per tenant: l'email è unica globalmente
|
||
# per ora, ma in futuro si potrebbe filtrare per subdomain)
|
||
user = await self._get_user_by_email(email)
|
||
|
||
if not user:
|
||
await self._log_audit(None, None, "auth.login", "failure", ip_address, {"reason": "user_not_found"})
|
||
raise InvalidCredentialsError()
|
||
|
||
# 2. Verifica account attivo
|
||
if not user.is_active:
|
||
raise AccountDisabledError()
|
||
|
||
# 3. Verifica blocco temporaneo
|
||
if user.locked_until and user.locked_until > datetime.now(UTC):
|
||
locked_str = user.locked_until.strftime("%H:%M")
|
||
raise AccountLockedError(locked_until=locked_str)
|
||
|
||
# 4. Verifica password
|
||
if not verify_password(password, user.password_hash):
|
||
await self._handle_failed_login(user)
|
||
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "wrong_password"})
|
||
raise InvalidCredentialsError()
|
||
|
||
# 5. Verifica TOTP (se abilitato)
|
||
if user.totp_enabled:
|
||
if not totp_code:
|
||
raise TOTPRequiredError()
|
||
if not self._verify_totp(user, totp_code):
|
||
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "invalid_totp"})
|
||
raise TOTPInvalidError()
|
||
|
||
# 6. Reset contatori falliti
|
||
await self._reset_failed_login(user)
|
||
|
||
# 7. Genera token
|
||
access_token = create_access_token(
|
||
subject=user.id,
|
||
tenant_id=user.tenant_id,
|
||
role=user.role,
|
||
)
|
||
refresh_token_raw = create_refresh_token(
|
||
subject=user.id,
|
||
tenant_id=user.tenant_id,
|
||
)
|
||
|
||
# 8. Salva refresh token in DB (hash)
|
||
rt = RefreshToken(
|
||
user_id=user.id,
|
||
token_hash=hash_token(refresh_token_raw),
|
||
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
|
||
user_agent=user_agent,
|
||
ip_address=ip_address,
|
||
)
|
||
self.db.add(rt)
|
||
|
||
# 9. Aggiorna last_login_at
|
||
await self.db.execute(
|
||
update(User)
|
||
.where(User.id == user.id)
|
||
.values(last_login_at=datetime.now(UTC))
|
||
)
|
||
|
||
await self._log_audit(user.tenant_id, user.id, "auth.login", "success", ip_address, {})
|
||
|
||
return access_token, refresh_token_raw
|
||
|
||
async def refresh_tokens(self, refresh_token_raw: str) -> tuple[str, str]:
|
||
"""
|
||
Valida il refresh token e restituisce nuova coppia di token.
|
||
Implementa rotation: il vecchio refresh token viene revocato.
|
||
"""
|
||
# Valida struttura JWT
|
||
try:
|
||
payload = decode_token(refresh_token_raw)
|
||
except JWTError:
|
||
raise TokenInvalidError()
|
||
|
||
if payload.get("type") != "refresh":
|
||
raise TokenInvalidError()
|
||
|
||
# Cerca il token in DB
|
||
token_hash = hash_token(refresh_token_raw)
|
||
result = await self.db.execute(
|
||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||
)
|
||
rt = result.scalar_one_or_none()
|
||
|
||
if not rt or not rt.is_valid:
|
||
raise TokenInvalidError()
|
||
|
||
# Carica l'utente
|
||
user_result = await self.db.execute(
|
||
select(User).where(User.id == rt.user_id)
|
||
)
|
||
user = user_result.scalar_one_or_none()
|
||
|
||
if not user or not user.is_active:
|
||
raise TokenInvalidError()
|
||
|
||
# Revoca il vecchio refresh token (rotation)
|
||
rt.revoked_at = datetime.now(UTC)
|
||
|
||
# Genera nuovi token
|
||
new_access = create_access_token(
|
||
subject=user.id,
|
||
tenant_id=user.tenant_id,
|
||
role=user.role,
|
||
)
|
||
new_refresh_raw = create_refresh_token(
|
||
subject=user.id,
|
||
tenant_id=user.tenant_id,
|
||
)
|
||
|
||
# Salva nuovo refresh token
|
||
new_rt = RefreshToken(
|
||
user_id=user.id,
|
||
token_hash=hash_token(new_refresh_raw),
|
||
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
|
||
ip_address=rt.ip_address,
|
||
)
|
||
self.db.add(new_rt)
|
||
|
||
return new_access, new_refresh_raw
|
||
|
||
async def logout(self, refresh_token_raw: str) -> None:
|
||
"""Revoca il refresh token (logout)."""
|
||
token_hash = hash_token(refresh_token_raw)
|
||
result = await self.db.execute(
|
||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||
)
|
||
rt = result.scalar_one_or_none()
|
||
if rt:
|
||
rt.revoked_at = datetime.now(UTC)
|
||
|
||
async def setup_totp(self, user: User) -> dict:
|
||
"""
|
||
Genera segreto TOTP e QR code per l'utente.
|
||
Il segreto viene cifrato e salvato in DB ma TOTP non è ancora attivo
|
||
(richiede verifica con totp_verify).
|
||
"""
|
||
# Genera segreto base32
|
||
secret = pyotp.random_base32()
|
||
|
||
# Cifra il segreto prima di salvarlo
|
||
encrypted_secret = encrypt_credential(secret)
|
||
user.totp_secret = encrypted_secret
|
||
# Non attivare ancora: richiede verifica
|
||
user.totp_enabled = False
|
||
|
||
# Genera URI otpauth://
|
||
totp = pyotp.TOTP(secret)
|
||
uri = totp.provisioning_uri(name=user.email, issuer_name="PecFlow")
|
||
|
||
# Genera QR code
|
||
qr = qrcode.QRCode(version=1, box_size=6, border=4)
|
||
qr.add_data(uri)
|
||
qr.make(fit=True)
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffered = io.BytesIO()
|
||
img.save(buffered, format="PNG")
|
||
qr_b64 = base64.b64encode(buffered.getvalue()).decode("ascii")
|
||
|
||
return {
|
||
"secret": secret,
|
||
"qr_uri": uri,
|
||
"qr_image_base64": f"data:image/png;base64,{qr_b64}",
|
||
}
|
||
|
||
async def verify_and_enable_totp(self, user: User, totp_code: str) -> bool:
|
||
"""
|
||
Verifica il codice TOTP e attiva il 2FA se corretto.
|
||
"""
|
||
if not user.totp_secret:
|
||
return False
|
||
|
||
if not self._verify_totp(user, totp_code):
|
||
raise TOTPInvalidError()
|
||
|
||
user.totp_enabled = True
|
||
return True
|
||
|
||
async def disable_totp(self, user: User) -> None:
|
||
"""Disabilita il 2FA per l'utente."""
|
||
user.totp_secret = None
|
||
user.totp_enabled = False
|
||
|
||
# ─── Private helpers ──────────────────────────────────────────────────────
|
||
|
||
async def _get_user_by_email(self, email: str) -> User | None:
|
||
result = await self.db.execute(
|
||
select(User).where(User.email == email.lower())
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
def _verify_totp(self, user: User, code: str) -> bool:
|
||
"""Verifica il codice TOTP (accetta ±1 intervallo per clock skew)."""
|
||
if not user.totp_secret:
|
||
return False
|
||
try:
|
||
secret = decrypt_credential(user.totp_secret)
|
||
totp = pyotp.TOTP(secret)
|
||
return totp.verify(code, valid_window=1)
|
||
except Exception:
|
||
return False
|
||
|
||
async def _handle_failed_login(self, user: User) -> None:
|
||
"""Incrementa contatore fallimenti, blocca se necessario."""
|
||
new_count = user.failed_login_count + 1
|
||
updates: dict = {"failed_login_count": new_count}
|
||
|
||
if new_count >= MAX_FAILED_ATTEMPTS:
|
||
updates["locked_until"] = datetime.now(UTC) + timedelta(minutes=LOCK_DURATION_MINUTES)
|
||
|
||
await self.db.execute(
|
||
update(User).where(User.id == user.id).values(**updates)
|
||
)
|
||
|
||
async def _reset_failed_login(self, user: User) -> None:
|
||
await self.db.execute(
|
||
update(User)
|
||
.where(User.id == user.id)
|
||
.values(failed_login_count=0, locked_until=None)
|
||
)
|
||
|
||
async def _log_audit(
|
||
self,
|
||
tenant_id: uuid.UUID | None,
|
||
user_id: uuid.UUID | None,
|
||
action: str,
|
||
outcome: str,
|
||
ip_address: str | None,
|
||
payload: dict,
|
||
) -> None:
|
||
log = AuditLog(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
action=action,
|
||
outcome=outcome,
|
||
ip_address=ip_address,
|
||
payload=payload,
|
||
)
|
||
self.db.add(log)
|