""" 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)