Накратко: Сигурни токени в PHP създавате с random_bytes(), около 32 байта, които преобразувате в текст с bin2hex() или Base64-URL. За случайни числа в диапазон има random_int(), а за кодове от фиксиран набор от знаци от PHP 8.3 Random\Randomizer::getBytesFromString(). Токените съхранявате като хеш, ограничавате валидността им, използвате ги само веднъж и ги сравнявате с hash_equals().
Връзка, която се налучква твърде лесно
„Забравена парола?“ Едно щракване и системата изпраща връзка с дълъг код. Който знае връзката, може да зададе паролата. Точно затова този код трябва да е непредвидим. Ако не е, атакуващият не се нуждае нито от пощенската кутия, нито от старата парола, а само от достатъчно опити.
При проверки на PHP приложения учудващо често се намират токени, построени от часа, от потребителския ID или от прост генератор на случайни числа. Те изглеждат дълги и случайни, но не са. Същото важи за API ключове, покани, връзки за споделяне на документи, кодове за потвърждение и CSRF токени.
Рискът е съвсем реален: предвидима връзка за нулиране позволява поемането на всеки акаунт, чийто имейл адрес е известен, без собственикът да забележи нещо, докато сам не може повече да влезе.
PHP предлага от версия 7.0 просто и сигурно решение. Трябва само да се използва последователно.
random_bytes в PHP: откъде идват сигурните случайни стойности
random_bytes() според наръчника на PHP връща криптографски сигурни случайни байтове, подходящи и за дълготрайни тайни като ключове. Функцията ползва източника на случайност на операционната система, под Linux например getrandom(), под Windows интерфейса CNG. Ако не намери подходящ източник, от PHP 8.2 хвърля Random\RandomException.
Прегледът показва кои функции стават за тайни:
| Функция | Подходяща ли е за токени? | Забележка |
|---|---|---|
random_bytes() |
да | сигурни байтове, от PHP 7.0 |
random_int() |
да | сигурно число в диапазон, от PHP 7.0 |
Random\Randomizer със Secure Engine |
да | Secure според наръчника е стандартният Engine |
rand(), mt_rand() |
не | предвидими, само за цели, несвързани със сигурността |
uniqid() |
не | базирана на времето, предвидена за уникалност |
md5(time()), sha1($id) |
не | входът се налучква, хешът не променя това |
str_shuffle(), array_rand() |
не | не ползват сигурен генератор |
Създаване на токени: дължина и кодиране
32 случайни байта съответстват на 256 бита и са повече от достатъчни за почти всяка цел. За предаване във връзки или заглавни части токенът трябва да се преобразува в текст. Обичайни са два варианта:
<?php
declare(strict_types=1);
// Hex: 64 знака, само 0-9 и a-f
$tokenHex = bin2hex(random_bytes(32));
// Base64-URL: 43 знака, безопасно в URL без допълнително кодиране
$tokenUrl = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
// Случайно число в диапазон, например за избор
$index = random_int(0, 9);
// От PHP 8.3: шестцифрен числов код, напр. за потвърждение по SMS
$randomizer = new \Random\Randomizer();
$code = $randomizer->getBytesFromString('0123456789', 6);
Късите числови кодове са оправдани само ако броят на опитите е строго ограничен и кодът изтича след няколко минути. За връзки, които се изпращат по имейл, винаги вземайте дългия токен.
За API ключове, които партньори или собствени системи ползват дългосрочно, са се доказали две допълнения. Кратък, неотаен префикс пред случайната част, например за среда или цел, улеснява разпознаването в журнали и при намиране в хранилища с код. И ключът се показва само веднъж при създаването; след това системата познава само неговия хеш. Ако ключ се изгуби, той се заменя, а не се възстановява.
Внимание при самостоятелно построени токени, които съдържат информация, например потребителски ID и срок на валидност в Base64. Кодирането не е защита: всеки може да чете и променя такива токени. Ако един токен трябва да носи данни, той трябва да бъде подписан с таен ключ, например с hash_hmac('sha256', ...), а подписът се проверява с hash_equals(). За по-сложни случаи проверените библиотеки са за предпочитане пред собствени конструкции.
Съхраняване, проверка и изтичане на токени
Сигурно създаденият токен е само половината работа. Поне толкова важно е как приложението борави с него. Доказан подход за връзки за нулиране и покани:
- Съхранявайте само хеша: в базата данни стои
hash('sha256', $token), а самият токен отива при потребителя само във връзката. - Ограничете валидността: запазете срока на валидност и го проверявайте при използване.
- Използвайте веднъж: след успешна употреба изтрийте записа или го маркирайте като използван.
- Обезсилвайте при промени: след смяна на парола изтрийте всички отворени токени за нулиране на акаунта.
- Сравнявайте за константно време: където токен се сравнява директно със съхранена стойност, използвайте
hash_equals()със съхранената стойност като първи параметър.
<?php
// Създаване
$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 изпращайте само във връзката, $eintrag съхранете
// Използване
$gesucht = hash('sha256', (string) ($_GET['token'] ?? ''));
// Търсете записа чрез Prepared Statement по token_hash,
// проверете срока, после изтрийте записа веднага
Ограничете освен това броя на заявките за нулиране за акаунт и период, за да не може функцията да бъде злоупотребена за масово изпращане на имейли. Отговорът на заявка за нулиране не бива също така да издава дали посоченият адрес изобщо има акаунт.
Бърз хеш като SHA-256 е достатъчен тук, защото самият токен вече е дълъг и случаен. За пароли, които хората избират, това не важи; там са нужни бавни методи, както обяснява Сигурно съхранение на пароли в PHP.
Токените не принадлежат в лог файловете. Проверете дали Вашият уеб сървър записва пълни URL адреси с параметри на заявката и дали отчетите за грешки изпращат данни от заявката.
След инцидент: обновяване на токени и ключове
Който е могъл да изпълнява код на сървъра, по правило е имал достъп и до съхранени токени, API ключове и данни от сесии. Ако след PHP2Shell или друг инцидент има съмнение за пробив, трябва затова да обновите не само паролите, но и дълготрайните токени и ключове: API достъпи, ключове за подпис на бисквитки или връзки, достъпи до платежни и куриерски услуги.
Сесиите са особен случай: PHP сам създава идентификаторите на сесиите. Как да защитите настройките им и да прекратите сесиите след инцидент, описва наръчникът Защита на сесиите в PHP. За токените във формуляри срещу фалшифицирани заявки вижте CSRF защита в PHP приложения. Други вградени инструменти представя Вградените функции за сигурност на PHP.
Как помага PHP SOS конкретно?
- Намираме във Вашето приложение всички места, където се създават токени, кодове и ключове, и заменяме несигурните източници.
- Проверяваме съхранението, срока на валидност и еднократната употреба на връзки за нулиране, покани и споделяне.
- Грижим се токените да не попадат в журнали, отчети за грешки или инструменти за анализ.
- След инцидент планираме с Вас подредената подмяна на токени и ключове.
Проверка на собствен PHP софтуер предлагаме като одит на сигурността и тест за проникване.
Често задавани въпроси
Защо mt_rand е неподходяща за токени?
mt_rand и rand са предвидени за статистика и игри, а не за тайни. Изходът им при определени обстоятелства може да се предскаже. За всичко, което атакуващият не бива да налучква, използвайте random_bytes или random_int.
Колко дълъг трябва да е един токен?
За връзки за нулиране на пароли, покани или API ключове 32 случайни байта са солиден избор, като шестнадесетичен текст 64 знака. 16 байта, тоест 128 бита, са разпространена долна граница.
uniqid сигурен токен ли е?
Не. uniqid се основава на текущото време и е предназначена за уникалност, а не за запазване в тайна. И комбинации като md5 от време или потребителски ID са предвидими.
Трябва ли да шифрирам токените за нулиране в базата данни?
Съхранявайте вместо токена негов хеш, например със SHA-256. Който чете базата данни, така не може да изгради валидни връзки. Тъй като самият токен вече е дълъг и случаен, не е нужен бавен метод като при паролите.
Какво се случва, ако системата не намери източник на случайност?
random_bytes и random_int хвърлят от PHP 8.2 Random\RandomException. Обработете този случай и прекъснете операцията, вместо да преминавате към по-слаб източник.