Авторизация пользователей в PHP с использованием баз данных MySQL

Раздел: Веб-разработка -> Авторизация и аутентификация

Основные подходы к реализации авторизации с помощью PHP и MySQL

Рекомендуемое решение: подготовленные запросы PDO и password_hash

Как сделать безопасную авторизацию с хранением паролей в хешированном виде?

Самый надёжный способ на данный момент - использовать расширение PDO для работы с базой данных и функции password_hash() и password_verify(). Это позволяет избежать SQL-инъекций и гарантирует, что пароли хранятся в виде криптостойкого хеша (по умолчанию bcrypt). Разберём пошаговую реализацию.

Шаг 1. Создание таблицы пользователей

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Шаг 2. Регистрация нового пользователя

<?php
// register.php
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$username = $_POST['username'];
$email = $_POST['email'];
$password = $_POST['password'];

// Проверка существования пользователя
$check = $pdo->prepare('SELECT id FROM users WHERE username = ? OR email = ?');
$check->execute([$username, $email]);
if ($check->fetch()) {
    // пользователь уже существует
}

$hash = password_hash($password, PASSWORD_DEFAULT);

$stmt = $pdo->prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)');
$stmt->execute([$username, $email, $hash]);
?>

Пояснение

  • Пароль никогда не сохраняется в открытом виде. password_hash() создаёт хеш с солью автоматически.
  • Подготовленные запросы (placeholders ?) защищают от SQL-инъекций.
  • Ошибки обрабатываются через исключения (ERRMODE_EXCEPTION).

Шаг 3. Аутентификация (вход)

<?php
session_start();
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$username = $_POST['username'];
$password = $_POST['password'];

$stmt = $pdo->prepare('SELECT id, username, password_hash FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch();

if ($user && password_verify($password, $user['password_hash'])) {
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['username'] = $user['username'];
    // перенаправление на защищённую страницу
    header('Location: profile.php');
} else {
    // неверные данные
}
?>

Функция password_verify() сравнивает введённый пароль с хешем из базы данных. Если совпадение есть - создаётся сессия.

Шаг 4. Защита страниц

<?php
session_start();
if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}
// дальнейшее выполнение для авторизованных пользователей
?>
Типичные проблемы и их решение
  • Проблема: Не запущена сессия (session_start()) перед проверкой. Решение: всегда вызывать session_start() в начале скрипта, если предполагается работа с сессией.
  • Проблема: Использование устаревших функций mysql_*. Решение: перейти на PDO или mysqli.
  • Проблема: Пароль не проходит верификацию, хотя данные верны. Решение: убедиться, что поле в базе данных имеет тип VARCHAR(255) и содержит именно хеш, а не сам пароль.
  • Проблема: Необходимость генерации новой соли. Решение: password_hash() делает это автоматически, дополнительных действий не требуется.

Вариант 1: Использование MySQLi вместо PDO

Как выполнить ту же задачу, но с расширением MySQLi?

MySQLi также поддерживает подготовленные запросы и объектно-ориентированный или процедурный стиль. Пример на ООП:

<?php
$mysqli = new mysqli('localhost', 'root', '', 'test');
if ($mysqli->connect_error) die('Connection failed: ' . $mysqli->connect_error);

$stmt = $mysqli->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->bind_param('s', $username);
$stmt->execute();
$stmt->bind_result($id, $hash);
$stmt->fetch();

if ($hash && password_verify($password, $hash)) {
    session_start();
    $_SESSION['user_id'] = $id;
}
$stmt->close();
?>

Цели использования:

  • Если проект уже использует MySQLi, переход на PDO может быть избыточным.
  • MySQLi поддерживает асинхронные запросы (в некоторых версиях).

Проблема: В процедурном стиле легко допустить ошибку, забыв передать ссылку на переменную в bind_param. Решение: внимательно сверять типы (s - string, i - integer и т.д.).

Вариант 2: Устаревший способ с MD5 (не рекомендуется)

