Авторизация пользователей в 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 и устанавливающий пользователя.