Implementare la 2FA in Java

L'autenticazione a due fattori (2FA) rappresenta oggi uno dei meccanismi di sicurezza più efficaci per proteggere gli account utente. Il principio è semplice: oltre alla password, l'utente deve fornire un secondo elemento di verifica, tipicamente un codice temporaneo generato da un'applicazione come Google Authenticator, Authy o Microsoft Authenticator. Questo articolo guida passo dopo passo nell'implementazione di un sistema 2FA completo in Java, basato sul protocollo TOTP (Time-based One-Time Password) definito dalla RFC 6238.

Fondamenti del protocollo TOTP

Il protocollo TOTP genera codici numerici temporanei a partire da una chiave segreta condivisa tra server e client. Il meccanismo si basa su tre elementi fondamentali: una chiave segreta (shared secret), il timestamp corrente Unix diviso per un intervallo di tempo (di norma 30 secondi) e l'algoritmo HMAC-SHA1. Il server genera la chiave segreta durante la fase di registrazione, la comunica all'utente tramite un QR code e la conserva in modo sicuro nel database. Ad ogni tentativo di login, il server calcola il codice atteso a partire dalla stessa chiave e dal tempo corrente, confrontandolo con quello fornito dall'utente.

La forza del sistema risiede nel fatto che la chiave segreta non transita mai sulla rete dopo la fase iniziale di configurazione: sia il server sia l'app generano indipendentemente lo stesso codice, sincronizzati soltanto dal tempo.

Dipendenze del progetto

Per un progetto Maven, le dipendenze necessarie sono minime. Serve una libreria per la codifica Base32, utilizzata dallo standard TOTP, e una per la generazione dei QR code. Il file pom.xml deve includere quanto segue:

<dependencies>
    <!-- Libreria per la codifica Base32 -->
    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.17.0</version>
    </dependency>

    <!-- Generazione QR code -->
    <dependency>
        <groupId>com.google.zxing</groupId>
        <artifactId>javase</artifactId>
        <version>3.5.3</version>
    </dependency>

    <!-- Core di ZXing -->
    <dependency>
        <groupId>com.google.zxing</groupId>
        <artifactId>core</artifactId>
        <version>3.5.3</version>
    </dependency>
</dependencies>

Non è necessaria alcuna libreria TOTP dedicata: l'algoritmo è sufficientemente semplice da implementare direttamente con le API crittografiche standard di Java.

Generazione della chiave segreta

Il primo passo consiste nel generare una chiave segreta casuale per ciascun utente. La chiave deve avere una lunghezza minima di 160 bit (20 byte) come raccomandato dalla RFC 4226. Viene poi codificata in Base32, il formato richiesto dalle app di autenticazione.

import java.security.SecureRandom;
import org.apache.commons.codec.binary.Base32;

public class SecretKeyGenerator {

    // Lunghezza della chiave in byte (160 bit)
    private static final int SECRET_KEY_LENGTH = 20;

    /**
     * Genera una chiave segreta casuale codificata in Base32.
     * Utilizza SecureRandom per garantire entropia crittografica.
     */
    public static String generateSecretKey() {
        SecureRandom random = new SecureRandom();
        byte[] secretBytes = new byte[SECRET_KEY_LENGTH];
        random.nextBytes(secretBytes);

        // Codifica in Base32 senza padding per compatibilità
        Base32 base32 = new Base32();
        return base32.encodeToString(secretBytes).replace("=", "");
    }
}

È fondamentale utilizzare SecureRandom anziché Random: quest'ultimo è un generatore pseudocasuale prevedibile, del tutto inadeguato per scopi crittografici. La chiave generata avrà un aspetto simile a JBSWY3DPEHPK3PXP4QTZMC7H e dovrà essere conservata nel database, associata all'utente, in forma cifrata o comunque protetta.

Implementazione dell'algoritmo TOTP

Il cuore del sistema è l'algoritmo che, data una chiave segreta e un istante temporale, produce un codice numerico a sei cifre. L'implementazione segue fedelmente la RFC 6238.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import org.apache.commons.codec.binary.Base32;

public class TotpGenerator {

    // Intervallo di tempo in secondi per ciascun codice
    private static final int TIME_STEP_SECONDS = 30;

    // Numero di cifre del codice generato
    private static final int CODE_DIGITS = 6;

    // Algoritmo HMAC utilizzato
    private static final String HMAC_ALGORITHM = "HmacSHA1";

