Implementare la 2FA in Python

L'autenticazione a due fattori (2FA, Two-Factor Authentication) rappresenta oggi uno degli strumenti più efficaci per proteggere gli account utente da accessi non autorizzati. Anche nel caso in cui una password venga compromessa, la 2FA aggiunge un secondo livello di verifica che rende estremamente difficile per un attaccante completare l'accesso. In questo articolo vedremo come implementare da zero un sistema completo di 2FA in Python, utilizzando il protocollo TOTP (Time-based One-Time Password), lo stesso alla base di applicazioni come Google Authenticator, Authy e Microsoft Authenticator.

Come funziona il protocollo TOTP

Il protocollo TOTP, definito nella RFC 6238, genera codici numerici temporanei a partire da due elementi: una chiave segreta condivisa tra server e client, e il tempo corrente. Il funzionamento si basa su un algoritmo HMAC-SHA1 (o varianti come SHA-256 e SHA-512) che riceve in input la chiave segreta e un contatore derivato dal tempo Unix corrente, diviso per un intervallo fisso, generalmente di 30 secondi. Il risultato viene troncato per ottenere un codice numerico, di solito a 6 cifre.

In pratica, il flusso è il seguente: il server genera una chiave segreta casuale e la condivide con l'utente, tipicamente sotto forma di QR code. L'utente scansiona il codice con la propria app di autenticazione. Da quel momento in poi, sia il server sia l'app generano indipendentemente lo stesso codice ogni 30 secondi, senza bisogno di comunicare tra loro. Al momento del login, l'utente inserisce il codice visualizzato dall'app e il server lo confronta con quello generato localmente.

Preparazione dell'ambiente

Per implementare la 2FA in Python utilizzeremo la libreria pyotp, che fornisce un'interfaccia semplice e conforme agli standard per la generazione e la verifica di codici TOTP e HOTP. Utilizzeremo anche qrcode per generare i QR code necessari alla configurazione delle app di autenticazione. Installiamo le dipendenze:

pip install pyotp qrcode[pil]

La libreria pyotp gestisce internamente tutta la complessità dell'algoritmo TOTP, mentre qrcode con il supporto Pillow ci permette di produrre immagini QR sia su file sia in formato terminale.

Generazione della chiave segreta

Il primo passo consiste nel generare una chiave segreta univoca per ciascun utente. Questa chiave, codificata in Base32, sarà il cuore dell'intero meccanismo di autenticazione. È fondamentale che venga generata con un generatore crittograficamente sicuro e conservata in modo protetto lato server.

import pyotp

def generate_secret_key():
    """Genera una chiave segreta casuale per l'utente."""
    # Genera una chiave Base32 di 32 caratteri usando entropia crittografica
    secret_key = pyotp.random_base32()
    return secret_key


# Esempio di utilizzo
user_secret = generate_secret_key()
print(f"Chiave segreta generata: {user_secret}")

La funzione random_base32() di pyotp utilizza internamente os.urandom(), garantendo un livello di entropia adeguato per applicazioni di sicurezza. La chiave risultante è una stringa di 32 caratteri in alfabeto Base32 (lettere maiuscole dalla A alla Z e cifre dal 2 al 7). Ogni chiave generata deve essere associata in modo univoco a un singolo utente e memorizzata nel database in forma cifrata, mai in chiaro.

Creazione dell'URI e del QR code

Per consentire all'utente di configurare la propria app di autenticazione, è necessario fornirgli un URI nel formato otpauth://, uno standard riconosciuto da tutte le principali app. Questo URI contiene la chiave segreta, il nome dell'emittente (la nostra applicazione) e l'identificativo dell'utente. Il modo più comodo per trasmetterlo è attraverso un QR code.

import pyotp
import qrcode

def generate_provisioning_uri(secret_key, user_email, issuer_name):
    """Genera l'URI otpauth:// per la configurazione dell'app di autenticazione."""
    # Crea l'oggetto TOTP con la chiave segreta dell'utente
    totp = pyotp.TOTP(secret_key)

    # Costruisce l'URI nel formato standard otpauth://
    provisioning_uri = totp.provisioning_uri(
        name=user_email,
        issuer_name=issuer_name
    )
    return provisioning_uri


