Александр Некрасов · Автономное руководство

PHP: полный мануал с примерами, функциями и ООП

Разделы этой страницы
  1. Хеширование паролей: полный справочник password_*
  2. Аккаунт магазина: сохранить возможность проверки, а не сам пароль
  3. Установка и возможности: Core, Argon2 и разные процессы PHP
  4. Все константы, алгоритмы и единицы измерения
  5. Что именно проверяется: байты, пробелы, Unicode и предел bcrypt
  6. Настроить стоимость: безопасность, задержка и параллельные входы
  7. Сохранение: одна полная строка, никакого открытого пароля
  8. Обновление при входе и гонка со сменой пароля
  9. Жизненный цикл пароля: регистрация, вход, смена и отзыв сессий
  10. Забытый пароль: одноразовый токен, срок и повторное использование
  11. Карта функций и небольшое упражнение
  12. Функции и методы этой темы
  13. Код и руководство с собой
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 86 · От первого вызова до миграции

Хеширование паролей: полный справочник password_*

Регистрация, проверка, соль, bcrypt/Argon2, стоимость и безопасное обновление: все функции официального семейства с постепенными примерами.

Аккаунт магазина: сохранить возможность проверки, а не сам пароль

Наглядная модель

Пароль проверяется по сохранённой строке

Магазину нужно узнавать владельца аккаунта при следующем входе, сохранив специальный хеш вместо открытого пароля.

  1. Регистрация

    password_hash получает введённый пароль и доверенную политику алгоритма/стоимости.

  2. Строка в базе

    Содержит алгоритм, параметры, соль и результат; хранится полностью.

  3. Следующий вход

    password_verify получает новый ввод и сохранённую серверную строку.

  4. true или false

    Совпадение — один шаг входа. Статус аккаунта, MFA, сессия и права проверяются отдельно.

При входе не сравнивают два заново созданных хеша: соль различается. Обновление стоимости делают после успешной проверки исходного пароля.

Нужно сохранить возможность будущего входа, не сохраняя исходный пароль. В результате верная фраза разрешит следующий шаг, неверная оставит понятный отказ. Хеш нельзя расшифровать в пароль; восстановление доступа меняет пароль.

Две разные операции
  1. Регистрация: исходная фраза → password_hash → строка в закрытом хранилище
  2. Вход: новая введённая фраза + сохранённая строка → password_verify
  3. Успех → контроллер создаёт сессию; отказ → сообщение

Переменная начинается с $; кавычки задают текст, = сохраняет результат вызова. Запятая разделяет аргументы. 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 — возможность конкретной сборки/провайдера, которую надо проверить на сервере, где действительно выполняется вход.

Проверяется рабочая среда
  1. CLI php -v показывает одну сборку
  2. Веб-SAPI может использовать другую сборку/config
  3. password_algos показывает зарегистрированные ID этого процесса
  4. Тестовая пара 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. Неизвестный ключ может не вызвать ошибку и не примениться.

Разные ручки регулировки
  1. bcrypt: cost определяет экспоненциальную работу
  2. Argon2: memory_cost — KiB, time_cost — число проходов
  3. threads — параметр параллелизма с ограничениями провайдера
  4. Проверяем фактические options через password_get_info
КонстантаСмысл и версия
PASSWORD_DEFAULTВ PHP 8.5.11 это bcrypt/2y; значение задумано меняющимся. Передаётся явно вторым аргументом: аргумент algo обязательный.
PASSWORD_BCRYPTbcrypt, формат $2y$,60 байтов. Cost 4–31, но нижняя техническая граница не является допустимой политикой защиты.
PASSWORD_BCRYPT_DEFAULT_COSTС PHP 7.0; в PHP 8.4+ значение 12, прежде 10. Повышение на 1 примерно удваивает основную работу, а не добавляет 1 миллисекунду.
PASSWORD_ARGON2IArgon2i с PHP 7.2 при поддержке. Новый выбор для хранения паролей обычно рассматривают вместе с Argon2id; алгоритмы не взаимозаменяют одной подписью.
PASSWORD_ARGON2IDArgon2id с PHP 7.3 при поддержке; использует memory_cost/time_cost/threads.
PASSWORD_ARGON2_DEFAULT_MEMORY_COSTKiB, с 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 положительные, а верхние технические пределы определяются библиотекой/провайдером и разрядностью. Недостаточная память/недопустимая комбинация даёт ошибку. Приложение не должно позволять пользователю выбирать эти числа или доходить до гигантских технических максимумов: установите доверенный бюджет.

Применить явно выбранную конфигурацию Argon2idСамостоятельный пример
<?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, не подобранная автоматически настройка вашего сервера. Перед применением измерьте задержку и память при ожидаемом числе одновременных входов.

Прочитать и проверить старую Argon2i-записьСамостоятельный пример
<?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.