    /**
     * Genera il codice TOTP per l'istante corrente.
     *
     * @param secretKeyBase32 la chiave segreta in formato Base32
     * @return il codice TOTP come stringa a sei cifre
     */
    public static String generateCode(String secretKeyBase32) {
        // Decodifica la chiave da Base32 a byte
        Base32 base32 = new Base32();
        byte[] decodedKey = base32.decode(secretKeyBase32);

        // Calcola il contatore temporale corrente
        long currentTimeStep = System.currentTimeMillis() / 1000 / TIME_STEP_SECONDS;

        return generateCodeForTimeStep(decodedKey, currentTimeStep);
    }

    /**
     * Genera il codice TOTP per un determinato intervallo temporale.
     *
     * @param key      la chiave segreta in byte
     * @param timeStep il contatore temporale
     * @return il codice TOTP come stringa a sei cifre
     */
    private static String generateCodeForTimeStep(byte[] key, long timeStep) {
        // Converte il contatore in un array di 8 byte (big-endian)
        byte[] timeBytes = ByteBuffer.allocate(8).putLong(timeStep).array();

        try {
            // Calcola l'HMAC-SHA1 della rappresentazione temporale
            Mac hmac = Mac.getInstance(HMAC_ALGORITHM);
            SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_ALGORITHM);
            hmac.init(keySpec);
            byte[] hash = hmac.doFinal(timeBytes);

            // Troncamento dinamico secondo RFC 4226
            int offset = hash[hash.length - 1] & 0x0F;

            int truncatedHash =
                ((hash[offset] & 0x7F) << 24)
              | ((hash[offset + 1] & 0xFF) << 16)
              | ((hash[offset + 2] & 0xFF) << 8)
              | (hash[offset + 3] & 0xFF);

            // Riduce il valore al numero di cifre desiderato
            int code = truncatedHash % (int) Math.pow(10, CODE_DIGITS);

            // Restituisce il codice con zeri iniziali se necessario
            return String.format("%0" + CODE_DIGITS + "d", code);

        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("Errore nella generazione del codice TOTP", e);
        }
    }
}

Il troncamento dinamico è il passaggio più delicato: l'ultimo nibble (4 bit) dell'hash HMAC determina un offset all'interno dell'hash stesso, da cui si estraggono 4 byte che, opportunamente mascherati, producono un intero positivo. Il modulo per 106 restituisce infine il codice a sei cifre.

Validazione del codice con finestra di tolleranza

Nella pratica, l'orologio del dispositivo dell'utente potrebbe non essere perfettamente sincronizzato con quello del server. Per questo motivo è buona norma accettare anche i codici relativi agli intervalli temporali immediatamente precedenti e successivi. Una finestra di tolleranza di ±1 intervallo (cioè ±30 secondi) è lo standard consigliato.

public class TotpValidator {

    // Finestra di tolleranza: quanti intervalli prima e dopo accettare
    private static final int ALLOWED_TIME_DRIFT = 1;