Как организовать авторизацию, если на проекте уже используется MD5?

До появления password_hash часто использовали md5() или sha1() с солью. Пример:

$salt = 'random_salt_123';
$hash = md5($salt . $password);
// INSERT / SELECT ...

Этот метод крайне небезопасен: MD5 легко поддаётся атакам перебором. Цель использования: только для совместимости со старыми системами, которые невозможно переписать.

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

Вариант 3: Использование Argon2 в password_hash

Как повысить стойкость хеша, выбрав другой алгоритм?

Начиная с PHP 7.2 поддерживается алгоритм Argon2. Для его активации нужно передать константу PASSWORD_ARGON2I (или PASSWORD_ARGON2ID в PHP 8.1+):

$hash = password_hash($password, PASSWORD_ARGON2ID, ['memory_cost' => 1<<17, 'time_cost' => 4, 'threads' => 2]);
// проверка остаётся той же

Цель: для систем, где требуется максимальная защита от параллельных атак (например, хранение паролей банковских клиентов).

Вариант 4: Авторизация с запоминанием пользователя (Remember Me)

Как реализовать функцию «Запомнить меня» с помощью токенов в БД?

Дополнительная таблица для хранения токенов:

CREATE TABLE auth_tokens (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    selector VARCHAR(64) NOT NULL,
    hashed_validator VARCHAR(255) NOT NULL,
    expires DATETIME NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

При входе снятие флажка «Remember me» генерирует случайный selector и validator. Hасшифрованный validator сохраняется в базе, selector отправляется в куку. При возврате пользователя кука сопоставляется с базой.

Ошибка: Сохранение validator в открытом виде. Решение: обязательно хешировать validator (через password_hash).

Вариант 5: Аутентификация через веб-токены (JWT) без сессий

Как построить stateless авторизацию для API на PHP?

Вместо сессий используется подписанный токен, который клиент хранит локально (например, в localStorage). При каждом запросе токен проверяется на сервере с помощью библиотек (например, firebase/php-jwt).

// Генерация JWT после успешного входа
use Firebase\JWT\JWT;
$payload = [
    'user_id' => $user['id'],
    'iat' => time(),
    'exp' => time() + 3600
];
$jwt = JWT::encode($payload, 'secret-key', 'HS256');
// возвращаем токен клиенту

Цель: REST API, мобильные приложения, микросервисы.

Детальные примеры с расширенным функционалом

Пример 1. Полный скрипт регистрации с валидацией и защитой от повторной отправки

Пример
<?php
session_start();
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username']);
    $email = trim($_POST['email']);
    $password = $_POST['password'];
    $confirm = $_POST['confirm'];

    // Валидация
    if (strlen($username) < 3 || strlen($username) > 50) {
        $errors[] = 'Имя пользователя должно содержать от 3 до 50 символов.';
    }
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = 'Некорректный email.';
    }
    if ($password !== $confirm) {
        $errors[] = 'Пароли не совпадают.';
    }
    if (strlen($password) < 6) {
        $errors[] = 'Пароль должен быть длиннее 6 символов.';
    }

    // Проверка на дубликаты
    if (empty($errors)) {
        $check = $pdo->prepare('SELECT id FROM users WHERE username = ? OR email = ?');
        $check->execute([$username, $email]);
        if ($check->fetch()) {
            $errors[] = 'Имя пользователя или email уже заняты.';
        }
    }

    if (empty($errors)) {
        $hash = password_hash($password, PASSWORD_DEFAULT);
        $stmt = $pdo->prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)');
        $stmt->execute([$username, $email, $hash]);
        $_SESSION['success'] = 'Регистрация прошла успешно. Можете войти.';
        header('Location: login.php');
        exit;
    }
}
?>
<!DOCTYPE html>
<html>
<body>
<?php if (!empty($errors)): ?>
    <ul><?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul>
