Implementare la 2FA in Node.js

L'autenticazione a due fattori (2FA) rappresenta uno degli strumenti più efficaci per proteggere gli account utente da accessi non autorizzati. Anche nel caso in cui una password venga compromessa, un secondo fattore di verifica impedisce all'attaccante di completare l'accesso. In questo articolo vedremo come implementare la 2FA basata su TOTP (Time-based One-Time Password) in un'applicazione Node.js, utilizzando lo standard RFC 6238 compatibile con app come Google Authenticator, Authy e Microsoft Authenticator.

Cos'è il TOTP e come funziona

Il TOTP è un algoritmo che genera codici numerici temporanei a partire da un segreto condiviso tra server e client. Il codice cambia ogni 30 secondi e viene calcolato combinando il segreto con il timestamp corrente attraverso una funzione HMAC-SHA1. Quando l'utente inserisce il codice visualizzato dalla propria app di autenticazione, il server genera indipendentemente lo stesso codice e li confronta. Se corrispondono, l'identità è confermata.

Il flusso tipico prevede tre fasi: la generazione del segreto, la presentazione del QR code all'utente per la registrazione nell'app di autenticazione, e infine la verifica del codice ad ogni login successivo.

Preparazione del progetto

Iniziamo creando un nuovo progetto Node.js e installando le dipendenze necessarie. Utilizzeremo speakeasy per la gestione dei segreti TOTP e la verifica dei codici, qrcode per generare i QR code, ed express come framework HTTP.

# Inizializzazione del progetto
npm init -y

# Installazione delle dipendenze necessarie
npm install express speakeasy qrcode jsonwebtoken

La struttura del progetto sarà volutamente lineare per concentrarci sulla logica della 2FA senza distrazioni architetturali. In un'applicazione reale, ogni componente andrebbe separato in moduli distinti.

Configurazione del server Express

Creiamo il file principale dell'applicazione con la configurazione base di Express e un archivio utenti in memoria. In produzione questo sarebbe sostituito da un database come PostgreSQL o MongoDB.

const express = require("express");
const speakeasy = require("speakeasy");
const QRCode = require("qrcode");
const jwt = require("jsonwebtoken");

const app = express();
app.use(express.json());

// Segreto per la firma dei token JWT
const JWT_SECRET = "un-secret-molto-lungo-e-complesso";

// Archivio utenti in memoria (usare un database in produzione)
const users = new Map();

app.listen(3000, () => {
  console.log("Server avviato sulla porta 3000");
});

Registrazione dell'utente

Il primo endpoint gestisce la registrazione. In questa fase creiamo l'utente con una password (che in produzione andrebbe hashata con bcrypt) e impostiamo lo stato iniziale della 2FA come disabilitato.

app.post("/api/register", (req, res) => {
  const { username, password } = req.body;

  // Verifica che l'utente non esista già
  if (users.has(username)) {
    return res.status(409).json({ error: "Utente già registrato" });
  }

  // Creazione dell'utente con 2FA disabilitata
  users.set(username, {
    username,
    password, // In produzione: usare bcrypt.hashSync(password, 10)
    twoFactorEnabled: false,
    twoFactorSecret: null,
  });

  res.status(201).json({ message: "Registrazione completata" });
});

Generazione del segreto e del QR code

Questo è il passaggio cruciale: quando l'utente decide di attivare la 2FA, il server genera un segreto univoco e lo restituisce sotto forma di QR code. L'utente scansiona il codice con la propria app di autenticazione, che memorizza il segreto e inizia a generare codici temporanei.

app.post("/api/2fa/setup", async (req, res) => {
  const { username } = req.body;

  const user = users.get(username);
  if (!user) {
    return res.status(404).json({ error: "Utente non trovato" });
  }

  // Generazione del segreto TOTP con codifica base32
  const secret = speakeasy.generateSecret({
    name: `MiaApp:${username}`,
    issuer: "MiaApp",
    length: 32,
  });

  // Salvataggio temporaneo del segreto (non ancora confermato)
  user.tempSecret = secret.base32;

  // Generazione del QR code come URL base64
  try {
    const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url);

    res.json({
      message: "Scansiona il QR code con la tua app di autenticazione",
      qrCode: qrCodeDataUrl,
      manualEntryKey: secret.base32, // Chiave per inserimento manuale
    });
  } catch (error) {
    res.status(500).json({ error: "Errore nella generazione del QR code" });
  }
});

Il campo otpauth_url generato da speakeasy segue lo standard URI otpauth://totp/, riconosciuto da tutte le app di autenticazione principali. Il parametro issuer permette all'utente di identificare facilmente a quale servizio appartiene il codice nella lista della propria app.