    /**
     * Verifica se il codice fornito dall'utente è valido.
     * Controlla l'intervallo corrente e quelli adiacenti
     * entro la finestra di tolleranza definita.
     *
     * @param secretKeyBase32 la chiave segreta dell'utente
     * @param userCode        il codice inserito dall'utente
     * @return true se il codice è valido, false altrimenti
     */
    public static boolean validateCode(String secretKeyBase32, String userCode) {
        if (userCode == null || userCode.length() != 6) {
            return false;
        }

        Base32 base32 = new Base32();
        byte[] decodedKey = base32.decode(secretKeyBase32);
        long currentTimeStep = System.currentTimeMillis() / 1000 / 30;

        // Verifica il codice per ogni intervallo nella finestra di tolleranza
        for (int i = -ALLOWED_TIME_DRIFT; i <= ALLOWED_TIME_DRIFT; i++) {
            String expectedCode = TotpGenerator.generateCodeForTimeStep(
                decodedKey, currentTimeStep + i
            );

            // Confronto a tempo costante per prevenire attacchi timing
            if (constantTimeEquals(expectedCode, userCode)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Confronto a tempo costante tra due stringhe.
     * Impedisce attacchi di tipo timing side-channel.
     */
    private static boolean constantTimeEquals(String a, String b) {
        if (a.length() != b.length()) {
            return false;
        }

        int result = 0;
        for (int i = 0; i < a.length(); i++) {
            result |= a.charAt(i) ^ b.charAt(i);
        }
        return result == 0;
    }
}

Il metodo constantTimeEquals merita attenzione particolare. Un confronto ordinario con equals restituisce false al primo carattere diverso: un attaccante potrebbe misurare il tempo di risposta per dedurre quanti caratteri del codice sono corretti. Il confronto a tempo costante elimina questa vulnerabilità eseguendo sempre tutte le operazioni di confronto, indipendentemente dalla posizione della prima differenza.

Generazione dell'URI e del QR code

Per consentire all'utente di configurare l'app di autenticazione, è necessario generare un URI nel formato standard otpauth:// e presentarlo come QR code. Il formato dell'URI è definito dallo standard di Google Authenticator ed è supportato da tutte le principali app.

import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class QrCodeGenerator {

    // Dimensione in pixel del QR code generato
    private static final int QR_SIZE = 300;

    /**
     * Costruisce l'URI nel formato otpauth:// riconosciuto
     * dalle app di autenticazione.
     *
     * @param secretKey   la chiave segreta in Base32
     * @param accountName l'identificativo dell'account (es. email)
     * @param issuer      il nome dell'applicazione o del servizio
     * @return l'URI completo in formato otpauth://
     */
    public static String buildOtpAuthUri(
            String secretKey, String accountName, String issuer) {

        String encodedIssuer = URLEncoder.encode(issuer, StandardCharsets.UTF_8);
        String encodedAccount = URLEncoder.encode(accountName, StandardCharsets.UTF_8);

        return String.format(
            "otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
            encodedIssuer,
            encodedAccount,
            secretKey,
            encodedIssuer
        );
    }

    /**
     * Genera il QR code come immagine PNG codificata in Base64.
     * Può essere incorporato direttamente in una pagina HTML
     * tramite un attributo src con prefisso data:image/png;base64.
     *
     * @param otpAuthUri l'URI otpauth:// da codificare
     * @return la stringa Base64 dell'immagine PNG
     */
    public static String generateQrCodeBase64(String otpAuthUri)
            throws WriterException, IOException {

        QRCodeWriter qrWriter = new QRCodeWriter();
        BitMatrix bitMatrix = qrWriter.encode(
            otpAuthUri, BarcodeFormat.QR_CODE, QR_SIZE, QR_SIZE
        );

        // Scrive l'immagine in un buffer di byte
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream);

        // Codifica in Base64 per l'incorporamento in HTML
        return Base64.getEncoder().encodeToString(outputStream.toByteArray());
    }
}

L'URI risultante avrà una struttura simile a otpauth://totp/MiaApp:utente@email.com?secret=JBSWY3DPEHPK3PXP&issuer=MiaApp&algorithm=SHA1&digits=6&period=30. Quando l'utente inquadra il QR code con la propria app di autenticazione, tutti i parametri vengono importati automaticamente.

Integrazione nel flusso di registrazione

Il flusso tipico di attivazione della 2FA per un utente già registrato prevede i seguenti passaggi: generazione della chiave segreta, presentazione del QR code, verifica di un primo codice per confermare la corretta configurazione e infine salvataggio della chiave nel database. Ecco una classe che orchestra l'intero processo.

public class TwoFactorAuthService {

    private final UserRepository userRepository;

    public TwoFactorAuthService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    /**
     * Avvia il processo di attivazione della 2FA per un utente.
     * Genera la chiave e il QR code, ma non salva ancora nulla:
     * la chiave verrà persistita solo dopo la verifica del primo codice.
     *
     * @param userId l'identificativo dell'utente
     * @return un oggetto con la chiave segreta e il QR code in Base64
     */
    public TwoFactorSetupData initiateSetup(long userId)
            throws Exception {

        User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("Utente non trovato"));

        // Genera una nuova chiave segreta
        String secretKey = SecretKeyGenerator.generateSecretKey();

        // Costruisce l'URI e genera il QR code
        String otpAuthUri = QrCodeGenerator.buildOtpAuthUri(
            secretKey, user.getEmail(), "MiaApplicazione"
        );
        String qrCodeBase64 = QrCodeGenerator.generateQrCodeBase64(otpAuthUri);

        // Restituisce i dati senza ancora persistere la chiave
        return new TwoFactorSetupData(secretKey, qrCodeBase64);
    }

    /**
     * Conferma l'attivazione della 2FA verificando il primo codice.
     * Solo se il codice è corretto, la chiave viene salvata nel database.
     *
     * @param userId         l'identificativo dell'utente
     * @param secretKey      la chiave generata nella fase precedente
     * @param verificationCode il codice inserito dall'utente
     * @return true se l'attivazione è riuscita
     */
    public boolean confirmSetup(long userId, String secretKey, String verificationCode) {
        // Verifica che il codice sia corretto prima di salvare
        if (!TotpValidator.validateCode(secretKey, verificationCode)) {
            return false;
        }

        // Il codice è valido: salva la chiave nel database
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("Utente non trovato"));

        user.setTwoFactorSecret(secretKey);
        user.setTwoFactorEnabled(true);
        userRepository.save(user);

        return true;
    }