<?php endif; ?>
<form method="post">
    <input name="username" required>
    <input type="email" name="email" required>
    <input type="password" name="password" required>
    <input type="password" name="confirm" required>
    <button type="submit">Зарегистрироваться</button>
</form>
</body>
</html>

Результат:

При успешной регистрации – перенаправление на login.php с сообщением в сессии. При ошибках – отображение списка проблем на той же странице.

Пример 2. Логин с учётом блокировки после неудачных попыток

Пример
<?php
session_start();
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// Таблица login_attempts
// CREATE TABLE login_attempts (ip VARCHAR(45) PRIMARY KEY, attempts INT DEFAULT 0, last_attempt DATETIME);

$ip = $_SERVER['REMOTE_ADDR'];

// Ограничение: не более 5 попыток за 15 минут
$max_attempts = 5;
$time_window = 15 * 60;

$attempt = $pdo->prepare('SELECT attempts, last_attempt FROM login_attempts WHERE ip = ?');
$attempt->execute([$ip]);
$data = $attempt->fetch();

if ($data && $data['attempts'] >= $max_attempts && (time() - strtotime($data['last_attempt'])) < $time_window) {
    die('Слишком много попыток. Повторите через 15 минут.');
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = $_POST['username'];
    $password = $_POST['password'];

    $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = ?');
    $stmt->execute([$username]);
    $user = $stmt->fetch();

    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 {
        // фиксируем неудачную попытку
        if ($data) {
            $pdo->prepare('UPDATE login_attempts SET attempts = attempts + 1, last_attempt = NOW() WHERE ip = ?')->execute([$ip]);
        } else {
            $pdo->prepare('INSERT INTO login_attempts (ip, attempts, last_attempt) VALUES (?, 1, NOW())')->execute([$ip]);
        }
        $errors[] = 'Неверное имя пользователя или пароль.';
    }
}
?>
<form method="post">
    <input name="username">
    <input type="password" name="password">
    <button>Войти</button>
</form>
<?php if (!empty($errors)): ?>
    <p class="fw-bold"><?= implode('<br>', $errors) ?></p>
<?php endif; ?>

Результат работы:

При превышении лимита попыток пользователь видит сообщение об ожидании. После успешного входа записи из login_attempts удаляются.

Пример 3. Использование password_hash с пользовательскими опциями и смена алгоритма

Пример
<?php
// Установка более высокой стоимости (cost) для bcrypt
$options = [
    'cost' => 12, // по умолчанию 10
];
$hash = password_hash('secure_password', PASSWORD_BCRYPT, $options);
echo $hash . "\n";

// Проверка алгоритма хеша
if (password_needs_rehash($hash, PASSWORD_ARGON2ID, ['memory_cost' => 2048, 'time_cost' => 4])) {
    $new_hash = password_hash('secure_password', PASSWORD_ARGON2ID, ['memory_cost' => 2048, 'time_cost' => 4]);
    // обновление хеша в базе
}
?>

Вывод:

$2y$12$... (хеш bcrypt с cost=12).
Если используется Argon2, хеш начинается с $argon2id$.

Пример 4. Авторизация через API (JSON) с JWT

Пример
<?php
// login_api.php
require_once 'vendor/autoload.php';
use Firebase\JWT\JWT;

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$data = json_decode(file_get_contents('php://input'), true);

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$data['username']]);
$user = $stmt->fetch();

if ($user && password_verify($data['password'], $user['password_hash'])) {
    $payload = [
        'sub' => $user['id'],
        'iat' => time(),
        'exp' => time() + 3600
    ];
    $jwt = JWT::encode($payload, 'super_secret_key', 'HS256');
    echo json_encode(['token' => $jwt]);
} else {
    http_response_code(401);
    echo json_encode(['error' => 'Unauthorized']);
}
?>

Ответ клиенту (например, через curl):

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."}

Для проверки токена на защищённых маршрутах используется middleware, декодирующий JWT и устанавливающий пользователя.

Авторизация PHP MySQL - comments

En
авторизация php mysql (php)