Практика аутентификации с логином и паролем
Основное решение: безопасное хеширование паролей и подготовленные запросы
Наиболее эффективный способ реализации логина и пароля в PHP - использование функций password_hash() и password_verify() вместе с подготовленными запросами PDO. Это защищает от SQL-инъекций и гарантирует надёжное хранение паролей.
При регистрации пароль хешируется с помощью bcrypt (алгоритм по умолчанию), а при входе проверяется. Также важно защитить форму от CSRF-атак с помощью токенов.
// Регистрация пользователя
$password = $_POST['password'];
$hash = password_hash($password, PASSWORD_DEFAULT);
// Сохранение $hash в базе данных через PDO prepared statement// Вход пользователя
$hashFromDB = $stmt->fetchColumn();
if (password_verify($_POST['password'], $hashFromDB)) {
// Успешная аутентификация
} else {
// Неверный пароль
}Цель:
Обеспечение безопасного хранения паролей и защиты от атак, таких как SQL-инъекции и перебор.
Проблема: если база данных скомпрометирована, хеши должны быть стойкими к перебору. Решение: использование bcrypt с достаточной стоимостью (cost factor), а ещё лучше - Argon2. Типичная ошибка: забыть указать опции стоимости или использовать устаревший алгоритм.
Вопрос: Как хранить пароли в открытом виде?
Это абсолютно недопустимо. Любая утечка базы данных раскрывает все пароли. Однако некоторые новички ошибочно делают это для упрощения разработки. Случаи использования: только в учебных целях на локальном сервере без реальных данных.
// Опасный код
$password = $_POST['password'];
$sql = "INSERT INTO users (password) VALUES ('$password')";Проблема: прямая утечка паролей. Решение: никогда не использовать такой подход.
Вопрос: Как использовать MD5 или SHA1 без соли?
Эти алгоритмы быстрые и уязвимы к атакам по радужным таблицам. Ранее использовались, но сейчас неприемлемы. Случаи использования: только для обратной совместимости со старыми системами, которые требуют миграции.
// Устаревший подход
$hash = md5($password); // или sha1($password)
$hash = md5($password . 'salt'); // немного лучше, но небезопасноПроблема: быстрые алгоритмы позволяют перебирать пароли миллиардами в секунду. Решение: заменить на bcrypt/argon2, используя механизм обновления хеша при успешном входе.
Вопрос: Как добавить собственную соль к хешу?
Ручное добавление соли возможно, но password_hash() уже включает автоматическую соль. Самостоятельная соль требует её хранения и может быть реализована небезопасно. Случаи использования: при миграции со старых систем, где соль была фиксированной.
$salt = 'myStaticSalt'; // не рекомендуется
$hash = md5($salt . $password);
// Лучше password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);Проблема: статическая соль делает все хеши уязвимыми, если она известна. Решение: использовать встроенные функции с случайной солью.
Вопрос: Как использовать Argon2 для хеширования?
Argon2 - современный алгоритм, победитель конкурса PHC. Доступен в PHP 7.2+ как PASSWORD_ARGON2I или PASSWORD_ARGON2ID. Случаи использования: высоконагруженные системы, где требуется максимальная стойкость к GPU-перебору.
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 2
]);Проблема: Argon2 может потреблять много ресурсов CPU и памяти, что на слабых серверах приводит к задержкам. Решение: подбирать параметры под производительность сервера.
Вопрос: Как защитить форму входа от CSRF?
CSRF-атака заставляет браузер пользователя отправить запрос на вход без его ведома. Решение: добавлять в форму скрытое поле с токеном, который проверяется на сервере. Случаи использования: все формы аутентификации.
// Генерация токена в сессии
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// В форме
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
// Проверка
if ($_POST['csrf_token'] !== $_SESSION['csrf_token']) { die('CSRF detected'); }Проблема: токен может быть перехвачен при XSS-уязвимости. Решение: дополнительно использовать ограничение времени жизни токена и политику CORS.
Вопрос: Как организовать сессии после успешного входа?
После проверки пароля создаётся сессия с идентификатором пользователя. Важно обновлять идентификатор сессии после входа (session_regenerate_id()), чтобы предотвратить атаки фиксации сессии. Случаи использования: любая система с сохранением состояния входа.
session_start();
if (password_verify(...)) {
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
}Проблема: невыполнение session_regenerate_id() делает систему уязвимой к фиксации сессии. Решение: вызывать эту функцию после каждой успешной аутентификации.
Вопрос: Как бороться с перебором паролей?
Ограничение числа попыток (throttling) и задержки. Реализовать можно через сессию или базу данных, фиксируя временные метки неудачных входов. Случаи использования: публичные сайты, подверженные атакам.
// Пример с сессией
$attempts = $_SESSION['login_attempts'] ?? 0;
if ($attempts >= 5 && time() - $_SESSION['last_attempt'] < 300) {
die('Too many attempts. Wait 5 minutes.');
}
// После неудачного входа
$_SESSION['login_attempts']++;
$_SESSION['last_attempt'] = time();Проблема: злоумышленник может обойти ограничение через разные IP. Решение: комбинировать с ограничением по IP (но это может затронуть пользователей за NAT).
Расширенные примеры реализации логина и пароля
Ниже приведены подробные примеры кода с комментариями и демонстрацией результатов.
Пример 1. Полный скрипт регистрации с хешированием и подготовленным запросом
// register.php
<?php
require 'db.php'; // подключение PDO
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username']);
$password = $_POST['password'];
$hash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
$stmt = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (:u, :h)');
$stmt->execute(['u' => $username, 'h' => $hash]);
echo 'User registered.';
}
?>
<form method="post">
<input type="text" name="username" required>
<input type="password" name="password" required>
<button type="submit">Register</button>
</form>Результат: при успешном выполнении в базу добавляется запись с хешем пароля. Пример хеша: $2y$12$SomeRandomSaltAndHashValue (в реальной строке будет длинный хеш).
Пример 2. Скрипт входа с проверкой CSRF-токена и защитой от перебора
// login.php
<?php
session_start();
// Генерация CSRF токена, если его нет
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Ограничение попыток (5 за 10 минут)
$maxAttempts = 5;
$lockoutTime = 600; // 10 минут
if (isset($_SESSION['attempts'])) {
if ($_SESSION['attempts'] >= $maxAttempts && time() - $_SESSION['last_attempt_time'] < $lockoutTime) {
die('Too many failed attempts. Try again later.');
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Проверка CSRF
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
die('Invalid CSRF token.');
}
$username = trim($_POST['username']);
$password = $_POST['password'];
require 'db.php';
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = :u');
$stmt->execute(['u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
// Сброс попыток
unset($_SESSION['attempts']);
unset($_SESSION['last_attempt_time']);
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
header('Location: dashboard.php');
exit;
} else {
$_SESSION['attempts'] = ($_SESSION['attempts'] ?? 0) + 1;
$_SESSION['last_attempt_time'] = time();
$error = 'Invalid username or password.';
}
}
?>
<form method="post">
<input type="text" name="username" required>
<input type="password" name="password" required>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<button type="submit">Log in</button>
<?php if (isset($error)) echo '<p style="color:red">' . $error . '</p>'; ?>
</form>Результат: при неверных данных выводится ошибка, счётчик попыток увеличивается. После 5 неудачных попыток за 10 минут доступ блокируется. После успешного входа происходит редирект.
Пример 3. Миграция с MD5 на bcrypt при входе пользователя
// При входе: если старый хеш (начало не с $2y$), обновляем его
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE username = :u');
$stmt->execute(['u' => $username]);
$oldHash = $stmt->fetchColumn();
if (password_verify($password, $oldHash) || (md5($password) === $oldHash)) {
if (md5($password) === $oldHash) {
// Замена на bcrypt
$newHash = password_hash($password, PASSWORD_DEFAULT);
$update = $pdo->prepare('UPDATE users SET password_hash = :h WHERE username = :u');
$update->execute(['h' => $newHash, 'u' => $username]);
}
// Успешный вход
}Результат: при каждом входе пользователя с устаревшим хешем его пароль перехешируется. Нет необходимости в массовом обновлении.
Пример 4. Использование Argon2id с кастомизацией параметров
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 итерации
'threads' => 3 // 3 параллельных потока
]);
// Проверка остаётся той же: password_verify($password, $hash)Результат: хеш будет начинаться с $argon2id$. Параметры можно настроить под производительность сервера.