    /**
     * Verifica il codice 2FA durante il login.
     *
     * @param userId   l'identificativo dell'utente
     * @param totpCode il codice inserito dall'utente
     * @return true se il codice è valido
     */
    public boolean verifyLogin(long userId, String totpCode) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("Utente non trovato"));

        if (!user.isTwoFactorEnabled()) {
            // La 2FA non è attiva per questo utente
            return true;
        }

        return TotpValidator.validateCode(user.getTwoFactorSecret(), totpCode);
    }
}

/**
 * Classe di trasporto per i dati della fase di configurazione.
 */
record TwoFactorSetupData(String secretKey, String qrCodeBase64) {
}

Il pattern qui adottato — generare la chiave, mostrarla all'utente e salvarla nel database solo dopo la verifica del primo codice — è cruciale. Se la chiave venisse salvata immediatamente, un utente che non completa la configurazione si ritroverebbe con la 2FA attiva ma senza l'app configurata, restando bloccato fuori dal proprio account.

Codici di recupero

Un sistema 2FA completo deve prevedere un meccanismo di recupero per il caso in cui l'utente perda l'accesso al proprio dispositivo. La prassi consolidata consiste nel generare un insieme di codici monouso al momento dell'attivazione.

import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;

public class RecoveryCodeGenerator {

    // Numero di codici di recupero da generare
    private static final int RECOVERY_CODE_COUNT = 10;

    // Lunghezza di ciascun codice in caratteri
    private static final int CODE_LENGTH = 8;

    // Caratteri utilizzabili nei codici (esclusi quelli ambigui)
    private static final String ALLOWED_CHARACTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

    /**
     * Genera un insieme di codici di recupero monouso.
     * I caratteri ambigui (0, O, 1, I) sono esclusi
     * per ridurre gli errori di trascrizione.
     *
     * @return la lista dei codici generati
     */
    public static List<String> generateRecoveryCodes() {
        SecureRandom random = new SecureRandom();
        List<String> codes = new ArrayList<>();

        for (int i = 0; i < RECOVERY_CODE_COUNT; i++) {
            StringBuilder code = new StringBuilder(CODE_LENGTH);

            for (int j = 0; j < CODE_LENGTH; j++) {
                int index = random.nextInt(ALLOWED_CHARACTERS.length());
                code.append(ALLOWED_CHARACTERS.charAt(index));
            }

            // Inserisce un trattino a metà per leggibilità
            code.insert(CODE_LENGTH / 2, '-');
            codes.add(code.toString());
        }

        return codes;
    }
}

I codici di recupero devono essere conservati nel database sotto forma di hash (ad esempio con bcrypt), esattamente come le password. Quando l'utente ne utilizza uno, il codice viene invalidato e rimosso. È buona pratica mostrare i codici una sola volta, chiedendo all'utente di trascriverli in un luogo sicuro.

Protezione contro attacchi di forza bruta

