Files
PecHub/backend/app/services/auth_service.py
T
mgiustini 58a233236c 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
2026-03-18 16:42:01 +01:00

303 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)