Autenticazione a due fattori con sessioni PHP: gestione sicura dello stato di accesso

by theArchitect
SHARE
Autenticazione a due fattori con sessioni PHP: gestione sicura dello stato di accesso
© Guida-HTML5.it

Introduzione

L’autenticazione con username e password protegge l’accesso, ma non è sufficiente in tutti gli scenari. Una password può essere rubata tramite phishing, riutilizzata su più siti oppure esposta durante una fuga di dati. Un secondo fattore, come un codice temporaneo inviato via email o generato da un’app, aggiunge un ulteriore livello di sicurezza.

In questo tutorial realizzeremo un flusso di autenticazione a due passaggi usando le sessioni PHP. L’aspetto più importante non è soltanto verificare il codice, ma rappresentare correttamente lo stato dell’utente:

  • prima verifica: credenziali corrette, ma autenticazione non ancora completata;
  • seconda verifica: codice temporaneo valido;
  • accesso autorizzato: la sessione contiene un’identità autenticata.

Questa distinzione impedisce di concedere l’accesso completo dopo la sola verifica della password.

Codice completo

Il seguente esempio usa un unico file PHP per semplicità. In un’applicazione reale, il controllo dell’utente dovrebbe essere eseguito interrogando un database e il codice dovrebbe essere inviato tramite un servizio email o SMS affidabile.

<?php
declare(strict_types=1);

session_start();

/*
 * In produzione questi dati devono provenire dal database.
 * La password è stata generata con password_hash().
 */
$utenteDemo = [
    ´id´ => 15,
    ´email´ => ´[email protected]´,
    ´password_hash´ => password_hash(´Password-Sicura-123!´, PASSWORD_DEFAULT)
];

$messaggio = ´´;
$errore = ´´;

if ($_SERVER[´REQUEST_METHOD´] === ´POST´) {
    $azione = $_POST[´azione´] ?? ´´;

    if ($azione === ´login´) {
        $email = trim($_POST[´email´] ?? ´´);
        $password = $_POST[´password´] ?? ´´;

        if (
            $email === $utenteDemo[´email´] &&
            password_verify($password, $utenteDemo[´password_hash´])
        ) {
            /*
             * L´utente ha superato il primo fattore.
             * Non impostiamo ancora $_SESSION[´autenticato´].
             */
            $_SESSION[´pending_user_id´] = $utenteDemo[´id´];
            $_SESSION[´pending_email´] = $utenteDemo[´email´];

            // Codice numerico casuale valido solo per questo esempio.
            $codice = (string) random_int(100000, 999999);

            $_SESSION[´otp_hash´] = password_hash($codice, PASSWORD_DEFAULT);
            $_SESSION[´otp_scadenza´] = time() + 300;
            $_SESSION[´otp_tentativi´] = 0;

            /*
             * In produzione: inviare $codice via email o tramite un
             * provider OTP. Non mostrarlo nella risposta HTTP.
             */
            $messaggio = ´Codice inviato. Per la demo il codice è: ´ . $codice;
        } else {
            $errore = ´Credenziali non valide.´;
        }
    }

    if ($azione === ´verifica_otp´) {
        $codiceInserito = trim($_POST[´codice´] ?? ´´);

        $otpPresente = isset(
            $_SESSION[´pending_user_id´],
            $_SESSION[´otp_hash´],
            $_SESSION[´otp_scadenza´],
            $_SESSION[´otp_tentativi´]
        );

        if (!$otpPresente) {
            $errore = ´La verifica non è disponibile. Ripeti il login.´;
        } elseif (time() > $_SESSION[´otp_scadenza´]) {
            $errore = ´Il codice è scaduto.´;
        } elseif ($_SESSION[´otp_tentativi´] >= 5) {
            $errore = ´Numero massimo di tentativi raggiunto.´;
        } else {
            $_SESSION[´otp_tentativi´]++;

            if (password_verify($codiceInserito, $_SESSION[´otp_hash´])) {
                /*
                 * La seconda verifica è completata.
                 * Da questo momento l´utente è autenticato.
                 */
                $_SESSION[´autenticato´] = true;
                $_SESSION[´user_id´] = $_SESSION[´pending_user_id´];
                $_SESSION[´user_email´] = $_SESSION[´pending_email´];

                // Eliminiamo i dati temporanei del secondo fattore.
                unset(
                    $_SESSION[´pending_user_id´],
                    $_SESSION[´pending_email´],
                    $_SESSION[´otp_hash´],
                    $_SESSION[´otp_scadenza´],
                    $_SESSION[´otp_tentativi´]
                );

                $messaggio = ´Autenticazione completata.´;
            } else {
                $errore = ´Codice non valido.´;
            }
        }
    }
}