Одна и та же строка на двух маршрутах
  1. Регистрация: байты выбранного пароля → hash
  2. Хранилище сохраняет всю строку результата
  3. Вход: те же правила ввода → verify
  4. Незаметный trim/смена кодировки меняет байты → отказ
Усечение после 72 байтов меняет смысл проверкиСамостоятельный пример
<?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 требуется явный отказ при превышении допустимой длины, не молчаливое принятие с усечением.

Рано отклонить недопустимый для 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 или память при потоке запросов. Параметры выбираются в доверенной конфигурации, измеряются на целевом окружении и пересматриваются вместе с нагрузкой.

Не одно число на весь мир
  1. Выбрать допустимый современный алгоритм и минимальную политику
  2. Измерить hash и verify на целевом сервере
  3. Проверить несколько одновременных входов и запас памяти
  4. Ограничить попытки; повторить замер после смены среды

На момент проверки 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), проверьте строгий режим ошибок своей БД и обработку отказа записи. Увеличение колонки позже не восстановит уже обрезанные хеши.

Контроль после записи
  1. hash → полная строка
  2. Параметризованный INSERT
  3. SELECT вернул точно ту же строку
  4. verify использует серверную строку
Записать и прочитать тестовый хеш через PDOСамостоятельный пример
<?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 вернул бы ему старый пароль под новым хешем. Поэтому запись должна учитывать версию прочитанного состояния.

Не затереть более позднюю смену
  1. Вход прочитал password_revision=1 и проверил старый пароль
  2. Другой запрос сменил пароль: revision=2
  3. Rehash пытается UPDATE ... WHERE revision=1
  4. Ноль обновлённых строк → не перезаписываем revision=2
Условный UPDATE сохраняет результат более поздней сменыСамостоятельный пример
<?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 записываются одной транзакцией; старые сессии требуют входа.
СбросКороткоживущий одноразовый секрет из отдельного каналаПосле проверки задаётся новый пароль; сам сброс не раскрывает прежнюю фразу.
Данные и состояние при смене пароля
  1. POST change-password: current_password + new_password + csrf
  2. Сессия даёт user_id; поле user_id из формы не выбирает чужой аккаунт
  3. password_verify(current_password, сохранённый hash)
  4. password_hash(new_password, выбранная политика)
  5. Транзакция: hash обновлён + session_version увеличена
  6. Каждый защищённый запрос сравнивает версию своей сессии с аккаунтом

Локальный опыт хранит одну запись 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 уже после ответа, поэтому клиент не измеряет их разное время.

Сброс без хранения готовой ссылки
  1. HTTP-обработчик одинаково ставит любой адрес в очередь и возвращает один текст
  2. Фоновый worker проверяет адрес, ищет аккаунт и только для него запускает выдачу
  3. random_bytes создаёт открытый selector и секретный validator
  4. База: selector + SHA-256(validator) + user_id + expires_at + used_at=null
  5. Письмо по HTTPS содержит selector.validator; журнал и аналитика не получают URL
  6. Обработчик находит строку по selector, проверяет срок/used_at и hash_equals
  7. Одна транзакция помечает токен использованным, меняет password_hash и отзывает сессии
  8. Пользователь получает уведомление; автоматический вход после сброса не выполняется

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. Такой маленький тест защищает порядок действий, не качество всей системы входа.

Неверный ввод не меняет запись
  1. storedHash создан раньше
  2. password_verify("wrong", storedHash) → false
  3. Ветка rehash не выполняется
  4. Сохранённая строка остаётся прежней
Возможное решение: отказ не меняет хранилищеСамостоятельный пример
<?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Прочитать алгоритм и стоимость серверного хеша для диагностики миграции, не раскрывая пароль.

PHP 5.5+ · Core

function password_get_info(string $hash): array

Показать администратору параметры своей политики. Функция возвращает массив. Ключ algoName даёт имя, вложенный options содержит cost у bcrypt. Она не проверяет знание пароля и не доказывает целость неизвестной строки.

Путь конкретных данных
  1. Создан тестовый bcrypt-хеш с cost4
  2. get_info разбирает algoName/options
  3. Отчёт показывает 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.

PHP 7.4+ · Core

function password_algos(): array

Проверить зависимость сервера до создания аккаунта. Возвращается список идентификаторов поддерживаемых password-алгоритмов. in_array с true ищет конкретный идентификатор без преобразования типов; порядок списка не выбирает политику безопасности.

Путь конкретных данных
  1. Политика требует bcrypt
  2. algos → in_array проверяет PASSWORD_BCRYPT
  3. Тестовая пара 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. На сборке без него ожидаемая политика — явная ошибка развёртывания, а не случайная смена алгоритма. Проверяйте возможность проверки уже существующих хешей на каждом новом сервере.

Прервать запуск, если обязательный Argon2id отсутствуетСамостоятельный пример
<?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.

Сначала понять регистрацию и вход; параметры защиты и границы примеров.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.