Stručně: Bezpečné tokeny vytvoříte v PHP pomocí random_bytes(), například 32 bajtů, které převedete na text pomocí bin2hex() nebo Base64-URL. Pro náhodná čísla v rozsahu existuje random_int(), pro kódy z pevné sady znaků od PHP 8.3 Random\Randomizer::getBytesFromString(). Tokeny ukládejte jako hash, omezte jejich platnost, používejte je jen jednou a porovnávejte je pomocí hash_equals().
Odkaz, který se příliš snadno uhodne
„Zapomněli jste heslo?“ Jedno kliknutí a systém odešle odkaz s dlouhým kódem. Kdo odkaz zná, může nastavit heslo. Právě proto musí být tento kód nepředvídatelný. Pokud není, útočník nepotřebuje ani schránku, ani staré heslo, jen dostatek pokusů.
Při kontrolách aplikací v PHP se překvapivě často najdou tokeny sestavené z času, ID uživatele nebo jednoduchého generátoru náhodných čísel. Vypadají dlouze a náhodně, ale nejsou. Totéž platí pro klíče API, pozvánkové odkazy, odkazy ke sdílení dokumentů, potvrzovací kódy a tokeny CSRF.
Riziko je hmatatelné: předvídatelný odkaz k resetu umožňuje převzít každý účet, jehož e-mailová adresa je známá, aniž by si majitel čehokoli všiml, dokud se sám nepřestane moci přihlásit.
PHP nabízí od verze 7.0 jednoduché a bezpečné řešení. Stačí ho důsledně používat.
random_bytes v PHP: odkud pocházejí bezpečné náhodné hodnoty
random_bytes() poskytuje podle příručky PHP kryptograficky bezpečné náhodné bajty, které se hodí i pro dlouhodobá tajemství, jako jsou klíče. Funkce používá zdroj náhody operačního systému, v Linuxu například getrandom(), ve Windows rozhraní CNG. Pokud nenajde vhodný zdroj, vyhodí od PHP 8.2 výjimku Random\RandomException.
Přehled ukazuje, které funkce se pro tajné hodnoty hodí:
| Funkce | Vhodná pro tokeny? | Poznámka |
|---|---|---|
random_bytes() |
ano | bezpečné bajty, od PHP 7.0 |
random_int() |
ano | bezpečné číslo v rozsahu, od PHP 7.0 |
Random\Randomizer s Secure enginem |
ano | Secure je podle příručky výchozí engine |
rand(), mt_rand() |
ne | předvídatelné, jen pro účely bez bezpečnostního významu |
uniqid() |
ne | založené na čase, určené pro jedinečnost |
md5(time()), sha1($id) |
ne | vstup lze uhodnout, hash na tom nic nemění |
str_shuffle(), array_rand() |
ne | nepoužívají bezpečný generátor |
Vytváření tokenů: délka a kódování
32 náhodných bajtů odpovídá 256 bitům a pro téměř jakýkoli účel je to více než dost. Pro přenos v odkazech nebo hlavičkách se musí token převést na text. Běžné jsou dvě varianty:
<?php
declare(strict_types=1);
// Hex: 64 znaků, jen 0-9 a a-f
$tokenHex = bin2hex(random_bytes(32));
// Base64-URL: 43 znaků, bezpečné v URL bez dalšího kódování
$tokenUrl = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
// Náhodné číslo v rozsahu, například pro výběr
$index = random_int(0, 9);
// Od PHP 8.3: šestimístný číselný kód, např. pro potvrzení přes SMS
$randomizer = new \Random\Randomizer();
$code = $randomizer->getBytesFromString('0123456789', 6);
Krátké číselné kódy jsou obhajitelné jen tehdy, když je počet pokusů přísně omezen a kód po několika minutách vyprší. Pro odkazy rozesílané e-mailem vždy použijte dlouhý token.
U klíčů API, které partneři nebo vlastní systémy používají trvale, se osvědčily dva doplňky. Krátká, neutajovaná předpona před náhodnou částí, například pro prostředí nebo účel, usnadňuje rozpoznání v logech a při nálezech v repozitářích kódu. A klíč se zobrazí jen jednou při vytvoření; poté systém zná už jen jeho hash. Pokud se klíč ztratí, nahradí se, neobnovuje se.
Pozor na vlastnoručně sestavené tokeny, které obsahují informace, například ID uživatele a čas vypršení v Base64. Kódování není zabezpečení: každý může takové tokeny číst a měnit. Pokud má token nést data, musí být podepsán tajným klíčem, například pomocí hash_hmac('sha256', ...), a podpis se ověřuje pomocí hash_equals(). Pro náročnější případy je lepší používat ověřené knihovny než vlastní konstrukce.
Ukládání, ověřování a vypršení tokenů
Bezpečně vytvořený token je jen polovina práce. Stejně důležité je, jak s ním aplikace nakládá. Osvědčený postup pro odkazy k resetu a pozvánky:
- Ukládat jen hash: V databázi je
hash('sha256', $token), samotný token odchází uživateli jen v odkazu. - Omezit platnost: Dobu vypršení uložit a při uplatnění ověřit.
- Použít jednou: Po úspěšném použití záznam smazat nebo označit jako spotřebovaný.
- Při změnách zneplatnit: Po změně hesla smazat všechny otevřené tokeny k resetu daného účtu.
- Porovnávat v konstantním čase: Kde se token přímo porovnává s uloženou hodnotou, použít
hash_equals()s uloženou hodnotou jako prvním parametrem.
<?php
// Vytvoření
$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'),
];
// $token odeslat jen v odkazu, $eintrag uložit
// Uplatnění
$gesucht = hash('sha256', (string) ($_GET['token'] ?? ''));
// Záznam vyhledat pomocí prepared statement podle token_hash,
// zkontrolovat vypršení, poté záznam okamžitě smazat
Omezte také počet požadavků na reset na účet a časové období, aby funkci nešlo zneužít k hromadnému rozesílání e-mailů. Odpověď na požadavek na reset by navíc neměla prozradit, zda zadaná adresa vůbec má účet.
Rychlý hash jako SHA-256 zde stačí, protože samotný token je už dlouhý a náhodný. Pro hesla, která volí lidé, to neplatí; tam jsou potřeba pomalé postupy, jak vysvětluje článek Bezpečné ukládání hesel v PHP.
Tokeny nepatří do logů. Zkontrolujte, zda váš webserver loguje úplné URL s parametry dotazu a zda chybové zprávy neodesílají data požadavku.
Po incidentu: obnovte tokeny a klíče
Kdo mohl na serveru spouštět kód, měl zpravidla přístup i k uloženým tokenům, klíčům API a datům relací. Pokud po PHP2Shell nebo jiném incidentu existuje podezření na průnik, měli byste proto obnovit nejen hesla, ale i dlouhodobé tokeny a klíče: přístupy k API, podpisové klíče pro cookies nebo odkazy, přístupy k platebním a dopravním službám.
Relace jsou zvláštní případ: ID relací vytváří PHP samo. Jak zabezpečit jejich nastavení a po incidentu relace ukončit, popisuje příručka Zabezpečení relací v PHP. K tokenům formulářů proti padělaným požadavkům viz Ochrana proti CSRF v aplikacích PHP. Další vestavěné nástroje představuje stránka Vestavěné bezpečnostní funkce PHP.
Jak konkrétně pomáhá PHP SOS?
- Ve vaší aplikaci najdeme všechna místa, kde se vytvářejí tokeny, kódy a klíče, a nahradíme nebezpečné zdroje.
- Zkontrolujeme ukládání, vypršení a jednorázové použití odkazů k resetu, pozvánek a sdílení.
- Postaráme se o to, aby tokeny nekončily v lozích, chybových zprávách ani analytických nástrojích.
- Po incidentu s vámi naplánujeme uspořádanou výměnu tokenů a klíčů.
Kontrolu vlastního softwaru v PHP nabízíme jako bezpečnostní audit a penetrační test.
Časté dotazy
Proč se mt_rand pro tokeny nehodí?
mt_rand a rand jsou určeny pro statistiku a hry, ne pro tajné hodnoty. Jejich výstupy lze za určitých okolností předpovědět. Pro vše, co útočník nesmí uhodnout, používejte random_bytes nebo random_int.
Jak dlouhý by měl token být?
Pro odkazy k obnovení hesla, pozvánky nebo klíče API je solidní volbou 32 náhodných bajtů, jako text v hexadecimálním zápisu 64 znaků. 16 bajtů, tedy 128 bitů, je rozšířená spodní hranice.
Je uniqid bezpečný token?
Ne. uniqid vychází z aktuálního času a je určen pro jedinečnost, ne pro utajení. Také kombinace jako md5 z času nebo ID uživatele jsou předvídatelné.
Musím tokeny pro reset v databázi šifrovat?
Místo tokenu ukládejte jeho hash, například pomocí SHA-256. Kdo čte databázi, nemůže z něj sestavit platné odkazy. Protože je samotný token už dlouhý a náhodný, nepotřebuje pomalý postup jako u hesel.
Co se stane, když systém nenajde zdroj náhody?
random_bytes a random_int vyhazují od PHP 8.2 výjimku Random\RandomException. Tento případ zachyťte a operaci přerušte, místo abyste přecházeli na slabší zdroj.