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,HttpOnlye una politicaSameSiteadeguata. - 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.