$autenticato = $_SESSION[´autenticato´] ?? false;
?>

<?php if ($messaggio !== ´´): ?>
    <p><?= htmlspecialchars($messaggio, ENT_QUOTES, ´UTF-8´) ?></p>
<?php endif; ?>

<?php if ($errore !== ´´): ?>
    <p><?= htmlspecialchars($errore, ENT_QUOTES, ´UTF-8´) ?></p>
<?php endif; ?>

<?php if (!$autenticato && !isset($_SESSION[´pending_user_id´])): ?>
    <h3>Primo passaggio: credenziali</h3>
    <form method="post">
        <input type="hidden" name="azione" value="login">
        <input type="email" name="email" required>
        <input type="password" name="password" required>
        <button type="submit">Continua</button>
    </form>
<?php elseif (!$autenticato): ?>
    <h3>Secondo passaggio: codice temporaneo</h3>
    <form method="post">
        <input type="hidden" name="azione" value="verifica_otp">
        <input type="text" name="codice" inputmode="numeric"
               pattern="[0-9]{6}" maxlength="6" required>
        <button type="submit">Verifica codice</button>
    </form>
<?php else: ?>
    <p>Accesso autorizzato per
        <strong><?= htmlspecialchars($_SESSION[´user_email´], ENT_QUOTES, ´UTF-8´) ?></strong>.
    </p>
<?php endif; ?>

Spiegazione

Stato intermedio

Dopo la verifica della password, il codice salva nella sessione solo dati temporanei con il prefisso pending_. Questo stato significa “primo fattore superato”, non “utente autenticato”. Le pagine riservate devono quindi controllare esclusivamente $_SESSION[´autenticato´] === true.

Generazione e memorizzazione del codice

random_int() genera valori casuali adatti a contesti di sicurezza. Il codice viene trasformato con password_hash(), così nella sessione non rimane il valore leggibile. Anche se qualcuno riuscisse a osservare i dati della sessione, non troverebbe direttamente il codice originale.

Scadenza e tentativi

Il codice dura cinque minuti e può essere provato al massimo cinque volte. Questi limiti riducono il rischio di attacchi a forza bruta. Dopo il superamento della soglia, è necessario iniziare una nuova procedura di login.

Controllo delle pagine protette

<?php
declare(strict_types=1);

session_start();

if (($_SESSION[´autenticato´] ?? false) !== true) {
    http_response_code(403);
    exit(´Accesso negato.´);
}

echo ´Contenuto riservato´;
?>

Best practice

  • Usa sempre HTTPS, soprattutto quando vengono trasmesse password e codici temporanei.
  • Non inserire mai il codice OTP nei log, negli URL o nei messaggi visibili in produzione.
  • Memorizza nel database solo password protette con password_hash().
  • Applica un limite ai tentativi anche lato server e, preferibilmente, per account e indirizzo IP.
  • Invalida il codice dopo un uso corretto o dopo la scadenza.
  • Non considerare autenticato l’utente finché il secondo fattore non è stato verificato.
  • Configura i cookie di sessione con flag Secure, HttpOnly e una politica SameSite adeguata.
  • In produzione preferisci TOTP con app come Google Authenticator o strumenti WebAuthn rispetto all’OTP via email, che dipende dalla sicurezza della casella di posta.

Riepilogo

Un’autenticazione a due fattori basata su sessioni richiede una gestione chiara degli stati. Dopo la password bisogna creare una sessione temporanea, generare un codice imprevedibile, applicare scadenza e limite ai tentativi e autorizzare l’utente soltanto dopo la seconda verifica. La sessione deve quindi distinguere tra “verifica in corso” e “accesso completato”. Questa separazione rende il flusso più comprensibile, testabile e sicuro.

Approfondisci con risorse ufficiali

SHARE