Посилання «скинути пароль» дає повний доступ до акаунта, поки воно дійсне, тому токен у ньому треба розглядати як тимчасовий пароль. Звідси перша вимога: непередбачуваність. uniqid() будується з поточного часу в мікросекундах, rand() і mt_rand() не криптостійкі, а обгортка з md5() нічого до випадковості не додає. Криптостійкі джерела в PHP це random_bytes() і random_int(); Str::random() у Laravel теж бере байти з random_bytes(). 32 байти, перетворені в hex, дають токен, який не перебрати.
Друга вимога стосується зберігання. Відкритий токен у базі означає, що будь-хто з доступом на читання, через бекап, репліку чи SQL-інʼєкцію, скине пароль кожному, хто нещодавно натиснув «забув пароль». Тому в базі лежить хеш. Оскільки токен довгий і випадковий, тут досить швидкого sha256, і за ним зручно шукати; Laravel іде далі і хешує токен через Hash::make(), а запис шукає за email. До цього додають термін дії (у Laravel 60 хвилин за замовчуванням), видалення після використання і обмеження частоти запитів на лист.
Порівняння секретів окрема тема. === для рядків іде байт за байтом і повертається на першій розбіжності, тож час відповіді залежить від того, скільки символів збіглось. З достатньою кількістю вимірів це дає змогу підбирати секрет посимвольно. hash_equals($known, $input) проходить рядок повністю, і час від вмісту не залежить. Його ставлять скрізь, де порівнюють токени, HMAC-підписи вебхуків і API-ключі. password_verify() порівнює безпечно сам, обгортати його не треба.
Останнє, що відрізняє сильну відповідь, це user enumeration. Текст «користувача з таким email не знайдено» перетворює форму на сервіс перевірки, чи зареєстрована людина, а для сайту вакансій чи клініки сам факт реєстрації вже чутливий. Але однакового тексту мало: якщо для існуючого користувача сервер рахує bcrypt чи надсилає лист, а для неіснуючого одразу відповідає, різницю видно по часу. Laravel загортає вхід і скидання пароля в Timebox на 200 мс, а однакове повідомлення застосунок має показати сам, бо брокер повертає окремий статус для невідомого email. І після успішного скидання інші сесії та remember-токени інвалідовують, а на пошту йде лист «ваш пароль змінено».
// Було: передбачуваний токен, відкритий текст у базі, порівняння через ===.
$token = md5(uniqid()); // uniqid() це поточний час
$user->reset_token = $token;
if ($request->input('token') === $user->reset_token) { /* ... */ }
// Стало: 32 випадкові байти, у базі лише хеш, порівняння за сталий час.
$token = bin2hex(random_bytes(32)); // 64 hex-символи в лист
DB::table('reset_tokens')->updateOrInsert(
['user_id' => $user->id],
['token_hash' => hash('sha256', $token), 'expires_at' => now()->addMinutes(30)],
);
// Перевірка: термін, хеш, одноразовість.
$row = DB::table('reset_tokens')->where('user_id', $user->id)->first();
$valid = $row !== null
&& now()->lt($row->expires_at)
&& hash_equals($row->token_hash, hash('sha256', (string) $request->input('token')));
if ($valid) {
DB::table('reset_tokens')->where('user_id', $user->id)->delete();
// далі: новий пароль через Hash::make(), новий remember_token, лист-повідомлення
}
// На практиці в Laravel усе це вже робить брокер:
$status = Password::sendResetLink($request->only('email'));
// Однакова відповідь незалежно від того, чи знайшовся email.
return back()->with('status', 'Якщо акаунт існує, ми надіслали лист із посиланням.');
Скажіть фразу «токен скидання - це тимчасовий пароль» і виведіть з неї всі вимоги: випадковий, хешований у базі, короткоживучий, одноразовий. А потім додайте те, про що рідко згадують: однакову відповідь і однаковий час для існуючого і неіснуючого email.