Глава 86 · От первого вызова до миграции
Хеширование паролей: полный справочник password_*
Регистрация, проверка, соль, bcrypt/Argon2, стоимость и безопасное обновление: все функции официального семейства с постепенными примерами.
Аккаунт магазина: сохранить возможность проверки, а не сам пароль
Пароль проверяется по сохранённой строке
Магазину нужно узнавать владельца аккаунта при следующем входе, сохранив специальный хеш вместо открытого пароля.
- Регистрация
password_hash получает введённый пароль и доверенную политику алгоритма/стоимости.
- Строка в базе
Содержит алгоритм, параметры, соль и результат; хранится полностью.
- Следующий вход
password_verify получает новый ввод и сохранённую серверную строку.
- true или false
Совпадение — один шаг входа. Статус аккаунта, MFA, сессия и права проверяются отдельно.
При входе не сравнивают два заново созданных хеша: соль различается. Обновление стоимости делают после успешной проверки исходного пароля.
Нужно сохранить возможность будущего входа, не сохраняя исходный пароль. В результате верная фраза разрешит следующий шаг, неверная оставит понятный отказ. Хеш нельзя расшифровать в пароль; восстановление доступа меняет пароль.
- Регистрация: исходная фраза → password_hash → строка в закрытом хранилище
- Вход: новая введённая фраза + сохранённая строка → password_verify
- Успех → контроллер создаёт сессию; отказ → сообщение
Переменная начинается с $; кавычки задают текст, = сохраняет результат вызова. Запятая разделяет аргументы. PASSWORD_DEFAULT — именованная константа политики, не текст пароля. if проверяет true/false, echo печатает сообщение. Сначала только одна проверка; сохранение файла и полноценный вход следуют ниже.
<?php
declare(strict_types=1);
$hash = password_hash('Учебная фраза', PASSWORD_DEFAULT);
if (password_verify('Учебная фраза', $hash)) {
echo "Пароль совпал. Следующий шаг — начать вход.\n";
}
Ожидаемый результат
Пароль совпал. Следующий шаг — начать вход.
Случайная соль меняет строку hash при каждом создании; проверка читает параметры из уже сохранённой строки. Сравнивать два новых password_hash через === нельзя.
Хеширование пароля специально требует вычислительной работы: при краже БД перебор вариантов должен быть дороже. Соль — случайная несекретная добавка для каждой записи; PHP создаёт её сам и включает в результат. Она мешает одинаковым паролям массово превращаться в одну заранее рассчитанную запись, но не делает слабый пароль неуязвимым.
| Инструмент | Для чего он нужен |
|---|---|
password_hash | Регистрация и смена пароля: создаём новую строку. |
password_verify | Вход: проверяем пароль по серверной строке. |
password_needs_rehash | После успешного входа: сравниваем параметры с новой политикой. |
password_get_info | Диагностика алгоритма и параметров сохранённого формата. |
password_algos | Проверка возможностей конкретной сборки PHP. |
Перед расширенными примерами полезны вызов и результат функции, массивы настроек и условия. Эта глава про хранение и проверку паролей; полный маршрут HTTP, CSRF, cookies и MFA продолжается в главе входа и сессий.
Установка и возможности: Core, Argon2 и разные процессы PHP
Само семейство встроено в PHP Core: отдельный модуль password.dll или библиотека Composer для этих функций не нужны. Базовое API с bcrypt доступно без внешней библиотеки. Argon2 — возможность конкретной сборки/провайдера, которую надо проверить на сервере, где действительно выполняется вход.
- CLI php -v показывает одну сборку
- Веб-SAPI может использовать другую сборку/config
- password_algos показывает зарегистрированные ID этого процесса
- Тестовая пара hash/verify проверяет требуемый алгоритм
| Способ поддержки Argon2 | Что учитывать |
|---|---|
| libargon2 | Сборка PHP с --with-password-argon2. Для PHP 7.3+ требуется libargon2 не старее 20161029. До PHP 8.1 можно было передать путь --with-password-argon2[=DIR]. |
| Sodium | С PHP 7.4 расширение может зарегистрировать альтернативный обработчик Argon2; наличие алгоритма проверяют в password_algos(), а не только по версии PHP. |
| OpenSSL | С PHP 8.4 возможна сборка с OpenSSL 3.2+ и --with-openssl --with-openssl-argon2. Это параметры сборки, не строки для php.ini. |
| Свойства среды | PASSWORD_ARGON2_PROVIDER сообщает выбранный провайдер при наличии; standard/libargon2, sodium или openssl. Проверяйте поддержку существующих хешей до переноса сервиса. |
<?php
declare(strict_types=1);
echo function_exists('password_hash') ? "API доступно\n" : "API отсутствует\n";
echo in_array('argon2id', password_algos(), true) ? "Argon2id доступен\n" : "Argon2id отсутствует\n";
echo defined('PASSWORD_ARGON2_PROVIDER') ? "Провайдер можно проверить\n" : "Нет Argon2-провайдера\n";
Ожидаемый результат
API доступно
Argon2id доступен
Провайдер можно проверить
Этот вывод проверен в PHP 8.5.11 с provider standard. На другой сборке он может отличаться: это диагностический результат, а не гарантия наличия Argon2 на любом PHP.
Не изменяйте алгоритм молча, если после переезда Argon2 исчез: уже сохранённые хеши должны продолжать проверяться. Исправьте состав сборки или выполните заранее согласованную миграцию. Веб-диагностику окружения держите закрытой и удаляйте после проверки; пользователям не нужен публичный phpinfo().
У этого семейства в официальном дереве нет отдельной страницы php.ini-настроек: cost и параметры Argon2 задаются аргументами. Но общий лимит памяти, число PHP workers и ограничения запросов всё равно влияют на работу сервиса.
Все константы, алгоритмы и единицы измерения
Константа выбирает алгоритм, а массив options задаёт его работу. Не передавайте один и тот же массив настроек без разбора от bcrypt к Argon2: имя cost относится к bcrypt; память и проходы — к Argon2. Неизвестный ключ может не вызвать ошибку и не примениться.
- bcrypt: cost определяет экспоненциальную работу
- Argon2: memory_cost — KiB, time_cost — число проходов
- threads — параметр параллелизма с ограничениями провайдера
- Проверяем фактические options через password_get_info
| Константа | Смысл и версия |
|---|---|
| PASSWORD_DEFAULT | В PHP 8.5.11 это bcrypt/2y; значение задумано меняющимся. Передаётся явно вторым аргументом: аргумент algo обязательный. |
| PASSWORD_BCRYPT | bcrypt, формат $2y$,60 байтов. Cost 4–31, но нижняя техническая граница не является допустимой политикой защиты. |
| PASSWORD_BCRYPT_DEFAULT_COST | С PHP 7.0; в PHP 8.4+ значение 12, прежде 10. Повышение на 1 примерно удваивает основную работу, а не добавляет 1 миллисекунду. |
| PASSWORD_ARGON2I | Argon2i с PHP 7.2 при поддержке. Новый выбор для хранения паролей обычно рассматривают вместе с Argon2id; алгоритмы не взаимозаменяют одной подписью. |
| PASSWORD_ARGON2ID | Argon2id с PHP 7.3 при поддержке; использует memory_cost/time_cost/threads. |
| PASSWORD_ARGON2_DEFAULT_MEMORY_COST | KiB, с PHP 7.2. В проверенной standard-сборке 65536 KiB, то есть 64 MiB на вычисление основной памяти алгоритма. |
| PASSWORD_ARGON2_DEFAULT_TIME_COST | Количество проходов, с PHP 7.2. В проверенной сборке 4. Это не секунды и не лимит длительности. |
| PASSWORD_ARGON2_DEFAULT_THREADS | С PHP 7.2 для соответствующего провайдера, в standard-сборке 1. Не обещайте наличие этой константы у Sodium. |
| PASSWORD_ARGON2_PROVIDER | С PHP 7.4, указывает реализацию Argon2 при наличии поддержки. Проверяйте defined() перед чтением. |
С PHP 7.4 значения PASSWORD_BCRYPT/ARGON2I/ARGON2ID/DEFAULT — строки, раньше были целыми. Не сохраняйте индекс из password_algos и не используйте вручную числовую таблицу старых ID. Хеш уже содержит параметры, требуемые verify; отдельные столбцы с солью не нужны.
У libargon2 memory_cost должен вмещать как минимум 8 KiB на каждую lane; time_cost и threads положительные, а верхние технические пределы определяются библиотекой/провайдером и разрядностью. Недостаточная память/недопустимая комбинация даёт ошибку. Приложение не должно позволять пользователю выбирать эти числа или доходить до гигантских технических максимумов: установите доверенный бюджет.
<?php
declare(strict_types=1);
if (!in_array('argon2id', password_algos(), true)) {
throw new RuntimeException('Argon2id is required for this example');
}
$options = ['memory_cost' => 19456, 'time_cost' => 2, 'threads' => 1];
$hash = password_hash('demo passphrase for argon', PASSWORD_ARGON2ID, $options);
$info = password_get_info($hash);
echo $info['algoName'], "\n";
echo $info['options']['memory_cost'], '/', $info['options']['time_cost'], '/', $info['options']['threads'], "\n";
var_dump(password_verify('demo passphrase for argon', $hash));
Ожидаемый результат
argon2id
19456/2/1
bool(true)
19456 KiB =19 MiB. Это одна из текущих минимальных конфигураций OWASP для Argon2id, не подобранная автоматически настройка вашего сервера. Перед применением измерьте задержку и память при ожидаемом числе одновременных входов.
<?php
declare(strict_types=1);
$hash = password_hash('demo legacy phrase', PASSWORD_ARGON2I,
['memory_cost' => 8192, 'time_cost' => 3, 'threads' => 1]);
echo password_get_info($hash)['algoName'], "\n";
var_dump(password_verify('demo legacy phrase', $hash));
var_dump(password_needs_rehash($hash, PASSWORD_ARGON2ID,
['memory_cost' => 19456, 'time_cost' => 2, 'threads' => 1]));
Ожидаемый результат
argon2i
bool(true)
bool(true)
Пример создаёт облегчённую учебную запись для исследования миграции. Успешная verify позволяет предложить новый хеш исходного пароля. Сам needs_rehash не изменяет старую строку.
salt с PHP 7.0 устарела, с PHP 8.0 игнорируется: соль генерируется автоматически. Изменять PASSWORD_DEFAULT принято в основной версии после того, как алгоритм провёл как минимум одну полную версию в Core; экстренное исправление безопасности — исключение. Не стройте длину столбца и план обновления на предположении «default навсегда bcrypt».
Что именно проверяется: байты, пробелы, Unicode и предел bcrypt
Пользователь вводит пароль как текст, но функция получает байты строки. Любое скрытое преобразование между регистрацией и входом меняет проверку. Не обрезайте пробелы, не удаляйте HTML-подобные символы и не экранируйте пароль для SQL: хеш хранится параметризованным запросом, а пароль вообще не должен отображаться в HTML.
<?php
declare(strict_types=1);
$prefix = str_repeat('a', 72);
$hash = password_hash($prefix . 'X', PASSWORD_BCRYPT, ['cost' => 4]);
var_dump(password_verify($prefix . 'Y', $hash));
$cyrillic = str_repeat('я', 37);
echo strlen($cyrillic), "\n";
Ожидаемый результат
bool(true)
74
Первые 72 байта совпали, поэтому разные хвосты не участвуют в bcrypt. 37 русских букв здесь занимают 74 байта UTF-8. Для bcrypt требуется явный отказ при превышении допустимой длины, не молчаливое принятие с усечением.
<?php
declare(strict_types=1);
function bcryptInputFits(string $password): bool {
return strlen($password) <= 72 && !str_contains($password, "\0");
}
var_dump(bcryptInputFits(' spaces remain part of password '));
var_dump(bcryptInputFits(str_repeat('я', 37)));
var_dump(bcryptInputFits("demo\0tail"));
Ожидаемый результат
bool(true)
bool(false)
bool(false)
Это только техническая проверка совместимости bcrypt, не достаточная политика пароля: она не оценивает минимум длины, популярность/компрометацию, пустую строку или риск аккаунта. У длинной парольной фразы лучше заранее выбрать подходящий алгоритм и ограничения сервиса.
Argon2 не имеет 72-байтового усечения bcrypt, но бесконечный запрос недопустим: ограничьте размер HTTP-тела и длину поля по разумной документированной политике до дорогого вычисления. Пример ограничителя не должен внезапно менять пароль: отклонение сообщается явно. Unicode-нормализация, если она выбрана, должна одинаково применяться на регистрации и входе с самого начала; PHP не делает её автоматически.
Не устраняйте ограничение bcrypt самодельным «сначала SHA-256»: сменится схема, сырой digest может содержать NUL, а композиция требует отдельного анализа. Предпочтите парольный алгоритм с нужным контрактом. Для разработанной схемы с pepper нужен отдельный секрет, версия и процедура ротации; password_hash сам не хранит pepper и не управляет им. HMAC и обычные хеши решают другие задачи.
Настроить стоимость: безопасность, задержка и параллельные входы
Алгоритм выполняется намеренно медленно. Слишком маленькая стоимость облегчает офлайн-перебор украденной БД; слишком большая может исчерпать workers, CPU или память при потоке запросов. Параметры выбираются в доверенной конфигурации, измеряются на целевом окружении и пересматриваются вместе с нагрузкой.
- Выбрать допустимый современный алгоритм и минимальную политику
- Измерить hash и verify на целевом сервере
- Проверить несколько одновременных входов и запас памяти
- Ограничить попытки; повторить замер после смены среды
На момент проверки OWASP предпочитает Argon2id и приводит минимум 19 MiB/2 прохода/параллелизм 1 как один вариант. Для наследованного bcrypt указан work factor не ниже 10; PHP 8.4+ по умолчанию использует 12. Это нижние ориентиры соответствующих источников, а не утверждение, что любое конкретное число достаточно вашему продукту. Актуальная рекомендация: OWASP Password Storage Cheat Sheet.
PHP.net предлагает ориентироваться на измерение интерактивной задержки, в примере документации около 350 мс. У вашего сервиса есть собственный бюджет ответа и параллельность: измеряйте медиану/хвосты под нагрузкой, не превращайте 350 мс в универсальную норму. Для замера длительности пригоден монотонный hrtime(); не поднимайте cost до 31 в бесконтрольном цикле «найти максимум».
<?php
declare(strict_types=1);
$memoryKiB = 65536;
$simultaneousHashes = 8;
$algorithmMemoryMiB = $memoryKiB * $simultaneousHashes / 1024;
echo $algorithmMemoryMiB, " MiB\n";
Ожидаемый результат
512 MiB
Это арифметическая оценка только основной памяти алгоритма. К ней добавляется PHP-процесс, библиотеки, БД-клиент и прочая работа. memory_limit не заменяет проверку реального потребления нативной библиотеки и ограничение одновременных запросов.
- Не выводить пароль/полный хеш в лог, profiler payload или URL; SensitiveParameter не скрывает произвольный var_dump.
- Проверять лимит попыток до дорогого verify, с защитой от злоупотребления блокировкой чужого аккаунта.
- Для отсутствующего аккаунта использовать заранее подготовленный фиктивный хеш сопоставимой политики, а не создавать дорогой новый на каждый запрос.
- Одинаковое публичное сообщение об ошибке полезно, но одно сообщение не делает весь endpoint константным по времени.
- Проверять новую политику реальным hash/verify в процессе развёртывания: get_info и needs_rehash не валидаторы всех настроек.
Ни salt, ни pepper не заменяют управление доступом к БД, TLS, MFA и исправления зависимостей. Описанный материал не реализует весь endpoint аутентификации; маршруты и ограничения находятся в сессиях и лимитах запросов.
Сохранение: одна полная строка, никакого открытого пароля
Сохраните всю строку password_hash без обрезки. У bcrypt длина 60 ASCII-байтов, у Argon2 больше; PASSWORD_DEFAULT может сменить формат. Заложите колонку с запасом, например VARCHAR(255), проверьте строгий режим ошибок своей БД и обработку отказа записи. Увеличение колонки позже не восстановит уже обрезанные хеши.
- hash → полная строка
- Параметризованный INSERT
- SELECT вернул точно ту же строку
- verify использует серверную строку
<?php
declare(strict_types=1);
$db = new PDO('sqlite::memory:');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, password_hash TEXT NOT NULL)');
$hash = password_hash('demo database phrase', PASSWORD_BCRYPT, ['cost' => 4]);
$insert = $db->prepare('INSERT INTO users (id, password_hash) VALUES (?, ?)');
$insert->execute([1, $hash]);
$read = $db->prepare('SELECT password_hash FROM users WHERE id = ?');
$read->execute([1]);
$stored = $read->fetchColumn();
var_dump($stored === $hash);
var_dump(password_verify('demo database phrase', $stored));
Ожидаемый результат
bool(true)
bool(true)
Самостоятельный опыт использует SQLite только в памяти и вымышленный пароль. SQLiteTEXT не моделирует ограничение VARCHAR конкретного SQL-сервера; для production-схемы проверяйте реальный драйвер, тип и ошибки записи. Здесь показаны параметры запроса и сохранность строки.
Отладка начинается с пути данных: одинаков ли договор ввода, цела ли колонка, правильную ли запись прочитали, не добавлены ли лишние преобразования. Не сохраняйте открытый пароль рядом «чтобы разобраться». При недоступной БД нельзя сообщать об успешной регистрации, если запись не подтверждена.
Этот пример предполагает, что основы PDO уже изучены: подготовленные запросы. В HTTP-приложении пароль берётся из проверенного поля POST, не из query string, а успех записи ещё не заменяет защиту формы и права текущего действия.
Обновление при входе и гонка со сменой пароля
Успешная проверка старого хеша даёт возможность создать новый из введённого пароля. Но между SELECT и UPDATE пользователь может сменить пароль в другом запросе. Безусловный UPDATE вернул бы ему старый пароль под новым хешем. Поэтому запись должна учитывать версию прочитанного состояния.
- Вход прочитал password_revision=1 и проверил старый пароль
- Другой запрос сменил пароль: revision=2
- Rehash пытается UPDATE ... WHERE revision=1
- Ноль обновлённых строк → не перезаписываем revision=2
<?php
declare(strict_types=1);
$db = new PDO('sqlite::memory:');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, password_hash TEXT NOT NULL, revision INTEGER NOT NULL)');
$old = password_hash('old demo phrase', PASSWORD_BCRYPT, ['cost' => 4]);
$insert = $db->prepare('INSERT INTO users VALUES (1, ?, 1)');
$insert->execute([$old]);
$snapshot = $db->query('SELECT password_hash, revision FROM users WHERE id = 1')->fetch(PDO::FETCH_ASSOC);
if (!password_verify('old demo phrase', $snapshot['password_hash'])) {
throw new RuntimeException('Fixture login failed');
}
$rehash = password_hash('old demo phrase', PASSWORD_BCRYPT, ['cost' => 5]);
// Имитируем завершившуюся в другом запросе смену пароля.
$reset = $db->prepare('UPDATE users SET password_hash = ?, revision = revision + 1 WHERE id = 1');
$reset->execute([password_hash('new demo phrase', PASSWORD_BCRYPT, ['cost' => 5])]);
$update = $db->prepare('UPDATE users SET password_hash = ?, revision = revision + 1 WHERE id = ? AND revision = ?');
$update->execute([$rehash, 1, $snapshot['revision']]);
echo $update->rowCount(), "\n";
$current = $db->query('SELECT password_hash FROM users WHERE id = 1')->fetchColumn();
var_dump(password_verify('new demo phrase', $current));
var_dump(password_verify('old demo phrase', $current));
Ожидаемый результат
0
bool(true)
bool(false)
Гонка воспроизведена детерминированной последовательностью операций в одной памяти, не нагрузочным тестом БД. Ноль строк — конфликт версии: нельзя продолжать выдачу сессии на предположении, что старый пароль всё ещё актуален. Повторите согласованную проверку текущего состояния или завершите вход по политике сервиса.
После успешной записи нужно соблюдать общую политику инвалидирования сессий при смене пароля. Для редких неактивных аккаунтов миграция «при следующем входе» может никогда не произойти; нужен срок действия старой схемы и маршрут сброса. При утечке pepper простая смена конфигурации не восстанавливает защищённость старых записей: план ротации зависит от схемы и наличия исходного пароля.
Старые MD5/SHA-1 записи не объявляются автоматически совместимыми с password_verify. Отдельный временный мигратор обязан точно знать старую схему, ограничивать её срок и после подтверждения создавать новый парольный хеш. Для новых аккаунтов такой путь не используется.
Жизненный цикл пароля: регистрация, вход, смена и отзыв сессий
Один пароль участвует в нескольких разных операциях. При регистрации сервер создаёт хеш; при входе сверяет новый ввод с сохранённой строкой; при смене сначала требует текущий пароль, затем записывает новый хеш и отзывает прежние сессии. Эти действия нельзя сводить к одному вызову password_hash.
| Событие | Источник личности | Что меняется |
|---|---|---|
| Регистрация | Проверенная форма и уникальный email | Создаётся новая запись с password_hash; исходная фраза не сохраняется. |
| Вход | Имя/email + новый ввод пароля | password_verify подтверждает ввод; при необходимости needs_rehash обновляет только хеш. |
| Смена пароля | Уже вошедший пользователь + повтор текущего пароля + CSRF | Новый hash и session_version записываются одной транзакцией; старые сессии требуют входа. |
| Сброс | Короткоживущий одноразовый секрет из отдельного канала | После проверки задаётся новый пароль; сам сброс не раскрывает прежнюю фразу. |
- POST change-password: current_password + new_password + csrf
- Сессия даёт user_id; поле user_id из формы не выбирает чужой аккаунт
- password_verify(current_password, сохранённый hash)
- password_hash(new_password, выбранная политика)
- Транзакция: hash обновлён + session_version увеличена
- Каждый защищённый запрос сравнивает версию своей сессии с аккаунтом
Локальный опыт хранит одну запись JSON, чтобы результат был виден без настройки СУБД. В работающем сервисе уникальность email обеспечивает индекс, а смену hash и session_version — транзакция. Низкие cost 4/5 выбраны только для быстрого теста; не переносите их в рабочую политику. С PHP 8.4 bcrypt по умолчанию использует cost 12. Значение измеряют на своём сервере. Здесь алгоритм явно PASSWORD_BCRYPT, поэтому действует предел 72 байта; не обрезайте пароль молча.
<?php
declare(strict_types=1);
$file = __DIR__ . '/account.json';
$algorithm = PASSWORD_BCRYPT;
$registerOptions = ['cost' => 4]; // Только быстрая локальная лаборатория.
$currentOptions = ['cost' => 5]; // Показываем обновление политики.
function saveAccount(string $file, array $account): void
{
$json = json_encode($account, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
if (file_put_contents($file, $json, LOCK_EX) !== strlen($json)) {
throw new RuntimeException('Учётная запись не сохранена');
}
}
function loadAccount(string $file): array
{
$json = file_get_contents($file);
if ($json === false) {
throw new RuntimeException('Учётная запись недоступна');
}
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
$email = 'anna@example.test';
$firstPassword = 'Первый пароль 2026!';
$account = [
'id' => 7,
'email' => $email,
'password_hash' => password_hash($firstPassword, $algorithm, $registerOptions),
'session_version' => 1,
];
saveAccount($file, $account);
echo "Регистрация: сохранён хеш, исходного пароля в файле нет.\n";
$account = loadAccount($file);
echo password_verify('ошибка', $account['password_hash'])
? "Неверный пароль: вход разрешён.\n"
: "Неверный пароль: вход запрещён.\n";
if (!password_verify($firstPassword, $account['password_hash'])) {
throw new RuntimeException('Учебный вход неожиданно отклонён');
}
if (password_needs_rehash($account['password_hash'], $algorithm, $currentOptions)) {
$account['password_hash'] = password_hash($firstPassword, $algorithm, $currentOptions);
saveAccount($file, $account);
echo "Вход: пароль подтверждён, политика хеша обновлена.\n";
}
$oldSession = ['user_id' => 7, 'session_version' => $account['session_version']];
$currentPassword = $firstPassword;
$newPassword = 'Новый пароль 2026!';
if (!password_verify($currentPassword, $account['password_hash'])) {
throw new RuntimeException('Текущий пароль не подтверждён');
}
$account['password_hash'] = password_hash($newPassword, $algorithm, $currentOptions);
$account['session_version']++;
saveAccount($file, $account);
echo "Смена пароля: записан новый хеш, версия сессий стала 2.\n";
$account = loadAccount($file);
$oldSessionAllowed = $oldSession['session_version'] === $account['session_version'];
echo $oldSessionAllowed
? "Старая сессия: доступ разрешён.\n"
: "Старая сессия: требуется новый вход.\n";
echo password_verify($firstPassword, $account['password_hash'])
? "Старый пароль: вход разрешён.\n"
: "Старый пароль: вход запрещён.\n";
echo password_verify($newPassword, $account['password_hash'])
? "Новый пароль: вход разрешён.\n"
: "Новый пароль: вход запрещён.\n";
unlink($file);
Ожидаемый результат
Регистрация: сохранён хеш, исходного пароля в файле нет.
Неверный пароль: вход запрещён.
Вход: пароль подтверждён, политика хеша обновлена.
Смена пароля: записан новый хеш, версия сессий стала 2.
Старая сессия: требуется новый вход.
Старый пароль: вход запрещён.
Новый пароль: вход разрешён.
Файл создаётся только в каталоге распакованного примера и удаляется в конце. Вывод не содержит пароль или строку хеша. Если запись/JSON повреждены, пример завершится исключением вместо разрешения входа.
Если выбираете PASSWORD_DEFAULT, помните: его алгоритм может измениться в будущей версии PHP, а столбец должен вмещать всю строку; официальный справочник рекомендует 255 байт. Доступность Argon2id проверяет password_algos(). Параметры своей сохранённой строки показывает password_get_info(), но только password_verify() подтверждает введённый пароль.
Забытый пароль: одноразовый токен, срок и повторное использование
Когда пароль забыт, сервер не может и не должен прислать старый: хеш необратим. Вместо этого он выдаёт случайную одноразовую возможность задать новый пароль. Защита от перечисления пользователей требует не только одинакового текста, но и сопоставимого времени HTTP-ответа для существующего и неизвестного адреса. В примере обработчик всегда записывает однотипное задание и сразу отвечает: «если аккаунт существует, инструкция отправлена». Поиск аккаунта, создание ссылки и отправка письма выполняются фоновым worker уже после ответа, поэтому клиент не измеряет их разное время.
- HTTP-обработчик одинаково ставит любой адрес в очередь и возвращает один текст
- Фоновый worker проверяет адрес, ищет аккаунт и только для него запускает выдачу
- random_bytes создаёт открытый selector и секретный validator
- База: selector + SHA-256(validator) + user_id + expires_at + used_at=null
- Письмо по HTTPS содержит selector.validator; журнал и аналитика не получают URL
- Обработчик находит строку по selector, проверяет срок/used_at и hash_equals
- Одна транзакция помечает токен использованным, меняет password_hash и отзывает сессии
- Пользователь получает уведомление; автоматический вход после сброса не выполняется
Selector не является секретом и нужен для индексированного поиска. Validator имеет 32 случайных байта; в хранилище остаётся только его быстрый SHA-256. Для такого высокоэнтропийного одноразового значения медленный password_hash не даёт той же пользы, что для придуманного человеком пароля. Срок проверяется на сервере; значение времени из query или hidden-поля не используется. Очередь должна быть надёжной: HTTP-ответ дают после подтверждённой записи задания. Если фонового worker нет, измеряют полный путь и аккуратно выравнивают его до выбранной нижней границы; одиночный sleep «на глаз» не гарантирует одинаковое время под разной нагрузкой.
<?php
declare(strict_types=1);
function requestPasswordReset(array &$jobs, string $email, int $now): string
{
// HTTP-обработчик не ищет аккаунт и не отправляет письмо.
// Он одинаково сохраняет задание для любого введённого адреса.
$jobs[] = [
'email' => strtolower(trim(substr($email, 0, 254))),
'requested_at' => $now,
];
return 'Если аккаунт существует, инструкция отправлена.';
}
function issueReset(array &$records, int $userId, int $now): string
{
$selector = bin2hex(random_bytes(8)); // Открытый идентификатор записи.
$validator = bin2hex(random_bytes(32)); // Секрет из письма.
$records[$selector] = [
'user_id' => $userId,
'validator_hash' => hash('sha256', $validator),
'expires_at' => $now + 900,
'used_at' => null,
];
return $selector . '.' . $validator;
}
function consumeReset(array &$records, string $presented, int $now): ?int
{
$parts = explode('.', $presented, 2);
if (count($parts) !== 2) {
return null;
}
[$selector, $validator] = $parts;
if (strlen($selector) !== 16 || strlen($validator) !== 64
|| !ctype_xdigit($selector) || !ctype_xdigit($validator)) {
return null;
}
$record = $records[$selector] ?? null;
if (!is_array($record) || $record['used_at'] !== null || $now >= $record['expires_at']) {
return null;
}
$candidate = hash('sha256', $validator);
if (!hash_equals($record['validator_hash'], $candidate)) {
return null;
}
$records[$selector]['used_at'] = $now;
return $record['user_id'];
}
function processResetJob(
array &$records,
array $usersByEmail,
array $job,
int $now
): ?string {
// Этот worker работает после HTTP-ответа: его разное время клиент не измеряет.
$email = filter_var($job['email'], FILTER_VALIDATE_EMAIL);
$userId = is_string($email) ? ($usersByEmail[$email] ?? null) : null;
return is_int($userId) ? issueReset($records, $userId, $now) : null;
}
$records = [];
$jobs = [];
$usersByEmail = ['anna@example.test' => 7];
$now = 1_000;
echo "Известный адрес: "
. requestPasswordReset($jobs, 'anna@example.test', $now) . "\n";
echo "Неизвестный адрес: "
. requestPasswordReset($jobs, 'nobody@example.test', $now) . "\n";
echo "HTTP-путь: оба запроса поставлены в очередь одинаково.\n";
$token = processResetJob($records, $usersByEmail, $jobs[0], $now);
$unknownToken = processResetJob($records, $usersByEmail, $jobs[1], $now);
echo is_string($token) && $unknownToken === null
? "Фоновый worker: письмо подготовлено только для существующего аккаунта.\n"
: "Фоновый worker: ошибка примера.\n";
$wrong = substr($token, 0, -1) . (str_ends_with($token, 'a') ? 'b' : 'a');
echo consumeReset($records, $wrong, 1_010) === null
? "Изменённая ссылка: отказ.\n"
: "Изменённая ссылка: принята.\n";
$userId = consumeReset($records, $token, 1_010);
echo $userId === 7
? "Первая правильная ссылка: можно задать новый пароль пользователю 7.\n"
: "Первая правильная ссылка: отказ.\n";
echo consumeReset($records, $token, 1_011) === null
? "Повтор той же ссылки: отказ.\n"
: "Повтор той же ссылки: принят.\n";
$expired = issueReset($records, 7, 2_000);
echo consumeReset($records, $expired, 2_900) === null
? "Ссылка через 900 секунд: срок истёк.\n"
: "Ссылка через 900 секунд: принята.\n";
Ожидаемый результат
Известный адрес: Если аккаунт существует, инструкция отправлена.
Неизвестный адрес: Если аккаунт существует, инструкция отправлена.
HTTP-путь: оба запроса поставлены в очередь одинаково.
Фоновый worker: письмо подготовлено только для существующего аккаунта.
Изменённая ссылка: отказ.
Первая правильная ссылка: можно задать новый пароль пользователю 7.
Повтор той же ссылки: отказ.
Ссылка через 900 секунд: срок истёк.
Пример не отправляет письмо и не открывает сеть. Он фактически проверяет случайный секрет, хеш в записи, точную границу срока и одноразовость. В рабочем сервисе выдача и потребление токена выполняются в базе с уникальным selector и транзакцией.
| Ошибка реализации | Наблюдаемая проблема | Исправление |
|---|---|---|
| В ссылке password_hash пользователя | Утечка базы сразу даёт действующую возможность сброса. | Новый случайный одноразовый validator; хранить только его digest. |
| Токен без срока/used_at | Старая или уже применённая ссылка продолжает менять пароль. | Проверять expires_at и атомарно ставить used_at. |
| Email или user_id из hidden выбирает запись | Параметр можно заменить и атаковать другой аккаунт. | Связь user_id хранится в серверной записи токена. |
| Текст одинаков, но неизвестный email завершается раньше | Злоумышленник различает аккаунты по времени ответа. | Одинаково ставить запрос в очередь; искать аккаунт и отправлять письмо после HTTP-ответа. |
| Ссылка строится из непроверенного Host | Письмо может вести на домен злоумышленника. | Базовый HTTPS-адрес задаётся конфигурацией приложения. |
| Новый пароль записан, старые входы живут | Украденная сессия продолжает работать. | Увеличить session_version/отозвать серверные сессии и потребовать новый вход. |
Карта функций и небольшое упражнение
Пройдите семейство последовательно или выберите конкретную задачу. Канонические старые карточки hash/verify/needs_rehash остаются по прежним адресам главы безопасности; здесь это один связанный справочник.
| Задача | Карточка |
|---|---|
| Создать строку хранения | password_hash |
| Проверить новый ввод | password_verify |
| Сравнить с новой политикой | password_needs_rehash |
| Прочитать метаданные | password_get_info |
| Проверить возможности сервера | password_algos |
Упражнение: есть корректный сохранённый хеш. Проверьте, что неверный пароль не приводит к изменению этой строки даже при необходимости rehash. Условие безопасности — новый хеш создаётся только после успешной verify. Такой маленький тест защищает порядок действий, не качество всей системы входа.
- storedHash создан раньше
- password_verify("wrong", storedHash) → false
- Ветка rehash не выполняется
- Сохранённая строка остаётся прежней
<?php
declare(strict_types=1);
$stored = password_hash('correct demo phrase', PASSWORD_BCRYPT, ['cost' => 4]);
$before = $stored;
$input = 'wrong phrase';
if (password_verify($input, $stored)
&& password_needs_rehash($stored, PASSWORD_BCRYPT, ['cost' => 5])) {
$stored = password_hash($input, PASSWORD_BCRYPT, ['cost' => 5]);
}
var_dump($stored === $before);
var_dump(password_verify('correct demo phrase', $stored));
Ожидаемый результат
bool(true)
bool(true)
Оператор&& не выполняет правую часть после false. В реальном сервисе сохранение меняетБД, поэтому дополняйте это проверкой числа обновлённых строк и отдельными тестами отказа БД/параллельности.
Дальше: безопасный вход, общая безопасность, ограничение попыток. Парольный хеш не заменяет подпись HMAC для webhook: это разные задачи и разные модели доверия.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
password_get_infoПрочитать алгоритм и стоимость серверного хеша для диагностики миграции, не раскрывая пароль.
function password_get_info(string $hash): arrayПоказать администратору параметры своей политики. Функция возвращает массив. Ключ algoName даёт имя, вложенный options содержит cost у bcrypt. Она не проверяет знание пароля и не доказывает целость неизвестной строки.
- Создан тестовый bcrypt-хеш с cost4
- get_info разбирает algoName/options
- Отчёт показывает bcrypt и4, без исходной фразы и hash
Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.
Прочитать алгоритм и стоимость серверного хеша для диагностики миграции, не раскрывая пароль.
| Параметр / результат | Контракт |
|---|---|
| hash | Строка password_hash(). Это анализ формата, а не доказательство подлинности или правильного пароля. |
| algo | Идентификатор алгоритма; в современном PHP string, либо null если формат не распознан. |
| algoName | Читаемое имя, например bcrypt/argon2id, или unknown. |
| options | Массив параметров: cost для bcrypt; memory_cost/time_cost/threads для Argon2. У unknown пустой массив. |
| Ошибки и ограничения | Неверный тип — TypeError. Даже распознанный префикс может находиться в испорченной строке: для пароля нужен password_verify(), не проверка algoName. |
Результат — массив с подписанными ячейками. $info["options"]["cost"] читает вложенное поле. Это полезно в служебном отчёте о миграции; не отдавайте метаданные аккаунтов каждому неавторизованному запросу.
<?php
declare(strict_types=1);
$hash = password_hash('Учебная запись', PASSWORD_BCRYPT, ['cost' => 4]);
$info = password_get_info($hash);
echo 'Алгоритм записи: ', $info['algoName'], "\n";
echo 'Учебная стоимость: ', $info['options']['cost'], "\n";
echo "Сам пароль и хеш в отчёт не включены.\n";
Ожидаемый результат
Алгоритм записи: bcrypt
Учебная стоимость: 4
Сам пароль и хеш в отчёт не включены.
Отчёт пригоден для проверки миграции собственного хранилища. У другого алгоритма options имеет другие ключи; нельзя читать cost у Argon2 как универсальное поле. cost4 выбран только ради короткого опыта. Полный вход через HTML, cookie и сессию.
unknown может означать повреждение, неподдерживаемую среду или старую схему. Из этого результата нельзя выбрать подходящий пароль и нельзя автоматически считать аккаунт безопасным.
<?php
declare(strict_types=1);
$info = password_get_info('broken stored value');
echo 'Распознанный алгоритм: ', $info['algoName'], "\n";
echo $info['algo'] === null ? "Запись требует проверки происхождения и восстановления доступа.\n" : "Алгоритм распознан.\n";
Ожидаемый результат
Распознанный алгоритм: unknown
Запись требует проверки происхождения и восстановления доступа.
Вывод показывает результат описанного выше опыта с указанными исходными данными.
В PHP 8.5 bcrypt распознаётся по части структуры; это не криптографическая проверка. Не используйте get_info как валидатор хеша, присланного снаружи.
<?php
declare(strict_types=1);
$fake = '$2y$04$' . str_repeat('!', 53);
echo 'Распознанный заголовок: ', password_get_info($fake)['algoName'], "\n";
echo password_verify('demo phrase', $fake) ? "Пароль подтверждён.\n" : "Вход запрещён: распознавание заголовка не проверило пароль.\n";
Ожидаемый результат
Распознанный заголовок: bcrypt
Вход запрещён: распознавание заголовка не проверило пароль.
Связанные функции: password_needs_rehash, password_verify, password_hash.
Сначала понять регистрацию и вход; параметры защиты и границы примеров.
password_algosУзнать, какие парольные алгоритмы зарегистрированы в конкретном процессе PHP.
function password_algos(): arrayПроверить зависимость сервера до создания аккаунта. Возвращается список идентификаторов поддерживаемых password-алгоритмов. in_array с true ищет конкретный идентификатор без преобразования типов; порядок списка не выбирает политику безопасности.
- Политика требует bcrypt
- algos → in_array проверяет PASSWORD_BCRYPT
- Тестовая пара hash/verify подтверждает возможность проверки
Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.
Узнать, какие парольные алгоритмы зарегистрированы в конкретном процессе PHP.
| Параметр / результат | Контракт |
|---|---|
| Аргументы | Нет. |
| Результат | Числовой массив строковых ID зарегистрированных алгоритмов. Обычно 2y, при наличии провайдера также argon2i/argon2id; не полагайтесь на позицию и обязательное число элементов. |
| Версия и назначение | PHP 7.4+. Это проверка возможностей процесса, не рейтинг алгоритмов и не автоматический выбор политики. CLI и веб-SAPI могут иметь разные расширения/сборки. |
Список возвращается сразу; вычисление пароля не запускается. Сравнение через in_array(..., true) проверяет точный ID. Настройку политики выбирает команда сервиса после оценки требований и нагрузки.
<?php
declare(strict_types=1);
$available = password_algos();
if (!in_array(PASSWORD_BCRYPT, $available, true)) { throw new RuntimeException('Сервер не поддерживает обязательный bcrypt'); }
$hash = password_hash('Учебный вход', PASSWORD_BCRYPT, ['cost' => 4]);
echo "Сервер поддерживает bcrypt.\n";
echo password_verify('Учебный вход', $hash) ? "Учебный аккаунт можно проверять на этом сервере.\n" : "Проверка не работает.\n";
Ожидаемый результат
Сервер поддерживает bcrypt.
Учебный аккаунт можно проверять на этом сервере.
Важна возможность проверять уже существующие хеши после переноса на другой сервер. При обязательном Argon2id нужно проверять именно его и наличие соответствующего провайдера. Нельзя молча заменить требуемый алгоритм первым элементом списка. Полный вход через HTML, cookie и сессию.
Пример выполнен на сборке с Argon2. На сборке без него ожидаемая политика — явная ошибка развёртывания, а не случайная смена алгоритма. Проверяйте возможность проверки уже существующих хешей на каждом новом сервере.
<?php
declare(strict_types=1);
$required = 'argon2id';
if (!in_array($required, password_algos(), true)) {
throw new RuntimeException('Required password algorithm is unavailable');
}
echo "Требуемый алгоритм доступен\n";
Ожидаемый результат
Требуемый алгоритм доступен
Связанные функции: password_hash, password_get_info, password_verify.
Сначала понять регистрацию и вход; параметры защиты и границы примеров.
Источники и обсуждения
- PHP: book.password
- PHP: password.setup
- PHP: password.requirements
- PHP: password.installation
- PHP: password.constants
- PHP: ref.password
- PHP: function.password-algos
- PHP: function.password-get-info
- PHP: function.password-hash
- PHP: function.password-needs-rehash
- PHP: function.password-verify
- OWASP: Password Storage