Практика аутентификации с логином и паролем

Раздел: Безопасность веб-приложений -> Аутентификация и пароли

Основное решение: безопасное хеширование паролей и подготовленные запросы

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

- логин пароль php (логин и пароль в php)

Логин и пароль в PHP - comments

En
логин пароль php (php)