Skip to content

Глава 6.3: Хеширование и пароли — password_hash, password_verify, почему MD5 — это плохо

📖 Введение

Представь: ты создаёшь сайт, пользователь регистрируется с паролем qwerty123. Ты сохраняешь этот пароль в базу данных как есть. Завтра хакер получает доступ к твоей БД через SQL-инъекцию или утечку данных. Что он видит? Пароли всех пользователей в открытом виде.

Вывод: никогда, никогда, НИКОГДА не храни пароли в открытом виде.

Почему это критически важно?

  1. Люди используют одинаковые пароли везде — взломав твой сайт, хакер получает доступ к почте, соцсетям, банковским приложениям пользователя
  2. Утечки баз данных случаются даже у гигантов — Yahoo, LinkedIn, Adobe — все пострадали
  3. Юридическая ответственность — в ЕС за небезопасное хранение паролей можно получить штраф до 4% годового оборота компании (GDPR)

В этой главе ты научишься правильно работать с паролями в PHP.


🔐 Теория: Хеширование vs Шифрование

Что такое хеширование?

Хеширование — это односторонняя функция, которая превращает любые данные в строку фиксированной длины (хеш).

Пароль: "qwerty123"
Хеш:    "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"

Ключевые свойства:

  • Односторонность — из хеша нельзя восстановить исходный пароль
  • Детерминированность — один и тот же пароль всегда даёт один и тот же хеш
  • Лавинный эффект — изменение одного символа полностью меняет хеш

Хеширование vs Шифрование

ХарактеристикаХешированиеШифрование
ОбратимостьНет (односторонняя функция)Да (с ключом)
РезультатФиксированная длинаПеременная длина
НазначениеПроверка целостности, хранение паролейЗащита передаваемых данных
Примерpassword_hash()openssl_encrypt()

Пример шифрования (неправильно для паролей!):

php
// ❌ ПЛОХО: шифрование обратимо
$encrypted = openssl_encrypt($password, 'AES-128-CBC', $key);
$decrypted = openssl_decrypt($encrypted, 'AES-128-CBC', $key);
// $decrypted === $password — можно восстановить пароль!

Пример хеширования (правильно!):

php
// ✅ ХОРОШО: хеш необратим
$hash = password_hash($password, PASSWORD_BCRYPT);
// Восстановить $password из $hash невозможно!

❌ Почему MD5, SHA1 и SHA256 — это плохо

Проблема скорости

MD5 и SHA-семейство созданы для быстрого вычисления хешей. Это хорошо для проверки целостности файлов, но катастрофа для паролей.

php
// 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

Атака:

  1. Хакер получает хеш из БД: 5f4dcc3b5aa765d61d8327deb882cf99
  2. Ищет его в rainbow table
  3. Находит: это пароль password
  4. Профит!

Отсутствие соли

php
// ❌ ПЛОХО: два одинаковых пароля дают одинаковые хеши
echo md5('qwerty'); // d8578edf8458ce06fbc5bb76a58c5ca4
echo md5('qwerty'); // d8578edf8458ce06fbc5bb76a58c5ca4

Если у тысячи пользователей пароль 123456, хакер сразу видит это по одинаковым хешам.


✅ Правильный способ: password_hash() и password_verify()

PHP предоставляет встроенные функции, которые делают всё правильно.

password_hash() — создание хеша

php
$password = 'qwerty123';
$hash = password_hash($password, PASSWORD_BCRYPT);

echo $hash;
// $2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

Что делает password_hash():

  1. ✅ Автоматически генерирует соль (salt) — случайную строку
  2. ✅ Использует медленный алгоритм (bcrypt по умолчанию)
  3. ✅ Сохраняет соль и cost в самом хеше
  4. ✅ Каждый раз создаёт разные хеши для одного пароля
php
// Два одинаковых пароля — разные хеши (из-за разной соли)
echo password_hash('qwerty', PASSWORD_BCRYPT) . "\n";
// $2y$10$abcdefghijklmnopqrstuv1234567890ABCDEFGHIJKLMNOPQRS

echo password_hash('qwerty', PASSWORD_BCRYPT) . "\n";
// $2y$10$XYZXYZXYZXYZXYZXYZXYZXYZ9876543210zyxwvutsrqponmlkji

password_verify() — проверка пароля

php
$password = 'qwerty123';
$hash = '$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';

if (password_verify($password, $hash)) {
    echo "Пароль верный!";
} else {
    echo "Пароль неверный!";
}

Как работает password_verify():

  1. Извлекает соль и cost из хеша
  2. Хеширует введённый пароль с той же солью
  3. Сравнивает результат с сохранённым хешем

🏗️ Практика: Регистрация и авторизация