Nota importante: il segreto viene salvato in un campo temporaneo (tempSecret) e non direttamente come segreto attivo. Questo perché la 2FA non deve essere considerata attiva finché l'utente non dimostra di aver configurato correttamente l'app inserendo un primo codice valido.

Verifica e attivazione della 2FA

Dopo aver scansionato il QR code, l'utente deve inserire il codice generato dalla propria app per confermare che tutto funzioni. Solo a quel punto la 2FA viene effettivamente abilitata sull'account.

app.post("/api/2fa/verify-setup", (req, res) => {
  const { username, token } = req.body;

  const user = users.get(username);
  if (!user || !user.tempSecret) {
    return res.status(400).json({ error: "Nessuna configurazione 2FA in corso" });
  }

  // Verifica che il codice inserito corrisponda al segreto temporaneo
  const isValid = speakeasy.totp.verify({
    secret: user.tempSecret,
    encoding: "base32",
    token,
    window: 1, // Tolleranza di un intervallo (30 secondi) in più o in meno
  });

  if (!isValid) {
    return res.status(401).json({ error: "Codice non valido, riprova" });
  }

  // Attivazione della 2FA: il segreto temporaneo diventa quello definitivo
  user.twoFactorSecret = user.tempSecret;
  user.twoFactorEnabled = true;
  delete user.tempSecret;

  // Generazione dei codici di recupero
  const recoveryCodes = generateRecoveryCodes(8);
  user.recoveryCodes = recoveryCodes;

  res.json({
    message: "Autenticazione a due fattori attivata con successo",
    recoveryCodes, // Mostrare all'utente una sola volta
  });
});

Il parametro window nella funzione di verifica è fondamentale. Un valore di 1 significa che il server accetta come valido anche il codice dell'intervallo immediatamente precedente o successivo. Questo compensa piccole differenze di sincronizzazione dell'orologio tra il dispositivo dell'utente e il server.

Generazione dei codici di recupero

I codici di recupero sono essenziali: permettono all'utente di accedere al proprio account anche quando non ha accesso al dispositivo con l'app di autenticazione. Ogni codice è monouso e deve essere conservato in un luogo sicuro.

const crypto = require("crypto");

function generateRecoveryCodes(count) {
  const codes = [];

  for (let i = 0; i < count; i++) {
    // Generazione di un codice casuale di 10 caratteri esadecimali
    const code = crypto.randomBytes(5).toString("hex").toUpperCase();

    // Formattazione con trattino per leggibilità (es. "A3F2B-C9D1E")
    const formattedCode = code.slice(0, 5) + "-" + code.slice(5);
    codes.push(formattedCode);
  }

  return codes;
}

Flusso di login con 2FA

Il login con 2FA avviene in due passaggi distinti. Nel primo passaggio l'utente fornisce username e password. Se le credenziali sono corrette e la 2FA è abilitata, il server restituisce un token temporaneo che identifica la sessione di autenticazione parziale. Nel secondo passaggio l'utente invia il codice TOTP insieme al token temporaneo per completare l'accesso.

app.post("/api/login", (req, res) => {
  const { username, password } = req.body;

  const user = users.get(username);

  // Verifica credenziali di base
  if (!user || user.password !== password) {
    return res.status(401).json({ error: "Credenziali non valide" });
  }

  // Se la 2FA non è attiva, login diretto
  if (!user.twoFactorEnabled) {
    const accessToken = jwt.sign(
      { username, loginComplete: true },
      JWT_SECRET,
      { expiresIn: "1h" }
    );
    return res.json({ accessToken });
  }

  // Se la 2FA è attiva, rilascio di un token temporaneo
  const partialToken = jwt.sign(
    { username, loginComplete: false },
    JWT_SECRET,
    { expiresIn: "5m" } // Scadenza breve per il secondo fattore
  );

  res.json({
    message: "Inserisci il codice dalla tua app di autenticazione",
    requiresTwoFactor: true,
    partialToken,
  });
});

Il token temporaneo ha una scadenza di soli 5 minuti. Questo limita la finestra temporale in cui un attaccante potrebbe tentare di indovinare il codice TOTP, anche nel caso in cui riuscisse a intercettare il token parziale.

Completamento del login con il codice TOTP

Il secondo endpoint del flusso di login accetta il token parziale e il codice TOTP, verifica entrambi e, se tutto è corretto, rilascia il token di accesso definitivo.