def create_qr_code(provisioning_uri, output_path):
    """Genera un'immagine QR code a partire dall'URI di provisioning."""
    # Configura il generatore QR con correzione d'errore alta
    qr = qrcode.QRCode(
        version=1,
        error_correction=qrcode.constants.ERROR_CORRECT_H,
        box_size=10,
        border=4,
    )

    # Inserisce i dati e genera l'immagine
    qr.add_data(provisioning_uri)
    qr.make(fit=True)

    qr_image = qr.make_image(fill_color="black", back_color="white")
    qr_image.save(output_path)
    return output_path


# Esempio di utilizzo
secret = pyotp.random_base32()
uri = generate_provisioning_uri(secret, "mario.rossi@example.com", "MiaApp")
create_qr_code(uri, "qrcode_2fa.png")

print(f"URI: {uri}")
print("QR code salvato in qrcode_2fa.png")

L'URI generato avrà un formato simile a otpauth://totp/MiaApp:mario.rossi@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MiaApp. Il parametro issuer compare nel nome visualizzato dall'app di autenticazione e aiuta l'utente a distinguere tra i diversi servizi configurati. Il livello di correzione d'errore ERROR_CORRECT_H consente al QR code di rimanere leggibile anche se parzialmente oscurato o danneggiato, il che lo rende adatto alla visualizzazione su schermi di varie dimensioni.

Verifica del codice TOTP

La verifica del codice inserito dall'utente è l'operazione più critica dell'intero flusso. Il server genera il codice TOTP atteso a partire dalla chiave segreta memorizzata e lo confronta con quello fornito dall'utente. È buona pratica includere una finestra di tolleranza per compensare eventuali sfasamenti tra l'orologio del server e quello del dispositivo dell'utente.

import pyotp
import time

def verify_totp_code(secret_key, user_code, tolerance_window=1):
    """
    Verifica un codice TOTP inserito dall'utente.
    Il parametro tolerance_window consente uno sfasamento temporale
    di N intervalli (ogni intervallo dura 30 secondi).
    """
    totp = pyotp.TOTP(secret_key)

    # Verifica il codice con la tolleranza specificata
    is_valid = totp.verify(user_code, valid_window=tolerance_window)
    return is_valid


def get_current_totp(secret_key):
    """Restituisce il codice TOTP corrente e il tempo residuo."""
    totp = pyotp.TOTP(secret_key)

    # Calcola il codice attuale
    current_code = totp.now()

    # Calcola quanti secondi mancano alla scadenza del codice
    time_remaining = 30 - (int(time.time()) % 30)

    return current_code, time_remaining


# Esempio di utilizzo
secret = "JBSWY3DPEHPK3PXP"  # Chiave di esempio
code, remaining = get_current_totp(secret)
print(f"Codice attuale: {code} (scade tra {remaining} secondi)")

# Simulazione di verifica
test_code = input("Inserisci il codice 2FA: ")
if verify_totp_code(secret, test_code):
    print("Codice valido. Accesso consentito.")
else:
    print("Codice non valido. Accesso negato.")

Il parametro valid_window con valore 1 significa che il server accetterà il codice corrente, quello immediatamente precedente e quello immediatamente successivo, coprendo un arco di circa 90 secondi. Questo valore rappresenta un buon compromesso tra sicurezza e usabilità. Aumentarlo troppo ridurrebbe la protezione offerta dalla natura temporale dei codici, mentre eliminarlo del tutto causerebbe rifiuti frequenti per utenti con orologi leggermente sfasati.

Protezione contro gli attacchi di replay

Un aspetto spesso trascurato è la protezione contro gli attacchi di replay, in cui un attaccante intercetta un codice TOTP valido e tenta di riutilizzarlo entro la finestra di validità. Per prevenire questo scenario, è necessario tenere traccia dell'ultimo codice utilizzato con successo da ciascun utente e rifiutare qualsiasi tentativo di riutilizzo.

import time
import hashlib

class ReplayProtectedTOTPVerifier:
    """Verificatore TOTP con protezione contro attacchi di replay."""

    def __init__(self):
        # Dizionario che memorizza l'ultimo timestamp di verifica per ogni utente
        self._last_used_timestamps = {}

    def _get_time_counter(self):
        """Calcola il contatore temporale corrente (intervalli di 30 secondi)."""
        return int(time.time()) // 30

    def _hash_code(self, code):
        """Genera un hash del codice per evitare di memorizzarlo in chiaro."""
        return hashlib.sha256(code.encode()).hexdigest()

    def verify(self, user_id, secret_key, user_code, tolerance_window=1):
        """
        Verifica il codice TOTP impedendo il riutilizzo dello stesso codice.
        Restituisce True solo se il codice è valido e non è già stato usato.
        """
        import pyotp

        totp = pyotp.TOTP(secret_key)

        # Verifica la validità del codice
        if not totp.verify(user_code, valid_window=tolerance_window):
            return False

        # Controlla che il codice non sia già stato utilizzato
        current_counter = self._get_time_counter()
        code_hash = self._hash_code(user_code)
        last_entry = self._last_used_timestamps.get(user_id)

        if last_entry is not None:
            last_counter, last_hash = last_entry
            # Rifiuta se lo stesso codice viene ripresentato nello stesso intervallo
            if last_hash == code_hash and abs(current_counter - last_counter) <= tolerance_window:
                return False

        # Registra il codice come utilizzato
        self._last_used_timestamps[user_id] = (current_counter, code_hash)
        return True


