In breve: i token sicuri in PHP si generano con random_bytes(), ad esempio 32 byte, che si convertono in testo con bin2hex() o Base64-URL. Per i numeri casuali in un intervallo esiste random_int(), per i codici a partire da un insieme fisso di caratteri, da PHP 8.3, Random\Randomizer::getBytesFromString(). I token si salvano come hash, se ne limita la validità, si usano una sola volta e si confrontano con hash_equals().
Un link troppo facile da indovinare
«Password dimenticata?» Un clic, e il sistema invia un link con un lungo codice. Chi conosce il link può impostare la password. Proprio per questo il codice deve essere imprevedibile. Se non lo è, a un aggressore non servono né la casella di posta né la vecchia password, solo abbastanza tentativi.
Nelle verifiche delle applicazioni PHP si trovano sorprendentemente spesso token costruiti con l'ora, l'ID utente o un semplice generatore di numeri casuali. Sembrano lunghi e casuali, ma non lo sono. Lo stesso vale per chiavi API, link di invito, link di condivisione di documenti, codici di conferma e token CSRF.
Il rischio è concreto: un link di reset prevedibile consente di prendere il controllo di qualsiasi account il cui indirizzo e-mail sia noto, senza che il titolare si accorga di nulla, finché non riesce più ad accedere.
PHP offre dalla versione 7.0 una soluzione semplice e sicura. Basta usarla con coerenza.
random_bytes in PHP: da dove provengono i valori casuali sicuri
Secondo il manuale PHP random_bytes() fornisce byte casuali crittograficamente sicuri, adatti anche a segreti di lunga durata come le chiavi. La funzione usa la fonte di casualità del sistema operativo, ad esempio getrandom() su Linux e l'interfaccia CNG su Windows. Se non trova una fonte adatta, da PHP 8.2 genera una Random\RandomException.
La panoramica mostra quali funzioni sono adatte ai segreti:
| Funzione | Adatta ai token? | Nota |
|---|---|---|
random_bytes() |
sì | Byte sicuri, da PHP 7.0 |
random_int() |
sì | Numero sicuro in un intervallo, da PHP 7.0 |
Random\Randomizer con motore Secure |
sì | Secondo il manuale Secure è il motore predefinito |
rand(), mt_rand() |
no | Prevedibili, solo per scopi non rilevanti per la sicurezza |
uniqid() |
no | Basata sul tempo, pensata per l'unicità |
md5(time()), sha1($id) |
no | Input indovinabile, l'hash non cambia nulla |
str_shuffle(), array_rand() |
no | Non usano un generatore sicuro |
Generare i token: lunghezza e codifica
32 byte casuali equivalgono a 256 bit e per quasi ogni scopo sono più che sufficienti. Per la trasmissione in link o header il token va convertito in testo. Due varianti sono comuni:
<?php
declare(strict_types=1);
// Hex: 64 caratteri, solo 0-9 e a-f
$tokenHex = bin2hex(random_bytes(32));
// Base64-URL: 43 caratteri, sicuro negli URL senza ulteriore codifica
$tokenUrl = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
// Numero casuale in un intervallo, ad esempio per una selezione
$index = random_int(0, 9);
// Da PHP 8.3: codice numerico di sei cifre, ad es. per una conferma via SMS
$randomizer = new \Random\Randomizer();
$code = $randomizer->getBytesFromString('0123456789', 6);
I codici numerici brevi sono accettabili solo se il numero di tentativi è rigorosamente limitato e il codice scade dopo pochi minuti. Per i link inviati per e-mail usi sempre il token lungo.
Per le chiavi API che partner o sistemi propri usano in modo duraturo si sono rivelate valide due integrazioni. Un breve prefisso non segreto davanti alla parte casuale, ad esempio per ambiente o scopo, facilita il riconoscimento nei log e nei ritrovamenti nei repository di codice. E la chiave viene mostrata una sola volta al momento della creazione; dopo, il sistema ne conosce solo l'hash. Se una chiave va persa, la si sostituisce, non la si ripristina.
Attenzione ai token costruiti in proprio che contengono informazioni, ad esempio ID utente e scadenza in Base64. La codifica non è una protezione: chiunque può leggere e modificare tali token. Se un token deve trasportare dati, deve essere firmato con una chiave segreta, ad esempio con hash_hmac('sha256', ...), e la firma va verificata con hash_equals(). Per i casi più complessi sono preferibili librerie collaudate a costruzioni proprie.
Salvare, verificare e far scadere i token
Un token generato in modo sicuro è solo metà dell'opera. Altrettanto importante è come l'applicazione lo gestisce. Un procedimento collaudato per i link di reset e di invito:
- Salvare solo l'hash: nel database c'è
hash('sha256', $token), il token stesso va all'utente solo tramite il link. - Limitare la validità: salvare anche la scadenza e verificarla all'utilizzo.
- Usare una sola volta: dopo l'uso riuscito cancellare la voce o contrassegnarla come consumata.
- Invalidare in caso di modifiche: dopo un cambio di password cancellare tutti i token di reset aperti dell'account.
- Confrontare in tempo costante: dove un token viene confrontato direttamente con un valore salvato, usare
hash_equals(), con il valore salvato come primo parametro.
<?php
// Generare
$token = bin2hex(random_bytes(32));
$eintrag = [
'konto_id' => $kontoId,
'token_hash' => hash('sha256', $token),
'gueltig_bis'=> (new DateTimeImmutable('+30 minutes'))->format('Y-m-d H:i:s'),
];
// inviare $token solo nel link, salvare $eintrag
// Riscattare
$gesucht = hash('sha256', (string) ($_GET['token'] ?? ''));
// cercare la voce tramite token_hash con prepared statement,
// verificare la scadenza, poi cancellare subito la voce
Limiti inoltre il numero di richieste di reset per account e periodo, affinché la funzione non possa essere usata in modo improprio per l'invio massivo di e-mail. La risposta a una richiesta di reset non dovrebbe poi rivelare se l'indirizzo indicato abbia davvero un account.
Un hash veloce come SHA-256 qui basta, perché il token stesso è già lungo e casuale. Per le password scelte dalle persone non vale lo stesso; lì servono algoritmi lenti, come spiega Salvare le password in PHP in sicurezza.
I token non appartengono ai file di log. Verifichi se il suo server web registra URL completi con parametri di query e se i rapporti di errore inviano con sé i dati della richiesta.
Dopo un incidente: rinnovare token e chiavi
Chi ha potuto eseguire codice su un server di norma ha avuto accesso anche a token, chiavi API e dati di sessione salvati. Se dopo PHP2Shell o un altro incidente c'è il sospetto di un'intrusione, dovrebbe quindi rinnovare non solo le password, ma anche token e chiavi di lunga durata: accessi API, chiavi di firma per cookie o link, accessi a servizi di pagamento e spedizione.
Le sessioni sono un caso particolare: PHP genera da sé gli ID di sessione. Come mettere in sicurezza le relative impostazioni e terminare le sessioni dopo un incidente lo descrive la guida Mettere in sicurezza le sessioni in PHP. Per i token dei moduli contro le richieste falsificate vedi Protezione CSRF nelle applicazioni PHP. Altri strumenti integrati li presenta Funzioni di sicurezza integrate di PHP.
Come aiuta concretamente PHP SOS?
- Troviamo nella sua applicazione tutti i punti in cui vengono generati token, codici e chiavi e sostituiamo le fonti insicure.
- Verifichiamo salvataggio, scadenza e uso singolo dei link di reset, invito e condivisione.
- Facciamo in modo che i token non finiscano in log, rapporti di errore o strumenti di analisi.
- Dopo un incidente pianifichiamo con Lei la sostituzione ordinata di token e chiavi.
La verifica del software PHP proprio la offriamo come audit di sicurezza e penetration test.
Domande frequenti
Perché mt_rand non è adatto ai token?
mt_rand e rand sono pensati per statistica e giochi, non per i segreti. I loro output in certi casi si possono prevedere. Per tutto ciò che un aggressore non deve indovinare usi random_bytes o random_int.
Quanto dovrebbe essere lungo un token?
Per i link di reimpostazione delle password, gli inviti o le chiavi API, 32 byte casuali sono una scelta solida, in testo esadecimale 64 caratteri. 16 byte, cioè 128 bit, sono un limite inferiore diffuso.
uniqid è un token sicuro?
No. uniqid si basa sull'ora corrente ed è pensato per l'unicità, non per la riservatezza. Anche combinazioni come md5 di ora o ID utente sono prevedibili.
Devo cifrare nel database i token di reset?
Salvi al posto del token un suo hash, ad esempio con SHA-256. Chi legge il database non può così costruire link validi. Poiché il token stesso è già lungo e casuale, non serve un algoritmo lento come per le password.
Che cosa succede se il sistema non trova alcuna fonte di casualità?
random_bytes e random_int da PHP 8.2 generano una Random\RandomException. Intercetti questo caso e interrompa l'operazione, anziché ripiegare su una fonte più debole.