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,302 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user