Un codice a sei cifre ha un milione di combinazioni possibili: senza contromisure, un attaccante potrebbe tentare tutte le combinazioni in meno di 30 secondi. È indispensabile implementare un meccanismo di rate limiting.

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public class BruteForceProtector {

    // Numero massimo di tentativi consentiti
    private static final int MAX_ATTEMPTS = 5;

    // Durata del blocco in millisecondi (15 minuti)
    private static final long LOCKOUT_DURATION_MS = 15 * 60 * 1000;

    // Registro dei tentativi falliti per ciascun utente
    private final Map<Long, FailedAttemptRecord> failedAttempts =
        new ConcurrentHashMap<>();

    /**
     * Verifica se l'utente è attualmente bloccato
     * a causa di troppi tentativi falliti.
     *
     * @param userId l'identificativo dell'utente
     * @return true se l'utente è bloccato
     */
    public boolean isLockedOut(long userId) {
        FailedAttemptRecord record = failedAttempts.get(userId);
        if (record == null) {
            return false;
        }

        // Controlla se il periodo di blocco è scaduto
        if (System.currentTimeMillis() - record.lastAttemptTime > LOCKOUT_DURATION_MS) {
            failedAttempts.remove(userId);
            return false;
        }

        return record.attemptCount.get() >= MAX_ATTEMPTS;
    }

    /**
     * Registra un tentativo fallito per l'utente specificato.
     *
     * @param userId l'identificativo dell'utente
     */
    public void recordFailedAttempt(long userId) {
        failedAttempts.compute(userId, (key, existing) -> {
            if (existing == null
                || System.currentTimeMillis() - existing.lastAttemptTime > LOCKOUT_DURATION_MS) {
                // Primo tentativo o periodo di blocco scaduto: crea un nuovo record
                return new FailedAttemptRecord();
            }
            // Incrementa il contatore dei tentativi
            existing.attemptCount.incrementAndGet();
            existing.lastAttemptTime = System.currentTimeMillis();
            return existing;
        });
    }

    /**
     * Azzera il contatore dopo un login riuscito.
     *
     * @param userId l'identificativo dell'utente
     */
    public void resetAttempts(long userId) {
        failedAttempts.remove(userId);
    }

    /**
     * Record interno per tracciare i tentativi falliti.
     */
    private static class FailedAttemptRecord {
        final AtomicInteger attemptCount = new AtomicInteger(1);
        volatile long lastAttemptTime = System.currentTimeMillis();
    }
}

Questa implementazione utilizza una ConcurrentHashMap adatta ad ambienti multi-thread. In un contesto di produzione con più istanze del server, il contatore dei tentativi andrebbe gestito tramite un sistema distribuito come Redis, per evitare che un attaccante possa aggirare il limite distribuendo le richieste tra istanze diverse.

Prevenzione del riuso dei codici

Un'ulteriore misura di sicurezza consiste nell'impedire il riutilizzo di un codice TOTP già consumato all'interno dello stesso intervallo temporale. Senza questo controllo, un intercettatore che cattura un codice valido potrebbe riutilizzarlo entro la finestra dei 30 secondi.

import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public class CodeReplayProtector {

    // Mappa: userId -> insieme dei codici già utilizzati con il relativo timestamp
    private final Map<Long, Set<String>> usedCodes = new ConcurrentHashMap<>();

    /**
     * Verifica se il codice è già stato utilizzato dall'utente
     * nell'intervallo temporale corrente e lo registra.
     *
     * @param userId l'identificativo dell'utente
     * @param code   il codice da verificare
     * @return true se il codice è nuovo e può essere accettato
     */
    public boolean markCodeAsUsed(long userId, String code) {
        long currentTimeStep = System.currentTimeMillis() / 1000 / 30;

        // Chiave composta: codice + intervallo temporale
        String compositeKey = code + ":" + currentTimeStep;

        Set<String> userCodes = usedCodes.computeIfAbsent(
            userId, k -> ConcurrentHashMap.newKeySet()
        );

        // add() restituisce true se l'elemento non era già presente
        return userCodes.add(compositeKey);
    }

    /**
     * Rimuove periodicamente i codici scaduti per liberare memoria.
     * Da invocare tramite uno scheduler a intervalli regolari.
     */
    public void purgeExpiredCodes() {
        long currentTimeStep = System.currentTimeMillis() / 1000 / 30;

        usedCodes.forEach((userId, codes) -> {
            codes.removeIf(compositeKey -> {
                String[] parts = compositeKey.split(":");
                long codeTimeStep = Long.parseLong(parts[1]);
                // Rimuove i codici più vecchi di due intervalli
                return currentTimeStep - codeTimeStep > 2;
            });

            // Rimuove le entry vuote dalla mappa
            if (codes.isEmpty()) {
                usedCodes.remove(userId);
            }
        });
    }
}

Esempio completo: controller REST

Per concludere, ecco un esempio di come integrare tutti i componenti in un controller REST utilizzando Spring Boot. Il controller espone gli endpoint necessari per il flusso completo di attivazione e verifica della 2FA.

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/2fa")
public class TwoFactorAuthController {

    private final TwoFactorAuthService authService;
    private final BruteForceProtector bruteForceProtector;
    private final CodeReplayProtector replayProtector;

    public TwoFactorAuthController(
            TwoFactorAuthService authService,
            BruteForceProtector bruteForceProtector,
            CodeReplayProtector replayProtector) {
        this.authService = authService;
        this.bruteForceProtector = bruteForceProtector;
        this.replayProtector = replayProtector;
    }

