Implementare l’autenticazione a due fattori (2FA) con Java

L’autenticazione a due fattori (2FA) aggiunge un secondo step oltre a username e password, riducendo in modo drastico il rischio di compromissione dell’account. In questa guida implementiamo la variante più comune: i codici TOTP (Time-based One-Time Password) compatibili con app come Google Authenticator, 1Password, Authy e Microsoft Authenticator.

Come funziona il TOTP (RFC 6238) in 60 secondi

  1. Il server genera e memorizza un secret condiviso (Base32) per l’utente.
  2. Il segreto viene “provisionato” sul dispositivo dell’utente tramite URI otpauth:// o QR code.
  3. Ad ogni login, l’utente inserisce il codice TOTP (6–8 cifre) che l’app calcola come HMAC del tempo attuale a step (di solito 30s) con il secret.
  4. Il server ricalcola localmente il TOTP e verifica la corrispondenza (con una piccola finestra di tolleranza).

Dipendenze consigliate

Useremo librerie mature e leggere per Base32 e QR code. Con Maven:

<dependencies>
  <dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.16.0</version>
  </dependency>
  <dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>core</artifactId>
    <version>3.5.3</version>
  </dependency>
  <dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>javase</artifactId>
    <version>3.5.3</version>
  </dependency>
</dependencies>

1) Generare il secret utente

Il segreto deve essere casuale, sufficientemente lungo (almeno 20 byte) e codificato in Base32 per compatibilità con gli authenticator.

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

public final class TotpSecrets {
  private static final SecureRandom RNG = new SecureRandom();

  public static String newBase32Secret(int numBytes) {
    byte[] buf = new byte[numBytes];
    RNG.nextBytes(buf);
    Base32 base32 = new Base32();
    return base32.encodeToString(buf).replace("=", ""); // padding opzionale
  }

  // Comodo default: 20 byte ~ 160 bit
  public static String newBase32Secret() {
    return newBase32Secret(20);
  }
}

2) Creare l’URI otpauth e il QR code

Formato standard: otpauth://totp/<issuer>:<accountName>?secret=<BASE32>&issuer=<issuer>&period=30&digits=6&algorithm=SHA1

import java.io.ByteArrayOutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.EnumMap;
import com.google.zxing.*;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;

public final class TotpProvisioning {

  public static String buildOtpAuthUri(
      String issuer, String accountName, String base32Secret,
      int digits, int period, String algorithm) {

    String label = url(issuer) + ":" + url(accountName);
    String params = "secret=" + base32Secret
        + "&issuer=" + url(issuer)
        + "&period=" + period
        + "&digits=" + digits
        + "&algorithm=" + url(algorithm);
    return "otpauth://totp/" + label + "?" + params;
  }

  public static byte[] qrPng(String text, int size) {
    try {
      EnumMap<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
      hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
      hints.put(EncodeHintType.MARGIN, 1);
      BitMatrix matrix = new MultiFormatWriter()
          .encode(text, BarcodeFormat.QR_CODE, size, size, hints);
      ByteArrayOutputStream out = new ByteArrayOutputStream();
      MatrixToImageWriter.writeToStream(matrix, "PNG", out);
      return out.toByteArray(); // salvare/servire come image/png
    } catch (Exception e) {
      throw new RuntimeException("Impossibile generare il QR", e);
    }
  }

  private static String url(String s) {
    return URLEncoder.encode(s, StandardCharsets.UTF_8);
  }
}

3) Calcolare e verificare il TOTP lato server

Implementiamo il TOTP (RFC 6238) con HMAC-SHA1/256/512. Per compatibilità massima usare SHA1, 6 cifre, periodo 30s.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base32;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.concurrent.TimeUnit;

public final class Totp {

  public static int generate(String base32Secret, long timestampSeconds,
                             int digits, int periodSeconds, String algorithm) {
    byte[] key = new Base32().decode(base32Secret);
    long counter = timestampSeconds / periodSeconds;
    byte[] msg = ByteBuffer.allocate(8).putLong(counter).array();

    try {
      Mac mac = Mac.getInstance("Hmac" + algorithm);
      mac.init(new SecretKeySpec(key, "Hmac" + algorithm));
      byte[] hash = mac.doFinal(msg);

      int offset = hash[hash.length - 1] & 0x0F;
      int binary = ((hash[offset] & 0x7F) << 24)
                 | ((hash[offset + 1] & 0xFF) << 16)
                 | ((hash[offset + 2] & 0xFF) << 8)
                 | (hash[offset + 3] & 0xFF);

      int otp = binary % (int) Math.pow(10, digits);
      return otp;
    } catch (Exception e) {
      throw new RuntimeException("Errore TOTP", e);
    }
  }