Структура таблицы

sql
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
<?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
<?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 (рекомендуется)

php
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

Параметры:

  • cost (сложность) — от 4 до 31, по умолчанию 10
  • Чем выше cost, тем медленнее хеширование (удваивается с каждым +1)

Как выбрать cost:

php
// Тест производительности
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 (современные)

php
// 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 (используй его!)

php
$hash = password_hash($password, PASSWORD_DEFAULT);

Почему это лучший выбор:

  • ✅ Автоматически использует самый надёжный алгоритм (сейчас bcrypt)
  • ✅ В будущем может переключиться на более новый алгоритм
  • ✅ Код не нужно менять при обновлении PHP

🔄 Рехеширование при обновлении алгоритма

Когда ты меняешь алгоритм или cost, старые хеши остаются в БД. Решение: рехешировать при логине.

php
<?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
<?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 или пароль';
    }
}
?>

Таблица для попыток:

sql
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. Требования к паролям

php
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)

php
// Генерация секрета для 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: Хеширование несколько раз

php
// ❌ ПЛОХО: каждый раз новый хеш
$hash1 = password_hash($password, PASSWORD_BCRYPT);
$hash2 = password_hash($password, PASSWORD_BCRYPT);
// $hash1 !== $hash2 — как тогда проверять?!

Правильно:

php
// ✅ ХОРОШО: хешируем один раз при регистрации
$hash = password_hash($password, PASSWORD_BCRYPT);
// Сохраняем в БД

// При проверке используем password_verify()
password_verify($inputPassword, $hash); // Работает!

❌ Ошибка 2: Хеширование пароля дважды

php
// ❌ ПЛОХО: хеш от хеша
$hash = password_hash($password, PASSWORD_BCRYPT);
$doubleHash = password_hash($hash, PASSWORD_BCRYPT);

// При проверке
password_verify($password, $doubleHash); // FALSE — не сработает!

❌ Ошибка 3: Использование устаревших функций

php
// ❌ ПЛОХО: устаревшие функции
crypt($password, $salt);     // Используй password_hash()
md5($password);              // Категорически нет!
sha1($password);             // Тоже нет!
hash('sha256', $password);   // Слишком быстро!

❌ Ошибка 4: Неправильная проверка

php
// ❌ ПЛОХО: сравнение хешей
$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 дней)
  • Выход из аккаунта (уничтожение сессии)

Подсказка для "Запомнить меня":

php
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: Восстановление пароля

Создай систему восстановления:

  1. Пользователь вводит email
  2. Генерируется токен, сохраняется в БД
  3. Отправляется ссылка (можно просто вывести на экран)
  4. По ссылке пользователь вводит новый пароль
  5. Токен одноразовый и истекает через 1 час

Структура таблицы:

sql
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Медленный ✅✅ Очень надёжныйОтлично, если поддерживается

🎓 Контрольные вопросы

  1. Чем хеширование отличается от шифрования?
  2. Почему MD5 и SHA256 плохо подходят для паролей?
  3. Что такое "соль" (salt) и зачем она нужна?
  4. Как работает функция password_verify() внутри?
  5. Что делать, если нужно обновить алгоритм хеширования для существующих пользователей?
  6. Зачем нужен параметр cost в bcrypt?
  7. Можно ли восстановить пароль из хеша?
  8. Как защититься от брутфорса при авторизации?

📚 Резюме главы

Что ты узнал:

  1. Хеширование ≠ Шифрование — хеш необратим, это защита
  2. Никогда не используй MD5/SHA для паролей — слишком быстрые, есть rainbow tables
  3. password_hash() — правильный способ хеширования в PHP
  4. password_verify() — правильный способ проверки пароля
  5. Соль генерируется автоматически — не нужно создавать вручную
  6. Рехеширование при обновлении — используй password_needs_rehash()
  7. Дополнительная защита — rate limiting, 2FA, сложные пароли

🔐 Золотое правило:

php
// При регистрации
$hash = password_hash($password, PASSWORD_DEFAULT);

// При входе
if (password_verify($inputPassword, $savedHash)) {
    // Авторизация успешна
}

Всё остальное — детали и улучшения. Но эти две функции — основа безопасного хранения паролей.


🚀 Что дальше?

В следующей главе "Глава 6.4: Загрузка файлов безопасно" ты узнаешь:

  • Как проверять типы файлов (не только по расширению!)
  • Почему нельзя доверять $_FILES['file']['type']
  • Как хранить файлы вне webroot
  • Защита от загрузки PHP-скриптов
  • Ограничение размера и количества файлов

Безопасность — это не одна фича, а образ мышления. Продолжаем! 💪

Выпущено под лицензией MIT