app.post("/api/login/2fa", (req, res) => {
  const { partialToken, token } = req.body;

  // Decodifica e validazione del token parziale
  let decoded;
  try {
    decoded = jwt.verify(partialToken, JWT_SECRET);
  } catch (error) {
    return res.status(401).json({ error: "Sessione scaduta, ripeti il login" });
  }

  // Controllo che il token sia effettivamente parziale
  if (decoded.loginComplete) {
    return res.status(400).json({ error: "Token non valido per questo endpoint" });
  }

  const user = users.get(decoded.username);
  if (!user) {
    return res.status(404).json({ error: "Utente non trovato" });
  }

  // Verifica del codice TOTP
  const isValid = speakeasy.totp.verify({
    secret: user.twoFactorSecret,
    encoding: "base32",
    token,
    window: 1,
  });

  if (!isValid) {
    return res.status(401).json({ error: "Codice 2FA non valido" });
  }

  // Rilascio del token di accesso completo
  const accessToken = jwt.sign(
    { username: decoded.username, loginComplete: true },
    JWT_SECRET,
    { expiresIn: "1h" }
  );

  res.json({
    message: "Login completato con successo",
    accessToken,
  });
});

Login tramite codice di recupero

Occorre prevedere anche la possibilità che l'utente non abbia accesso alla propria app di autenticazione. In quel caso può utilizzare uno dei codici di recupero generati in fase di attivazione. Ogni codice funziona una sola volta e viene invalidato dopo l'uso.

app.post("/api/login/recovery", (req, res) => {
  const { partialToken, recoveryCode } = req.body;

  // Decodifica del token parziale
  let decoded;
  try {
    decoded = jwt.verify(partialToken, JWT_SECRET);
  } catch (error) {
    return res.status(401).json({ error: "Sessione scaduta, ripeti il login" });
  }

  const user = users.get(decoded.username);
  if (!user) {
    return res.status(404).json({ error: "Utente non trovato" });
  }

  // Ricerca del codice di recupero nell'elenco dell'utente
  const codeIndex = user.recoveryCodes.indexOf(recoveryCode.toUpperCase());

  if (codeIndex === -1) {
    return res.status(401).json({ error: "Codice di recupero non valido" });
  }

  // Rimozione del codice usato (monouso)
  user.recoveryCodes.splice(codeIndex, 1);

  // Rilascio del token di accesso completo
  const accessToken = jwt.sign(
    { username: decoded.username, loginComplete: true },
    JWT_SECRET,
    { expiresIn: "1h" }
  );

  res.json({
    message: "Login completato tramite codice di recupero",
    accessToken,
    remainingRecoveryCodes: user.recoveryCodes.length,
  });
});

È buona pratica avvisare l'utente del numero di codici di recupero rimanenti. Quando scendono sotto una certa soglia (per esempio 3), conviene invitare l'utente a generarne di nuovi.

Disabilitazione della 2FA

L'utente deve poter disattivare la 2FA dal proprio profilo. Per sicurezza, questa operazione richiede la verifica di un codice TOTP valido, in modo che solo chi possiede il dispositivo di autenticazione possa effettuarla.

app.post("/api/2fa/disable", (req, res) => {
  const { username, token } = req.body;

  const user = users.get(username);
  if (!user || !user.twoFactorEnabled) {
    return res.status(400).json({ error: "2FA non attiva su questo account" });
  }

  // Verifica del codice prima di disabilitare
  const isValid = speakeasy.totp.verify({
    secret: user.twoFactorSecret,
    encoding: "base32",
    token,
    window: 1,
  });

  if (!isValid) {
    return res.status(401).json({ error: "Codice non valido" });
  }

  // Disabilitazione della 2FA e pulizia dei dati associati
  user.twoFactorEnabled = false;
  user.twoFactorSecret = null;
  user.recoveryCodes = [];

  res.json({ message: "Autenticazione a due fattori disattivata" });
});

Middleware di protezione delle rotte

Per proteggere le rotte che richiedono un'autenticazione completa, creiamo un middleware che verifica il token JWT e controlla che il login sia stato completato integralmente, inclusa la fase 2FA.

function requireFullAuth(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Token di accesso mancante" });
  }

  const accessToken = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(accessToken, JWT_SECRET);

    // Verifica che il login sia stato completato (inclusa la 2FA)
    if (!decoded.loginComplete) {
      return res.status(403).json({ error: "Autenticazione incompleta" });
    }

    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ error: "Token non valido o scaduto" });
  }
}

// Esempio di rotta protetta
app.get("/api/profile", requireFullAuth, (req, res) => {
  const user = users.get(req.user.username);
  res.json({
    username: user.username,
    twoFactorEnabled: user.twoFactorEnabled,
  });
});

Protezione contro attacchi brute force