    /**
     * Avvia la configurazione della 2FA.
     * Restituisce la chiave segreta e il QR code in Base64.
     */
    @PostMapping("/setup")
    public ResponseEntity<?> initiateSetup(@RequestAttribute("userId") long userId)
            throws Exception {

        TwoFactorSetupData setupData = authService.initiateSetup(userId);

        return ResponseEntity.ok(Map.of(
            "secretKey", setupData.secretKey(),
            "qrCode", setupData.qrCodeBase64()
        ));
    }

    /**
     * Conferma l'attivazione verificando il primo codice generato dall'app.
     */
    @PostMapping("/setup/confirm")
    public ResponseEntity<?> confirmSetup(
            @RequestAttribute("userId") long userId,
            @RequestBody SetupConfirmRequest request) {

        boolean confirmed = authService.confirmSetup(
            userId, request.secretKey(), request.verificationCode()
        );

        if (!confirmed) {
            return ResponseEntity.badRequest().body(
                Map.of("error", "Codice non valido. Riprova.")
            );
        }

        // Genera i codici di recupero
        var recoveryCodes = RecoveryCodeGenerator.generateRecoveryCodes();

        return ResponseEntity.ok(Map.of(
            "message", "2FA attivata con successo",
            "recoveryCodes", recoveryCodes
        ));
    }

    /**
     * Verifica il codice TOTP durante il login.
     * Include protezione contro forza bruta e riuso dei codici.
     */
    @PostMapping("/verify")
    public ResponseEntity<?> verifyCode(
            @RequestAttribute("userId") long userId,
            @RequestBody VerifyRequest request) {

        // Controlla se l'utente è bloccato per troppi tentativi
        if (bruteForceProtector.isLockedOut(userId)) {
            return ResponseEntity.status(429).body(
                Map.of("error", "Troppi tentativi. Riprova tra 15 minuti.")
            );
        }

        // Controlla che il codice non sia già stato usato
        if (!replayProtector.markCodeAsUsed(userId, request.code())) {
            return ResponseEntity.badRequest().body(
                Map.of("error", "Codice già utilizzato. Attendi il prossimo codice.")
            );
        }

        boolean valid = authService.verifyLogin(userId, request.code());

        if (!valid) {
            bruteForceProtector.recordFailedAttempt(userId);
            return ResponseEntity.status(401).body(
                Map.of("error", "Codice non valido")
            );
        }

        // Login riuscito: azzera il contatore dei tentativi
        bruteForceProtector.resetAttempts(userId);
        return ResponseEntity.ok(Map.of("message", "Verifica completata"));
    }
}

/**
 * Record per la richiesta di conferma della configurazione.
 */
record SetupConfirmRequest(String secretKey, String verificationCode) {}

/**
 * Record per la richiesta di verifica del codice.
 */
record VerifyRequest(String code) {}

Considerazioni sulla sicurezza in produzione

L'implementazione presentata copre i fondamentali, ma un sistema in produzione richiede alcune accortezze aggiuntive. La chiave segreta nel database deve essere cifrata con un algoritmo simmetrico come AES-256: se il database viene compromesso, le chiavi in chiaro permetterebbero a un attaccante di generare codici validi per qualsiasi utente. È altrettanto importante servire gli endpoint esclusivamente tramite HTTPS, impostare header di sicurezza appropriati e gestire le sessioni in modo che il secondo fattore venga richiesto a ogni nuovo login e non resti valido indefinitamente.

Il rate limiting descritto in questo articolo è basato sulla memoria del processo e non sopravvive a un riavvio del server. In ambiente distribuito conviene delegare questa responsabilità a Redis o a un sistema analogo, così da mantenere i contatori condivisi tra tutte le istanze e persistenti nel tempo.

Infine, è consigliabile offrire all'utente la possibilità di disattivare la 2FA (previa verifica dell'identità), di rigenerare i codici di recupero e di visualizzare lo storico degli accessi, così da poter individuare tempestivamente eventuali attività sospette.

Conclusioni

L'implementazione della 2FA in Java non richiede librerie complesse: l'algoritmo TOTP è snello e si appoggia interamente sulle API crittografiche standard del linguaggio. I punti chiave da ricordare sono la generazione sicura della chiave con SecureRandom, il confronto a tempo costante per prevenire attacchi timing, la finestra di tolleranza per gestire la desincronizzazione degli orologi, la protezione contro forza bruta e riuso dei codici, e la cifratura della chiave segreta a riposo nel database. Con questi accorgimenti, il sistema risulta robusto, conforme agli standard e compatibile con tutte le principali app di autenticazione.