Глава 6.3: Хеширование и пароли — password_hash, password_verify, почему MD5 — это плохо
📖 Введение
Представь: ты создаёшь сайт, пользователь регистрируется с паролем qwerty123. Ты сохраняешь этот пароль в базу данных как есть. Завтра хакер получает доступ к твоей БД через SQL-инъекцию или утечку данных. Что он видит? Пароли всех пользователей в открытом виде.
Вывод: никогда, никогда, НИКОГДА не храни пароли в открытом виде.
Почему это критически важно?
- Люди используют одинаковые пароли везде — взломав твой сайт, хакер получает доступ к почте, соцсетям, банковским приложениям пользователя
- Утечки баз данных случаются даже у гигантов — Yahoo, LinkedIn, Adobe — все пострадали
- Юридическая ответственность — в ЕС за небезопасное хранение паролей можно получить штраф до 4% годового оборота компании (GDPR)
В этой главе ты научишься правильно работать с паролями в PHP.
🔐 Теория: Хеширование vs Шифрование
Что такое хеширование?
Хеширование — это односторонняя функция, которая превращает любые данные в строку фиксированной длины (хеш).
Пароль: "qwerty123"
Хеш: "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"Ключевые свойства:
- ✅ Односторонность — из хеша нельзя восстановить исходный пароль
- ✅ Детерминированность — один и тот же пароль всегда даёт один и тот же хеш
- ✅ Лавинный эффект — изменение одного символа полностью меняет хеш
Хеширование vs Шифрование
| Характеристика | Хеширование | Шифрование |
|---|---|---|
| Обратимость | Нет (односторонняя функция) | Да (с ключом) |
| Результат | Фиксированная длина | Переменная длина |
| Назначение | Проверка целостности, хранение паролей | Защита передаваемых данных |
| Пример | password_hash() | openssl_encrypt() |
Пример шифрования (неправильно для паролей!):
// ❌ ПЛОХО: шифрование обратимо
$encrypted = openssl_encrypt($password, 'AES-128-CBC', $key);
$decrypted = openssl_decrypt($encrypted, 'AES-128-CBC', $key);
// $decrypted === $password — можно восстановить пароль!Пример хеширования (правильно!):
// ✅ ХОРОШО: хеш необратим
$hash = password_hash($password, PASSWORD_BCRYPT);
// Восстановить $password из $hash невозможно!❌ Почему MD5, SHA1 и SHA256 — это плохо
Проблема скорости
MD5 и SHA-семейство созданы для быстрого вычисления хешей. Это хорошо для проверки целостности файлов, но катастрофа для паролей.
// MD5 — слишком быстрый
$start = microtime(true);
for ($i = 0; $i < 1000000; $i++) {
md5('password');
}
echo microtime(true) - $start; // ~0.5 секунды на миллион хешей!Почему это плохо?
Современная видеокарта NVIDIA RTX 4090 может проверить:
- MD5: ~200 миллиардов хешей в секунду
- SHA256: ~100 миллиардов хешей в секунду
- bcrypt: ~100 тысяч хешей в секунду
Rainbow Tables (радужные таблицы)
Это предвычисленные базы данных вида:
пароль → MD5 хеш
password → 5f4dcc3b5aa765d61d8327deb882cf99
123456 → e10adc3949ba59abbe56e057f20f883e
qwerty → d8578edf8458ce06fbc5bb76a58c5ca4Атака:
- Хакер получает хеш из БД:
5f4dcc3b5aa765d61d8327deb882cf99 - Ищет его в rainbow table
- Находит: это пароль
password - Профит!
Отсутствие соли
// ❌ ПЛОХО: два одинаковых пароля дают одинаковые хеши
echo md5('qwerty'); // d8578edf8458ce06fbc5bb76a58c5ca4
echo md5('qwerty'); // d8578edf8458ce06fbc5bb76a58c5ca4Если у тысячи пользователей пароль 123456, хакер сразу видит это по одинаковым хешам.
✅ Правильный способ: password_hash() и password_verify()
PHP предоставляет встроенные функции, которые делают всё правильно.
password_hash() — создание хеша
$password = 'qwerty123';
$hash = password_hash($password, PASSWORD_BCRYPT);
echo $hash;
// $2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWyЧто делает password_hash():
- ✅ Автоматически генерирует соль (salt) — случайную строку
- ✅ Использует медленный алгоритм (bcrypt по умолчанию)
- ✅ Сохраняет соль и cost в самом хеше
- ✅ Каждый раз создаёт разные хеши для одного пароля
// Два одинаковых пароля — разные хеши (из-за разной соли)
echo password_hash('qwerty', PASSWORD_BCRYPT) . "\n";
// $2y$10$abcdefghijklmnopqrstuv1234567890ABCDEFGHIJKLMNOPQRS
echo password_hash('qwerty', PASSWORD_BCRYPT) . "\n";
// $2y$10$XYZXYZXYZXYZXYZXYZXYZXYZ9876543210zyxwvutsrqponmlkjipassword_verify() — проверка пароля
$password = 'qwerty123';
$hash = '$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
if (password_verify($password, $hash)) {
echo "Пароль верный!";
} else {
echo "Пароль неверный!";
}Как работает password_verify():
- Извлекает соль и cost из хеша
- Хеширует введённый пароль с той же солью
- Сравнивает результат с сохранённым хешем
🏗️ Практика: Регистрация и авторизация
Структура таблицы
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Важно: поле называется password_hash, а не password — это явно показывает, что там хеш!
Регистрация пользователя
<?php
// register.php
require 'db.php'; // Подключение к БД через PDO
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email']);
$password = $_POST['password'];
$passwordConfirm = $_POST['password_confirm'];
$errors = [];
// Валидация
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Некорректный email';
}
if (strlen($password) < 8) {
$errors[] = 'Пароль должен быть минимум 8 символов';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Пароли не совпадают';
}
// Проверка, существует ли пользователь
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
$errors[] = 'Пользователь с таким email уже существует';
}
if (empty($errors)) {
// ✅ Хешируем пароль
$passwordHash = password_hash($password, PASSWORD_BCRYPT);
// Сохраняем в БД
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$stmt->execute([$email, $passwordHash]);
echo "Регистрация успешна!";
exit;
}
foreach ($errors as $error) {
echo "<p style='color: red;'>$error</p>";
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Регистрация</title>
</head>
<body>
<h1>Регистрация</h1>
<form method="POST">
<p>
<label>Email: <input type="email" name="email" required></label>
</p>
<p>
<label>Пароль: <input type="password" name="password" required></label>
</p>
<p>
<label>Повтор пароля: <input type="password" name="password_confirm" required></label>
</p>
<button type="submit">Зарегистрироваться</button>
</form>
</body>
</html>Авторизация пользователя
<?php
// login.php
session_start();
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email']);
$password = $_POST['password'];
// Получаем пользователя из БД
$stmt = $pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// ✅ Проверяем пароль
if ($user && password_verify($password, $user['password_hash'])) {
// Пароль верный — создаём сессию
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_email'] = $user['email'];
header('Location: dashboard.php');
exit;
} else {
$error = 'Неверный email или пароль';
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Вход</title>
</head>
<body>
<h1>Вход</h1>
<?php if (isset($error)): ?>
<p style="color: red;"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="POST">
<p>
<label>Email: <input type="email" name="email" required></label>
</p>
<p>
<label>Пароль: <input type="password" name="password" required></label>
</p>
<button type="submit">Войти</button>
</form>
<p>Нет аккаунта? <a href="register.php">Зарегистрируйтесь</a></p>
</body>
</html>🔧 Алгоритмы хеширования
PASSWORD_BCRYPT (рекомендуется)
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);Параметры:
- cost (сложность) — от 4 до 31, по умолчанию 10
- Чем выше cost, тем медленнее хеширование (удваивается с каждым +1)
Как выбрать cost:
// Тест производительности
function testCost($cost) {
$start = microtime(true);
password_hash('test_password', PASSWORD_BCRYPT, ['cost' => $cost]);
return microtime(true) - $start;
}
for ($cost = 8; $cost <= 14; $cost++) {
$time = testCost($cost);
echo "Cost $cost: " . round($time * 1000, 2) . " мс\n";
}
// Результат (примерно):
// Cost 8: 10 мс
// Cost 9: 20 мс
// Cost 10: 40 мс ← по умолчанию
// Cost 11: 80 мс
// Cost 12: 160 мс ← хороший выбор для 2025 года
// Cost 13: 320 мс
// Cost 14: 640 мсРекомендация: выбери cost так, чтобы хеширование занимало ~100-300 мс. Это защитит от брутфорса, но не замедлит работу сайта.
PASSWORD_ARGON2I и PASSWORD_ARGON2ID (современные)
// Argon2i (защита от side-channel атак)
$hash = password_hash($password, PASSWORD_ARGON2I, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 итерации
'threads' => 2 // 2 потока
]);
// Argon2id (гибрид, рекомендуется)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 2
]);Преимущества Argon2:
- ✅ Защита от атак через GPU (требует много памяти)
- ✅ Настройка потребления CPU, RAM и потоков
- ✅ Победитель Password Hashing Competition 2015
Недостатки:
- ❌ Требует PHP 7.2+ и расширение Sodium
- ❌ Меньше распространён, чем bcrypt
PASSWORD_DEFAULT (используй его!)
$hash = password_hash($password, PASSWORD_DEFAULT);Почему это лучший выбор:
- ✅ Автоматически использует самый надёжный алгоритм (сейчас bcrypt)
- ✅ В будущем может переключиться на более новый алгоритм
- ✅ Код не нужно менять при обновлении PHP
🔄 Рехеширование при обновлении алгоритма
Когда ты меняешь алгоритм или cost, старые хеши остаются в БД. Решение: рехешировать при логине.
<?php
// login.php
session_start();
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email']);
$password = $_POST['password'];
$stmt = $pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
// ✅ Проверяем, нужно ли обновить хеш
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
$stmt->execute([$newHash, $user['id']]);
error_log("Обновлён хеш для пользователя {$user['id']}");
}
$_SESSION['user_id'] = $user['id'];
header('Location: dashboard.php');
exit;
}
$error = 'Неверный email или пароль';
}
?>Что делает password_needs_rehash():
- Проверяет, создан ли хеш с текущим алгоритмом и параметрами
- Возвращает
true, если хеш устарел
🛡️ Дополнительные меры безопасности
1. Rate Limiting (ограничение попыток)
<?php
// login.php с защитой от брутфорса
session_start();
require 'db.php';
// Проверяем количество попыток
$ip = $_SERVER['REMOTE_ADDR'];
$stmt = $pdo->prepare('
SELECT COUNT(*) as attempts
FROM login_attempts
WHERE ip = ? AND attempted_at > NOW() - INTERVAL 15 MINUTE
');
$stmt->execute([$ip]);
$attempts = $stmt->fetchColumn();
if ($attempts >= 5) {
die('Слишком много попыток входа. Попробуйте через 15 минут.');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email']);
$password = $_POST['password'];
$stmt = $pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
// Успешный вход — очищаем попытки
$pdo->prepare('DELETE FROM login_attempts WHERE ip = ?')->execute([$ip]);
$_SESSION['user_id'] = $user['id'];
header('Location: dashboard.php');
exit;
} else {
// Неудачная попытка — записываем
$stmt = $pdo->prepare('INSERT INTO login_attempts (ip, attempted_at) VALUES (?, NOW())');
$stmt->execute([$ip]);
$error = 'Неверный email или пароль';
}
}
?>Таблица для попыток:
CREATE TABLE login_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45) NOT NULL,
attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (ip, attempted_at)
);2. Требования к паролям
function validatePassword($password) {
$errors = [];
if (strlen($password) < 12) {
$errors[] = 'Минимум 12 символов';
}
if (!preg_match('/[a-z]/', $password)) {
$errors[] = 'Нужна минимум одна строчная буква';
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = 'Нужна минимум одна заглавная буква';
}
if (!preg_match('/[0-9]/', $password)) {
$errors[] = 'Нужна минимум одна цифра';
}
if (!preg_match('/[^a-zA-Z0-9]/', $password)) {
$errors[] = 'Нужен минимум один спецсимвол (!@#$%^&*)';
}
// Проверка на популярные пароли
$commonPasswords = ['Password123!', 'Qwerty123!', 'Admin123!'];
if (in_array($password, $commonPasswords)) {
$errors[] = 'Этот пароль слишком популярный';
}
return $errors;
}
// Использование
$errors = validatePassword($_POST['password']);
if (!empty($errors)) {
foreach ($errors as $error) {
echo "<p>$error</p>";
}
}3. Двухфакторная аутентификация (2FA)
// Генерация секрета для Google Authenticator
// composer require pragmarx/google2fa
use PragmaRX\Google2FA\Google2FA;
$google2fa = new Google2FA();
// При включении 2FA
$secret = $google2fa->generateSecretKey();
// Сохраняем $secret в БД для пользователя
$stmt = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?');
$stmt->execute([$secret, $userId]);
// QR-код для сканирования
$qrCodeUrl = $google2fa->getQRCodeUrl(
'MyApp',
$userEmail,
$secret
);
// При входе проверяем код из приложения
$valid = $google2fa->verifyKey($user['totp_secret'], $_POST['totp_code']);⚠️ Частые ошибки
❌ Ошибка 1: Хеширование несколько раз
// ❌ ПЛОХО: каждый раз новый хеш
$hash1 = password_hash($password, PASSWORD_BCRYPT);
$hash2 = password_hash($password, PASSWORD_BCRYPT);
// $hash1 !== $hash2 — как тогда проверять?!Правильно:
// ✅ ХОРОШО: хешируем один раз при регистрации
$hash = password_hash($password, PASSWORD_BCRYPT);
// Сохраняем в БД
// При проверке используем password_verify()
password_verify($inputPassword, $hash); // Работает!❌ Ошибка 2: Хеширование пароля дважды
// ❌ ПЛОХО: хеш от хеша
$hash = password_hash($password, PASSWORD_BCRYPT);
$doubleHash = password_hash($hash, PASSWORD_BCRYPT);
// При проверке
password_verify($password, $doubleHash); // FALSE — не сработает!❌ Ошибка 3: Использование устаревших функций
// ❌ ПЛОХО: устаревшие функции
crypt($password, $salt); // Используй password_hash()
md5($password); // Категорически нет!
sha1($password); // Тоже нет!
hash('sha256', $password); // Слишком быстро!❌ Ошибка 4: Неправильная проверка
// ❌ ПЛОХО: сравнение хешей
$inputHash = password_hash($inputPassword, PASSWORD_BCRYPT);
if ($inputHash === $savedHash) { // Всегда FALSE из-за разной соли!
echo "Вход выполнен";
}
// ✅ ХОРОШО: используй password_verify()
if (password_verify($inputPassword, $savedHash)) {
echo "Вход выполнен";
}🧪 Практические задания
Задание 1: Базовая регистрация
Создай систему регистрации с требованиями:
- Email должен быть уникальным
- Пароль минимум 8 символов
- Пароли должны совпадать
- Хеш сохраняется в БД
Задание 2: Авторизация с запоминанием
Реализуй:
- Авторизацию через email/пароль
- Checkbox "Запомнить меня" (cookie на 30 дней)
- Выход из аккаунта (уничтожение сессии)
Подсказка для "Запомнить меня":
if (isset($_POST['remember_me'])) {
$token = bin2hex(random_bytes(32));
// Сохраняем токен в БД
$stmt = $pdo->prepare('UPDATE users SET remember_token = ? WHERE id = ?');
$stmt->execute([password_hash($token, PASSWORD_BCRYPT), $userId]);
// Сохраняем токен в cookie
setcookie('remember_token', $token, time() + 30 * 24 * 3600, '/', '', true, true);
}Задание 3: Смена пароля
Реализуй страницу смены пароля:
- Проверка старого пароля
- Валидация нового пароля
- Новый пароль должен отличаться от старого
- Обновление хеша в БД
Задание 4: Восстановление пароля
Создай систему восстановления:
- Пользователь вводит email
- Генерируется токен, сохраняется в БД
- Отправляется ссылка (можно просто вывести на экран)
- По ссылке пользователь вводит новый пароль
- Токен одноразовый и истекает через 1 час
Структура таблицы:
CREATE TABLE password_resets (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (email, token)
);Задание 5: Миграция с MD5 на bcrypt
У тебя есть старая БД с паролями в MD5. Напиши скрипт:
- При входе проверяй, есть ли поле
legacy_md5 - Если да — проверяй через
md5($password) === $user['legacy_md5'] - При успешном входе создай
password_hash()и удалиlegacy_md5
📊 Сравнительная таблица алгоритмов
| Алгоритм | Скорость | Безопасность | Рекомендация |
|---|---|---|---|
| MD5 | Очень быстрый | ❌ Взломан | Никогда не используй |
| SHA1 | Очень быстрый | ❌ Взломан | Никогда не используй |
| SHA256 | Быстрый | ⚠️ Слишком быстрый | Не для паролей |
| bcrypt | Медленный ✅ | ✅ Надёжный | Рекомендуется |
| Argon2 | Медленный ✅ | ✅ Очень надёжный | Отлично, если поддерживается |
🎓 Контрольные вопросы
- Чем хеширование отличается от шифрования?
- Почему MD5 и SHA256 плохо подходят для паролей?
- Что такое "соль" (salt) и зачем она нужна?
- Как работает функция
password_verify()внутри? - Что делать, если нужно обновить алгоритм хеширования для существующих пользователей?
- Зачем нужен параметр
costв bcrypt? - Можно ли восстановить пароль из хеша?
- Как защититься от брутфорса при авторизации?
📚 Резюме главы
✅ Что ты узнал:
- Хеширование ≠ Шифрование — хеш необратим, это защита
- Никогда не используй MD5/SHA для паролей — слишком быстрые, есть rainbow tables
password_hash()— правильный способ хеширования в PHPpassword_verify()— правильный способ проверки пароля- Соль генерируется автоматически — не нужно создавать вручную
- Рехеширование при обновлении — используй
password_needs_rehash() - Дополнительная защита — rate limiting, 2FA, сложные пароли
🔐 Золотое правило:
// При регистрации
$hash = password_hash($password, PASSWORD_DEFAULT);
// При входе
if (password_verify($inputPassword, $savedHash)) {
// Авторизация успешна
}Всё остальное — детали и улучшения. Но эти две функции — основа безопасного хранения паролей.
🚀 Что дальше?
В следующей главе "Глава 6.4: Загрузка файлов безопасно" ты узнаешь:
- Как проверять типы файлов (не только по расширению!)
- Почему нельзя доверять
$_FILES['file']['type'] - Как хранить файлы вне webroot
- Защита от загрузки PHP-скриптов
- Ограничение размера и количества файлов
Безопасность — это не одна фича, а образ мышления. Продолжаем! 💪