Un codice TOTP ha solo sei cifre, il che significa un milione di combinazioni possibili. Senza protezione, un attaccante potrebbe tentare di indovinarlo con attacchi automatizzati. È fondamentale implementare un sistema di rate limiting specifico per gli endpoint di verifica 2FA.

// Registro dei tentativi di verifica 2FA per ogni utente
const loginAttempts = new Map();

function rateLimitTwoFactor(req, res, next) {
  const identifier = req.body.partialToken || req.ip;

  const attempts = loginAttempts.get(identifier) || {
    count: 0,
    firstAttempt: Date.now(),
  };

  // Reset dopo 15 minuti
  const fifteenMinutes = 15 * 60 * 1000;
  if (Date.now() - attempts.firstAttempt > fifteenMinutes) {
    attempts.count = 0;
    attempts.firstAttempt = Date.now();
  }

  attempts.count++;
  loginAttempts.set(identifier, attempts);

  // Blocco dopo 5 tentativi in 15 minuti
  if (attempts.count > 5) {
    const remainingTime = Math.ceil(
      (fifteenMinutes - (Date.now() - attempts.firstAttempt)) / 60000
    );
    return res.status(429).json({
      error: `Troppi tentativi. Riprova tra ${remainingTime} minuti`,
    });
  }

  next();
}

// Applicazione del rate limiting agli endpoint sensibili
// app.post("/api/login/2fa", rateLimitTwoFactor, handlerLogin2fa);
// app.post("/api/login/recovery", rateLimitTwoFactor, handlerLoginRecovery);

Considerazioni sulla sicurezza in produzione

L'implementazione presentata in questo articolo è funzionale ma richiede diversi accorgimenti prima di essere utilizzata in produzione.

Il segreto TOTP deve essere cifrato nel database. Salvarlo in chiaro significherebbe che una violazione del database comprometterebbe anche il secondo fattore di autenticazione, vanificando completamente il suo scopo. Utilizzate un algoritmo di cifratura simmetrica come AES-256-GCM con una chiave di cifratura conservata separatamente, ad esempio in una variabile d'ambiente o in un servizio di gestione dei segreti come AWS Secrets Manager o HashiCorp Vault.

Le password devono essere sempre hashate con bcrypt o argon2, mai conservate in chiaro. Il segreto JWT deve essere lungo, casuale e conservato in modo sicuro. In produzione è preferibile utilizzare coppie di chiavi RSA o ECDSA per la firma dei token.

Considerate l'implementazione di notifiche via email quando la 2FA viene attivata, disattivata, o quando viene utilizzato un codice di recupero. Questo permette all'utente legittimo di accorgersi tempestivamente di eventuali accessi non autorizzati.

Infine, registrate in un log di audit ogni tentativo di autenticazione, riuscito o fallito, con timestamp e indirizzo IP. Questi log sono preziosi sia per il debugging che per l'analisi forense in caso di incidente di sicurezza.

Test dell'implementazione

Per verificare il funzionamento completo del flusso, possiamo utilizzare curl dalla riga di comando. Ecco la sequenza di chiamate che copre l'intero ciclo di vita della 2FA.

# 1. Registrazione di un nuovo utente
curl -X POST http://localhost:3000/api/register \
  -H "Content-Type: application/json" \
  -d '{"username": "mario", "password": "password123"}'

# 2. Avvio della configurazione 2FA
curl -X POST http://localhost:3000/api/2fa/setup \
  -H "Content-Type: application/json" \
  -d '{"username": "mario"}'

# 3. Conferma con il codice generato dall'app di autenticazione
curl -X POST http://localhost:3000/api/2fa/verify-setup \
  -H "Content-Type: application/json" \
  -d '{"username": "mario", "token": "123456"}'

# 4. Login - primo passaggio (credenziali)
curl -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"username": "mario", "password": "password123"}'

# 5. Login - secondo passaggio (codice TOTP)
curl -X POST http://localhost:3000/api/login/2fa \
  -H "Content-Type: application/json" \
  -d '{"partialToken": "TOKEN_DAL_PASSO_4", "token": "654321"}'

Conclusione

Implementare la 2FA in Node.js è un processo articolato ma alla portata di qualsiasi sviluppatore. La libreria speakeasy gestisce tutta la complessità crittografica del protocollo TOTP, permettendoci di concentrarci sulla logica applicativa. I punti chiave da ricordare sono: separare il flusso di login in due fasi distinte, richiedere una verifica iniziale prima di attivare la 2FA, fornire sempre codici di recupero, proteggere gli endpoint con rate limiting, e cifrare i segreti nel database. Con queste accortezze, la vostra applicazione offrirà un livello di sicurezza significativamente superiore rispetto alla sola autenticazione tramite password.