  public static boolean verify(String base32Secret, String code,
                               int digits, int periodSeconds, String algorithm,
                               int window) {
    long now = Instant.now().getEpochSecond();
    for (int i = -window; i <= window; i++) {
      long ts = now + (long) i * periodSeconds;
      int candidate = generate(base32Secret, ts, digits, periodSeconds, algorithm);
      if (padded(candidate, digits).equals(code)) return true;
    }
    return false;
  }

  private static String padded(int n, int digits) {
    String s = Integer.toString(n);
    return "0".repeat(Math.max(0, digits - s.length())) + s;
  }

  public static String currentCode(String base32Secret) {
    int code = generate(base32Secret, Instant.now().getEpochSecond(), 6, 30, "SHA1");
    return padded(code, 6);
  }
}

4) Flusso di onboarding e login

  1. Onboarding 2FA: dopo l’autenticazione con password, genera il segreto, salva nel database e mostra QR/URI. Richiedi all’utente di inserire un primo codice per confermare.
  2. Login: verifica le credenziali, poi richiedi il codice TOTP e validalo con una finestra temporale (per esempio ±1 step).

Esempio di servizio per l’onboarding

public class TwoFaService {

public record Provisioning(String secretBase32, String otpAuthUri, byte\[] qrPng) {}

public Provisioning enable2FA(String userId, String issuer, String accountName) {
String secret = TotpSecrets.newBase32Secret();
String uri = TotpProvisioning.buildOtpAuthUri(issuer, accountName, secret, 6, 30, "SHA1");
byte\[] qr = TotpProvisioning.qrPng(uri, 256);
// TODO: persist "secret" in modo sicuro associato a userId
return new Provisioning(secret, uri, qr);
}

public boolean verify(String userId, String code) {
// TODO: carica "secret" dal DB
String secret = loadSecretFor(userId);
return Totp.verify(secret, code, 6, 30, "SHA1", 1);
}

private String loadSecretFor(String userId) { throw new UnsupportedOperationException(); }
} 

5) Integrazione con Spring Security (opzionale)

Schema comune: primo step con username/password ottenendo uno stato “parzialmente autenticato”; secondo step che verifica il TOTP e promuove l’utente a completamente autenticato.

import org.springframework.security.authentication.*;
import org.springframework.security.core.*;
import org.springframework.security.web.authentication.*;

public class TotpAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

  public TotpAuthenticationFilter() {
    super("/login/totp");
    setAuthenticationSuccessHandler(new SavedRequestAwareAuthenticationSuccessHandler());
  }

  @Override
  public Authentication attemptAuthentication(javax.servlet.http.HttpServletRequest req,
                                              javax.servlet.http.HttpServletResponse res) {
    String userId = req.getParameter("userId");
    String code = req.getParameter("code");
    boolean ok = twoFaService.verify(userId, code);
    if (!ok) throw new BadCredentialsException("Invalid TOTP");
    return new UsernamePasswordAuthenticationToken(userId, null, List.of());
  }

  private final TwoFaService twoFaService = new TwoFaService();
}

Persistenza e sicurezza del secret

  • Crittografa i secret a riposo: usa una chiave KMS o una master key protetta da HSM.
  • Proteggi in memoria: azzera i buffer dove possibile e limita i log.
  • Rate limiting sulla verifica TOTP per utente/IP.
  • Recovery codes: genera una manciata di codici statici monouso stampabili dall’utente.
  • Disallineamenti orari: usa NTP sul server; imposta una finestra di verifica (±1 o ±2 passi).
  • Revoca: consenti di disattivare/rigenerare il secret dopo una verifica forte.

Test rapidi

# Esegui un main di prova per stampare un codice corrente
jshell --class-path target/dependency/*:target/classes <<'EOF'
import TotpSecrets, Totp;
var secret = TotpSecrets.newBase32Secret();
System.out.println("Secret: " + secret);
System.out.println("Code:   " + Totp.currentCode(secret));
EOF

Domande frequenti

Posso usare SHA256/512? Sì: aggiorna algorithm coerentemente su server e nel parametro dell’URI (molte app lo supportano).
Posso validare codici di 8 cifre? Sì: imposta digits=8 su generazione e verifica, e nell’URI.

Checklist di produzione

  • Onboarding con conferma del primo codice.
  • Rate limit e audit logging (senza mai loggare i secret).
  • Recovery codes e flusso di reset sicuro.
  • Backup/replica dei secret crittografati.
  • Monitoraggio degli errori e drift dell’orologio.

Conclusione

Con poche classi e qualche accortezza di sicurezza, puoi offrire una 2FA TOTP interoperabile con le principali app di autenticazione. Gli esempi mostrati sono pensati per essere integrati facilmente in qualsiasi applicazione Java o Spring Boot.

Torna su