In questa lezione impari a
- spiegare perché HTTP "non ricorda" e come lo risolvono cookie e sessioni
- usare session_start e $_SESSION
- salvare e verificare le password con password_hash e password_verify
- realizzare login, logout e pagine riservate
- proteggere i form dagli attacchi CSRF con un token
Oggi chiunque apra il sito può aggiungere, modificare ed eliminare libri. Serve un'area riservata: solo il bibliotecario, dopo aver inserito nome utente e password, può cambiare i dati. Per farlo il server deve ricordarsi di lui da una pagina all'altra.
HTTP non ha memoria
Ogni richiesta HTTP è indipendente dalle altre: quando il browser chiede prestiti.php, il server non sa che due secondi prima lo stesso browser ha fatto il login. Si dice che HTTP è un protocollo senza stato (stateless).
La soluzione è un cookie: un piccolo testo che il server manda al browser e che il browser gli rimanda a ogni richiesta successiva, come un braccialetto all'ingresso di un concerto.
Le sessioni di PHP usano questo meccanismo:
- alla prima visita PHP crea una sessione con un identificativo casuale e lungo (
PHPSESSID=k3j9f…) e lo manda al browser in un cookie; - i dati della sessione (chi è l'utente, il suo ruolo…) restano sul server, in un file associato a quell'identificativo;
- a ogni richiesta il browser rimanda il cookie e PHP ritrova i dati giusti in
$_SESSION.
Nel cookie c'è solo un numero senza significato: i dati veri il visitatore non li vede e non li può modificare.
<?php
session_start(); // all'inizio di OGNI pagina che usa la sessione, prima di qualsiasi output
$_SESSION['visite'] = ($_SESSION['visite'] ?? 0) + 1;
echo "Hai aperto questa pagina {$_SESSION['visite']} volte.";
Ricarica la pagina: il numero cresce. Apri la stessa pagina in una finestra anonima: riparte da 1, perché è un altro browser con un'altra sessione. Negli strumenti per sviluppatori (F12 → Applicazione → Cookie) vedi il cookie PHPSESSID.
Nel kit, session_start() è già in funzioni.php, che ogni pagina include: per questo messaggio() funzionava già nella lezione 12.
Chi ruba il cookie di sessione di un utente (per esempio con un attacco XSS, lezione 9) può presentarsi al server al suo posto. Per questo
h()è così importante, e su un sito vero il sito va su HTTPS.
Le password: mai in chiaro
La soluzione più semplice sarebbe salvare la password così com'è e controllarla nella query, come nell'esercizio 3 della lezione 11:
username | password
admin | biblioteca2026
<?php
// ❌ password in chiaro: funziona, ma è un errore grave
$stmt = mysqli_prepare($conn, 'SELECT * FROM utenti WHERE username = ? AND password = ?');
mysqli_stmt_bind_param($stmt, 'ss', $username, $password);
Funziona, e con la query preparata non c'è nemmeno la SQL injection. Il problema è un altro: chiunque legga la tabella vede tutte le password. Può essere un attacco, un backup dimenticato su una chiavetta, un collega curioso con phpMyAdmin. E siccome molte persone usano la stessa password ovunque, con quelle password si entra anche nella loro email o nei loro social.
Per questo nella tabella utenti del nostro database le password non ci sono. C'è una colonna password_hash:
admin | $2y$10$wA1VGBNR..2zIRev24224.8Vtl.2MA2XtXPs85.uYQ8rs5kALt6s6
È un'impronta (hash) della password: si calcola dalla password con una funzione a senso unico. Dalla password si ottiene l'impronta, ma dall'impronta non si risale alla password. Se un giorno qualcuno rubasse il database (succede anche a grandi aziende), troverebbe solo impronte.
PHP fa tutto con due funzioni:
<?php
$impronta = password_hash('biblioteca2026', PASSWORD_DEFAULT);
echo $impronta; // $2y$10$... ogni volta diversa!
var_dump(password_verify('biblioteca2026', $impronta)); // bool(true)
var_dump(password_verify('biblioteca2025', $impronta)); // bool(false)
password_hashusa un algoritmo lento apposta (bcrypt): per un utente che fa il login, qualche centesimo di secondo non si nota; per chi prova miliardi di password per indovinarne una, è un ostacolo enorme.- Aggiunge da sola un sale (salt): un valore casuale diverso ogni volta. Per questo la stessa password dà impronte diverse, e due utenti con la stessa password non hanno la stessa impronta.
password_verifysa leggere algoritmo e sale dall'impronta stessa e controlla se la password corrisponde.
Non usare
md5()osha1()per le password, anche se le trovi in tanti esempi vecchi. Sono velocissime (pensate per altro): una scheda video ne prova miliardi al secondo, e per le password comuni esistono tabelle già calcolate. La colonnapassword_hashè unVARCHAR(255)perché in futuroPASSWORD_DEFAULTpotrà passare ad algoritmi con impronte più lunghe.
L'utente di prova del database è admin, password biblioteca2026. Per creare altri utenti si calcola l'impronta e la si inserisce con una query preparata (vedi l'esercizio 1).
Il login
<?php
require 'funzioni.php';
$errore = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_controlla();
$username = trim($_POST['username'] ?? '');
$stmt = mysqli_prepare($conn, 'SELECT id, username, password_hash, ruolo FROM utenti WHERE username = ?');
mysqli_stmt_bind_param($stmt, 's', $username);
mysqli_stmt_execute($stmt);
$u = mysqli_fetch_assoc(mysqli_stmt_get_result($stmt));
// password_verify confronta la password scritta con l'impronta salvata (mai la password in chiaro!)
if ($u && password_verify($_POST['password'] ?? '', $u['password_hash'])) {
session_regenerate_id(true); // nuovo identificativo di sessione: protegge dal furto di sessione
$_SESSION['utente'] = ['id' => $u['id'], 'username' => $u['username'], 'ruolo' => $u['ruolo']];
messaggio('Benvenuto, ' . $u['username'] . '!');
vai('prestiti.php');
}
// stesso messaggio se sbagli utente o password: non diciamo quale dei due è sbagliato
$errore = 'Nome utente o password sbagliati.';
}
$titolo = 'Accedi';
require 'header.php';
?>
<h1>Accedi</h1>
<?php if ($errore): ?><p class="messaggio errore"><?= h($errore) ?></p><?php endif; ?>
<form method="post" class="modulo">
<?= csrf_campo() ?>
<label>Nome utente <input name="username" required autocomplete="username"></label>
<label>Password <input name="password" type="password" required autocomplete="current-password"></label>
<button>Entra</button>
</form>
<?php require 'footer.php'; ?>
Riga per riga:
- si cerca l'utente per username, con una query preparata (la lezione 11 ci ha mostrato cosa succede altrimenti);
- se esiste,
password_verifycontrolla la password. Nella query non si confronta la password: l'impronta va verificata in PHP; - se è tutto giusto, si salvano in sessione i dati che servono (id, username, ruolo; mai la password o l'impronta);
session_regenerate_id(true)cambia l'identificativo della sessione al momento del login. Protegge dalla session fixation: un attaccante che fosse riuscito a far usare alla vittima un identificativo scelto da lui, dopo il login se lo ritrova inutile;- il messaggio d'errore è lo stesso sia per username inesistente sia per password sbagliata: così un estraneo non può scoprire quali username esistono.
csrf_campo() e csrf_controlla() li spieghiamo tra poco.
Il logout
<?php
require 'funzioni.php';
$_SESSION = []; // svuoto i dati della sessione
session_destroy(); // e la chiudo
session_start(); // ne apro una nuova solo per il messaggio
messaggio('Sei uscito.');
vai('index.php');
Le pagine riservate
Dopo il login, $_SESSION['utente'] esiste; prima no. Una pagina riservata deve quindi controllarlo all'inizio, e mandare al login chi non è entrato:
<?php
require 'funzioni.php';
if (!isset($_SESSION['utente'])) {
header('Location: login.php');
exit;
}
// ...da qui in giù, solo chi ha fatto il login
Funziona, ma andrebbe copiato in ogni pagina riservata. Come per h(), conviene trasformarlo in una funzione. In funzioni.php ce ne sono due pronte:
<?php
function utente(): ?array
{
return $_SESSION['utente'] ?? null;
}
// Le pagine riservate la chiamano all'inizio: chi non ha fatto il login torna alla pagina di accesso
function richiedi_login(): void
{
if (!utente()) {
messaggio('Per questa pagina devi accedere.', 'errore');
vai('login.php');
}
}
Proteggere una pagina ora costa una riga, subito dopo il require:
<?php
require 'funzioni.php';
richiedi_login();
Aggiungila a libro_form.php, libro_elimina.php e a tutte le pagine che modificano dati. Poi, in libro.php, mostra i pulsanti Modifica ed Elimina solo a chi è entrato:
<?php if (utente()): ?>
<div class="azioni">
...i due pulsanti...
</div>
<?php endif; ?>
⚠️ Nascondere il pulsante non basta. Se togli il pulsante ma dimentichi
richiedi_login()nella pagina che esegue l'azione, chiunque conosca l'indirizzo può comunque usarla (basta un form fatto in casa che inviaid=3alibro_elimina.php). La protezione vera sta nella pagina che fa l'azione; nasconderlo è solo una questione di interfaccia.
header.php del kit usa già utente() per cambiare il menu: prima del login mostra "Accedi", dopo mostra "Prestiti", "+ Nuovo libro" ed "Esci (admin)".
I ruoli
Ogni utente ha un ruolo (bibliotecario o admin), salvato in sessione al login. Per un'azione riservata agli amministratori:
<?php
require 'funzioni.php';
richiedi_login();
if (utente()['ruolo'] !== 'admin') {
http_response_code(403); // 403 = vietato
exit('Solo gli amministratori possono eliminare i libri.');
}
L'attacco CSRF
Il login protegge le pagine da chi non è entrato. Ma c'è un attacco che sfrutta proprio chi è entrato: il CSRF (Cross-Site Request Forgery, falsificazione di richieste tra siti).
- Il bibliotecario è entrato nella biblioteca, e il suo browser ha il cookie di sessione.
- In un'altra scheda apre un sito qualsiasi, magari un link ricevuto per email. La pagina contiene, nascosto:
<form action="http://localhost/biblioteca/libro_elimina.php" method="post" id="f">
<input type="hidden" name="id" value="15">
</form>
<script>document.getElementById('f').submit();</script>
- Il form parte da solo verso la biblioteca, e il browser, come sempre, aggiunge il cookie di sessione. Per il server è una richiesta del bibliotecario: il libro viene eliminato.
Il server non può distinguere la richiesta "vera" da quella falsificata guardando il cookie, perché il cookie c'è in entrambe.
La difesa: il token
La soluzione è un token: un codice segreto e casuale, salvato in sessione, che la biblioteca inserisce in ogni suo form come campo nascosto. Quando il form arriva, il server controlla che il token ci sia e sia giusto. Il sito dell'attaccante non può conoscerlo, perché non può leggere le pagine della biblioteca: la sua richiesta falsa arriva senza token, o con quello sbagliato, e viene respinta.
<?php
// Token contro gli attacchi CSRF: ogni modulo POST lo invia, il server lo controlla
function csrf_campo(): string
{
$_SESSION['csrf'] ??= bin2hex(random_bytes(16));
return '<input type="hidden" name="csrf" value="' . $_SESSION['csrf'] . '">';
}
function csrf_controlla(): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(400);
die('Richiesta non valida: ricarica la pagina e riprova.');
}
}
random_bytes(16)genera 16 byte imprevedibili (adatti alla crittografia, non comerand());bin2hexli trasforma in 32 caratteri esadecimali.??=crea il token solo se non esiste già.hash_equalsconfronta due stringhe in un tempo che non dipende da quanti caratteri sono uguali: evita che si possa indovinare il token un carattere alla volta misurando i tempi di risposta.
L'uso è sempre lo stesso, in due punti:
<!-- 1. in OGNI form POST, subito dopo <form> -->
<form method="post" action="libro_elimina.php">
<?= csrf_campo() ?>
...
</form>
<?php
// 2. in OGNI pagina che riceve un POST, prima di fare qualsiasi cosa
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_controlla();
// ...
}
Aggiungili ai form di libro.php (Elimina), libro_form.php e login.php, e a libro_elimina.php.
Le difese si sommano: POST per le azioni (lezione 9), login per le pagine riservate, token CSRF contro le richieste falsificate, query preparate contro la SQL injection,
h()contro l'XSS. Nessuna da sola basta; tutte insieme rendono l'applicazione solida. È anche l'elenco che all'Esame di Stato ti permette di scrivere un buon paragrafo sulla sicurezza.
I browser moderni aggiungono una protezione in più: i cookie con l'attributo SameSite=Lax (predefinito in Chrome ed Edge) non vengono inviati con i form POST che partono da altri siti. È un aiuto, ma non tutti i browser lo applicano allo stesso modo: il token resta necessario.
Esercizi
Esercizio 1 – Nuovo utente. Crea utente_nuovo.php, una pagina riservata agli admin, con un form per creare un utente (username, password ripetuta due volte, ruolo). La password deve avere almeno 10 caratteri. Salva solo l'impronta.
Mostra la soluzione
<?php
require 'funzioni.php';
richiedi_login();
if (utente()['ruolo'] !== 'admin') {
http_response_code(403);
exit('Pagina riservata agli amministratori.');
}
$errori = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_controlla();
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$ruolo = $_POST['ruolo'] ?? '';
if (!preg_match('/^[a-z0-9._]{3,30}$/', $username)) $errori[] = 'Username: da 3 a 30 lettere minuscole, cifre, punto o trattino basso.';
if (mb_strlen($password) < 10) $errori[] = 'La password deve avere almeno 10 caratteri.';
if ($password !== ($_POST['conferma'] ?? '')) $errori[] = 'Le due password non coincidono.';
if (!in_array($ruolo, ['bibliotecario', 'admin'], true)) $errori[] = 'Ruolo non valido.';
if (!$errori) {
try {
$impronta = password_hash($password, PASSWORD_DEFAULT);
$stmt = mysqli_prepare($conn, 'INSERT INTO utenti (username, password_hash, ruolo) VALUES (?, ?, ?)');
mysqli_stmt_bind_param($stmt, 'sss', $username, $impronta, $ruolo);
mysqli_stmt_execute($stmt);
messaggio("Utente $username creato.");
vai('prestiti.php');
} catch (mysqli_sql_exception $e) {
if ($e->getCode() === 1062) $errori[] = 'Questo username esiste già.';
else throw $e;
}
}
}
// ...poi header, elenco degli errori e il form con csrf_campo(),
// i campi username, password, conferma (type="password") e un <select name="ruolo">
La password non passa da trim: uno spazio all'inizio o alla fine può far parte della password scelta. E non si rimostra mai nel form in caso di errore.
Esercizio 2 – Prova il CSRF. Con il bibliotecario entrato, crea in un'altra cartella di htdocs il file trappola.html con il form nascosto della lezione, puntato a un libro senza prestiti che hai creato tu. Aprilo prima senza il controllo del token in libro_elimina.php, poi con. Cosa succede? Cosa cambia se nella trappola scrivi un token inventato?
Mostra la soluzione
- Senza controllo: aprendo
trappola.htmlil libro viene eliminato, e il browser finisce sul catalogo della biblioteca con il messaggio "Libro eliminato". L'utente non ha cliccato nulla. - Con
csrf_controlla(): la pagina risponde "Richiesta non valida" (codice 400) e il libro resta. - Con un token inventato: stessa cosa,
hash_equalslo rifiuta. L'attaccante dovrebbe conoscere il token della sessione del bibliotecario, ma non può leggerlo.
Nota: se il tuo browser applica SameSite=Lax e la trappola è su un indirizzo diverso (per esempio 127.0.0.1 invece di localhost), il cookie potrebbe non partire e l'attacco fallire già senza token. Su localhost lo stesso sito, invece, il cookie parte: è proprio il motivo per cui il token serve comunque.
Esercizio 3 – Troppi tentativi. Modifica login.php in modo che, dopo 5 tentativi sbagliati nella stessa sessione, il login venga bloccato per 5 minuti. Perché questa protezione, da sola, non basta contro un attaccante determinato?
Mostra la soluzione
<?php
// in cima al blocco POST, dopo csrf_controlla()
if (($_SESSION['blocco_fino'] ?? 0) > time()) {
$errore = 'Troppi tentativi. Riprova tra qualche minuto.';
} else {
// ... la verifica di prima; in caso di successo:
// unset($_SESSION['tentativi']);
// in caso di errore:
$_SESSION['tentativi'] = ($_SESSION['tentativi'] ?? 0) + 1;
if ($_SESSION['tentativi'] >= 5) {
$_SESSION['blocco_fino'] = time() + 300;
$_SESSION['tentativi'] = 0;
}
}
La sessione la controlla il browser: un attaccante può cancellare il cookie e ricominciare con una sessione nuova, oppure usare un programma che non conserva i cookie. Una protezione seria registra i tentativi nel database, per username e per indirizzo IP ($_SERVER['REMOTE_ADDR']). È comunque un buon primo passo, e insieme a password_hash (lento apposta) rende gli attacchi molto più faticosi.