# Esempio di utilizzo
verifier = ReplayProtectedTOTPVerifier()
secret = "JBSWY3DPEHPK3PXP"

# Prima verifica: successo
result_first = verifier.verify("user_001", secret, "123456")
print(f"Prima verifica: {result_first}")

# Seconda verifica con lo stesso codice: rifiutata
result_second = verifier.verify("user_001", secret, "123456")
print(f"Seconda verifica (replay): {result_second}")

Questa implementazione memorizza in un dizionario l'hash dell'ultimo codice verificato con successo insieme al contatore temporale. In un ambiente di produzione, queste informazioni andrebbero persistite in un datastore rapido come Redis, con una scadenza automatica delle chiavi pari a due volte l'intervallo TOTP (60 secondi per un intervallo di 30), così da evitare un accumulo indefinito di dati.

Generazione dei codici di backup

I codici di backup sono essenziali per garantire all'utente l'accesso al proprio account in caso di smarrimento o rottura del dispositivo su cui è configurata l'app di autenticazione. Si tratta di codici monouso, generati al momento dell'attivazione della 2FA e consegnati all'utente affinché li conservi in un luogo sicuro.

import secrets
import hashlib

def generate_backup_codes(count=10, code_length=8):
    """
    Genera un set di codici di backup monouso.
    Restituisce sia i codici in chiaro (da mostrare all'utente)
    sia i rispettivi hash (da memorizzare nel database).
    """
    plain_codes = []
    hashed_codes = []

    for _ in range(count):
        # Genera un codice casuale crittograficamente sicuro
        raw_code = secrets.token_hex(code_length // 2)

        # Formatta il codice in gruppi di 4 per leggibilità (es. "a1b2-c3d4")
        formatted_code = "-".join(
            raw_code[i:i+4] for i in range(0, len(raw_code), 4)
        )

        # Calcola l'hash per la memorizzazione sicura
        code_hash = hashlib.sha256(raw_code.encode()).hexdigest()

        plain_codes.append(formatted_code)
        hashed_codes.append(code_hash)

    return plain_codes, hashed_codes


def verify_backup_code(input_code, stored_hashes):
    """
    Verifica un codice di backup e lo invalida dopo l'uso.
    Restituisce l'indice del codice usato, oppure -1 se non valido.
    """
    # Rimuove i trattini dal codice inserito dall'utente
    clean_code = input_code.replace("-", "")
    code_hash = hashlib.sha256(clean_code.encode()).hexdigest()

    # Cerca il codice tra quelli ancora validi
    for index, stored_hash in enumerate(stored_hashes):
        if stored_hash is not None and stored_hash == code_hash:
            # Invalida il codice usato sostituendolo con None
            stored_hashes[index] = None
            return index

    return -1


# Esempio di utilizzo
plain, hashes = generate_backup_codes()

print("Codici di backup generati:")
for i, code in enumerate(plain, 1):
    print(f"  {i:2d}. {code}")

# Simulazione di verifica
test = plain[3]  # Prende il quarto codice per il test
result = verify_backup_code(test, hashes)
print(f"\nVerifica del codice '{test}': indice {result}")

# Tentativo di riutilizzo
result_reuse = verify_backup_code(test, hashes)
print(f"Riutilizzo dello stesso codice: indice {result_reuse}")

I codici vengono generati tramite secrets.token_hex(), che utilizza il generatore crittografico del sistema operativo. Nel database vanno memorizzati esclusivamente gli hash SHA-256 dei codici, mai i codici stessi. Quando un codice viene usato, il corrispondente hash viene annullato per impedirne il riutilizzo. È consigliabile rigenerare l'intero set quando il numero di codici rimanenti scende sotto una soglia minima (ad esempio 3) e notificare l'utente.

Rate limiting sui tentativi di verifica

Un sistema di 2FA senza limiti sul numero di tentativi è vulnerabile ad attacchi di forza bruta. Un codice TOTP a 6 cifre offre un milione di combinazioni possibili, ma con una finestra di tolleranza di 3 intervalli (90 secondi), un attaccante avrebbe tempo sufficiente per provare migliaia di combinazioni se non viene limitato. Implementare un meccanismo di rate limiting è dunque indispensabile.

import time
from collections import defaultdict

class RateLimiter:
    """Limitatore di frequenza per i tentativi di verifica 2FA."""

    def __init__(self, max_attempts=5, lockout_duration=300):
        # Numero massimo di tentativi prima del blocco
        self._max_attempts = max_attempts
        # Durata del blocco in secondi (default: 5 minuti)
        self._lockout_duration = lockout_duration
        # Registro dei tentativi per ogni utente
        self._attempts = defaultdict(list)
        # Registro dei blocchi attivi
        self._lockouts = {}

    def is_locked(self, user_id):
        """Controlla se l'utente è attualmente bloccato."""
        if user_id not in self._lockouts:
            return False

        lockout_until = self._lockouts[user_id]
        if time.time() >= lockout_until:
            # Il blocco è scaduto, rimuovilo
            del self._lockouts[user_id]
            self._attempts[user_id].clear()
            return False

        return True

    def get_lockout_remaining(self, user_id):
        """Restituisce i secondi rimanenti del blocco, oppure 0."""
        if user_id not in self._lockouts:
            return 0
        remaining = self._lockouts[user_id] - time.time()
        return max(0, int(remaining))

    def record_attempt(self, user_id, success):
        """
        Registra un tentativo di verifica.
        In caso di successo, azzera il contatore.
        In caso di fallimento, incrementa e verifica la soglia.
        """
        now = time.time()

        if success:
            # Azzera tutti i tentativi dopo un accesso riuscito
            self._attempts[user_id].clear()
            if user_id in self._lockouts:
                del self._lockouts[user_id]
            return

        # Rimuove i tentativi più vecchi di 10 minuti
        cutoff = now - 600
        self._attempts[user_id] = [
            t for t in self._attempts[user_id] if t > cutoff
        ]

        # Registra il nuovo tentativo fallito
        self._attempts[user_id].append(now)

        # Se il limite è raggiunto, attiva il blocco
        if len(self._attempts[user_id]) >= self._max_attempts:
            self._lockouts[user_id] = now + self._lockout_duration
            self._attempts[user_id].clear()


# Esempio di utilizzo
limiter = RateLimiter(max_attempts=3, lockout_duration=60)

user = "user_001"

# Simulazione di tre tentativi falliti
for attempt in range(4):
    if limiter.is_locked(user):
        remaining = limiter.get_lockout_remaining(user)
        print(f"Tentativo {attempt + 1}: BLOCCATO (riprova tra {remaining}s)")
    else:
        print(f"Tentativo {attempt + 1}: codice errato")
        limiter.record_attempt(user, success=False)

In questo esempio, dopo 3 tentativi falliti l'utente viene bloccato per 60 secondi. In un ambiente reale, è opportuno adottare una strategia di blocco progressivo: ad esempio, 1 minuto dopo 3 tentativi, 5 minuti dopo 6, 30 minuti dopo 9, e così via. I tentativi falliti andrebbero registrati anche in un sistema di logging per consentire l'analisi di eventuali attacchi e l'attivazione di alert automatici.

Integrazione completa in un flusso di autenticazione

Vediamo ora come tutti i componenti precedenti si combinano in un sistema coerente. La classe seguente rappresenta un servizio di autenticazione 2FA completo, adatto a essere integrato in un'applicazione web tramite un framework come Flask o FastAPI.

import pyotp
import hashlib
import secrets
import time
from collections import defaultdict

class TwoFactorAuthService:
    """Servizio completo di autenticazione a due fattori."""

    def __init__(self, issuer_name, max_attempts=5, lockout_seconds=300):
        # Nome dell'applicazione mostrato nelle app di autenticazione
        self._issuer_name = issuer_name
        self._rate_limiter = RateLimiter(max_attempts, lockout_seconds)
        self._replay_verifier = ReplayProtectedTOTPVerifier()

        # In produzione, questi dati risiedono nel database
        self._user_secrets = {}
        self._user_backup_hashes = {}
        self._user_2fa_enabled = {}

    def enroll_user(self, user_id, user_email):
        """
        Avvia il processo di registrazione 2FA per un utente.
        Restituisce la chiave segreta e l'URI per il QR code.
        """
        # Genera una nuova chiave segreta
        secret_key = pyotp.random_base32()
        self._user_secrets[user_id] = secret_key

        # Genera l'URI per la configurazione
        totp = pyotp.TOTP(secret_key)
        provisioning_uri = totp.provisioning_uri(
            name=user_email,
            issuer_name=self._issuer_name
        )

        # Genera i codici di backup
        plain_codes, hashed_codes = generate_backup_codes()
        self._user_backup_hashes[user_id] = hashed_codes

        # La 2FA non è ancora attiva finché l'utente non conferma
        self._user_2fa_enabled[user_id] = False

        return {
            "secret_key": secret_key,
            "provisioning_uri": provisioning_uri,
            "backup_codes": plain_codes,
        }

    def confirm_enrollment(self, user_id, verification_code):
        """
        Conferma l'attivazione della 2FA verificando un primo codice.
        Questo assicura che l'utente abbia configurato correttamente l'app.
        """
        secret = self._user_secrets.get(user_id)
        if secret is None:
            return {"success": False, "error": "Utente non trovato."}

        totp = pyotp.TOTP(secret)
        if totp.verify(verification_code, valid_window=1):
            self._user_2fa_enabled[user_id] = True
            return {"success": True, "message": "2FA attivata con successo."}

        return {"success": False, "error": "Codice non valido. Riprova."}

    def verify_login(self, user_id, code):
        """
        Verifica un codice 2FA durante il login.
        Gestisce rate limiting, protezione replay e codici di backup.
        """
        # Controlla se l'utente è bloccato
        if self._rate_limiter.is_locked(user_id):
            remaining = self._rate_limiter.get_lockout_remaining(user_id)
            return {
                "success": False,
                "error": f"Troppi tentativi. Riprova tra {remaining} secondi.",
                "locked": True,
            }

        secret = self._user_secrets.get(user_id)
        if secret is None or not self._user_2fa_enabled.get(user_id):
            return {"success": False, "error": "2FA non configurata."}

        # Tenta la verifica TOTP con protezione replay
        if self._replay_verifier.verify(user_id, secret, code):
            self._rate_limiter.record_attempt(user_id, success=True)
            return {"success": True, "method": "totp"}

        # Se il TOTP fallisce, prova come codice di backup
        backup_hashes = self._user_backup_hashes.get(user_id, [])
        backup_index = verify_backup_code(code, backup_hashes)

        if backup_index >= 0:
            self._rate_limiter.record_attempt(user_id, success=True)
            remaining_backups = sum(1 for h in backup_hashes if h is not None)
            return {
                "success": True,
                "method": "backup",
                "remaining_backup_codes": remaining_backups,
            }

        # Nessun metodo ha funzionato
        self._rate_limiter.record_attempt(user_id, success=False)
        return {"success": False, "error": "Codice non valido."}

    def disable_2fa(self, user_id, verification_code):
        """Disattiva la 2FA previa verifica del codice corrente."""
        secret = self._user_secrets.get(user_id)
        if secret is None:
            return {"success": False, "error": "Utente non trovato."}

        totp = pyotp.TOTP(secret)
        if not totp.verify(verification_code, valid_window=1):
            return {"success": False, "error": "Codice non valido."}

        # Rimuove tutti i dati 2FA dell'utente
        del self._user_secrets[user_id]
        self._user_backup_hashes.pop(user_id, None)
        self._user_2fa_enabled[user_id] = False

        return {"success": True, "message": "2FA disattivata."}

Il servizio espone quattro operazioni principali. enroll_user avvia la registrazione generando chiave, URI e codici di backup. confirm_enrollment verifica che l'utente abbia configurato correttamente l'app richiedendo un primo codice di prova. verify_login gestisce la verifica completa durante l'accesso, includendo TOTP, codici di backup, rate limiting e protezione anti-replay. Infine, disable_2fa permette la disattivazione previa conferma con un codice valido.

Esempio di integrazione con Flask

Per rendere il sistema utilizzabile in un contesto web, ecco un esempio di integrazione con il framework Flask. Le rotte seguenti implementano gli endpoint necessari per la registrazione e la verifica della 2FA.

from flask import Flask, request, jsonify, send_file
import io

app = Flask(__name__)

# Inizializza il servizio 2FA
auth_service = TwoFactorAuthService(issuer_name="MiaApp")


@app.route("/api/2fa/enroll", methods=["POST"])
def enroll():
    """Endpoint per avviare la registrazione 2FA."""
    data = request.get_json()
    user_id = data.get("user_id")
    email = data.get("email")

    if not user_id or not email:
        return jsonify({"error": "Parametri mancanti."}), 400

    result = auth_service.enroll_user(user_id, email)

    # Genera il QR code come immagine in memoria
    import qrcode
    qr = qrcode.make(result["provisioning_uri"])
    buffer = io.BytesIO()
    qr.save(buffer, format="PNG")
    buffer.seek(0)

    # Restituisce i dati necessari al frontend
    import base64
    qr_base64 = base64.b64encode(buffer.getvalue()).decode()

    return jsonify({
        "qr_code": f"data:image/png;base64,{qr_base64}",
        "backup_codes": result["backup_codes"],
        "message": "Scansiona il QR code con la tua app di autenticazione.",
    })


@app.route("/api/2fa/confirm", methods=["POST"])
def confirm():
    """Endpoint per confermare l'attivazione della 2FA."""
    data = request.get_json()
    user_id = data.get("user_id")
    code = data.get("code")

    result = auth_service.confirm_enrollment(user_id, code)
    status_code = 200 if result["success"] else 400
    return jsonify(result), status_code


@app.route("/api/2fa/verify", methods=["POST"])
def verify():
    """Endpoint per la verifica 2FA durante il login."""
    data = request.get_json()
    user_id = data.get("user_id")
    code = data.get("code")

    result = auth_service.verify_login(user_id, code)
    status_code = 200 if result["success"] else 401
    return jsonify(result), status_code


if __name__ == "__main__":
    # Avvia il server in modalità di sviluppo
    app.run(debug=True, port=5000)

Questa struttura separa nettamente la logica di autenticazione dalla logica web, rendendo il servizio facilmente testabile e sostituibile. In un'applicazione reale, gli endpoint andrebbero protetti con autenticazione di sessione o JWT, e la comunicazione dovrebbe avvenire esclusivamente su HTTPS.

Considerazioni sulla sicurezza in produzione

L'implementazione presentata in questo articolo è funzionante e completa dal punto di vista logico, ma per un deploy in produzione è necessario affrontare alcune questioni aggiuntive.

La chiave segreta di ciascun utente deve essere cifrata a riposo nel database, utilizzando una chiave di cifratura gestita tramite un servizio dedicato come AWS KMS, HashiCorp Vault o un equivalente. Memorizzarla in chiaro significherebbe che una compromissione del database annullerebbe l'intera protezione offerta dalla 2FA.

La sincronizzazione temporale del server è un aspetto critico. Il protocollo TOTP dipende dal fatto che server e dispositivo dell'utente condividano lo stesso orario. Il server deve utilizzare NTP (Network Time Protocol) per mantenere il proprio orologio allineato. Uno sfasamento anche di pochi secondi potrebbe causare rifiuti inspiegabili dei codici.

Il trasporto della chiave segreta verso l'utente, sia come QR code sia come stringa testuale, deve avvenire su canale cifrato (HTTPS). Il QR code non dovrebbe mai essere memorizzato nella cache del browser e la pagina che lo visualizza dovrebbe indicare al client di non effettuare caching tramite le apposite intestazioni HTTP.

Infine, ogni evento legato alla 2FA (attivazione, disattivazione, verifica riuscita, tentativo fallito, utilizzo di codice di backup) deve essere registrato in un log di audit immutabile, con timestamp, indirizzo IP e user agent. Queste informazioni sono fondamentali sia per il rilevamento di attività sospette sia per la conformità normativa.

Conclusione

Implementare la 2FA in Python con il protocollo TOTP è un processo accessibile grazie a librerie mature come pyotp. Tuttavia, passare da un prototipo funzionante a un sistema pronto per la produzione richiede attenzione a numerosi dettagli: protezione anti-replay, rate limiting, cifratura delle chiavi, codici di backup e logging completo. Ogni componente presentato in questo articolo affronta una specifica vulnerabilità e, presi insieme, formano un sistema di autenticazione solido e conforme alle pratiche raccomandate dalla comunità della sicurezza informatica. Il codice completo può essere esteso con funzionalità aggiuntive come il supporto a WebAuthn/FIDO2 per le chiavi hardware, o l'invio di notifiche push come secondo fattore alternativo.