Авторизация на чистом PHP: регистрация, вход, сессии и защита
Во фреймворках авторизация есть «из коробки». Но чтобы понимать, что происходит внутри Laravel или Symfony, полезно один раз написать её руками. Заодно разберём ошибки, из-за которых самописные авторизации взламывают.
Что получится в итоге:
public/
├── register.php — регистрация
├── login.php — вход (HTML-форма и JSON-ответ для JS)
├── logout.php — выход
└── profile.php — закрытая страница
src/
├── db.php — подключение PDO
├── session.php — настройка сессии
├── csrf.php — CSRF-токены
└── auth.php — функции авторизации
Создать таблицу и подключение к базе
Нужны две таблицы: пользователи (email, хэш пароля, имя, дата регистрации) и журнал неудачных попыток входа — по нему в задаче 5 будем ограничивать перебор паролей. Email должен быть уникальным.
Напишите schema.sql и функцию db() в src/db.php. Требования к подключению: одно соединение PDO на весь запрос, ошибки базы — исключениями, кодировка utf8mb4, настоящие подготовленные запросы, а не их эмуляция.
Показать решение Скрыть решение
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE login_attempts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45) NOT NULL,
email VARCHAR(255) NOT NULL,
attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (ip, attempted_at)
);
<?php
declare(strict_types=1);
function db(): PDO
{
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
'app',
'secret',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return $pdo;
}
password_hash хранится в VARCHAR(255): длина хэша зависит от алгоритма и может вырасти, когда PHP сменит алгоритм по умолчанию.
Безопасно настроить сессию
Настройки сессии по умолчанию небезопасны: cookie доступна из JavaScript и уходит по HTTP.
Показать решение Скрыть решение
<?php
declare(strict_types=1);
function start_session(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_set_cookie_params([
'lifetime' => 0, // до закрытия браузера
'path' => '/',
'secure' => isset($_SERVER['HTTPS']), // только по HTTPS (на проде — всегда true)
'httponly' => true, // недоступна из JS — защита от кражи через XSS
'samesite' => 'Lax', // не уходит с POST-запросами с чужих сайтов
]);
ini_set('session.use_strict_mode', '1'); // не принимать чужие ID сессий
session_start();
}
Защитить формы от CSRF
Злоумышленник может разместить на своём сайте форму, которая отправит POST на ваш /logout.php или /profile.php от имени залогиненного пользователя. Защитите формы.
Показать решение Скрыть решение
Каждой сессии выдаём случайный токен. Форма отправляет его в скрытом поле, сервер сверяет.
<?php
declare(strict_types=1);
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_token" value="' . csrf_token() . '">';
}
function csrf_verify(): void
{
$sent = $_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!is_string($sent) || !hash_equals(csrf_token(), $sent)) {
http_response_code(419);
exit('Срок действия страницы истёк. Обновите её и попробуйте снова.');
}
}
hash_equals сравнивает строки за постоянное время. Обычное === останавливается на первом несовпадающем символе, и по времени ответа теоретически можно подобрать токен.
Регистрация
Примите email, имя и пароль, проверьте данные и сохраните пользователя.
Показать решение Скрыть решение
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
/** @return array<string, string> ошибки по полям */
function register_user(string $email, string $name, string $password): array
{
$errors = [];
$email = mb_strtolower(trim($email));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Некорректный email';
}
if (mb_strlen(trim($name)) < 2) {
$errors['name'] = 'Имя слишком короткое';
}
if (mb_strlen($password) < 8) {
$errors['password'] = 'Пароль должен быть не короче 8 символов';
}
if ($errors) {
return $errors;
}
$stmt = db()->prepare('SELECT 1 FROM users WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetchColumn()) {
return ['email' => 'Этот email уже зарегистрирован'];
}
db()->prepare('INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?)')
->execute([$email, trim($name), password_hash($password, PASSWORD_DEFAULT)]);
return [];
}
Никогда не используйте md5 или sha1 для паролей. Они слишком быстрые: видеокарта перебирает миллиарды вариантов в секунду. password_hash использует медленный алгоритм (bcrypt или argon2) с солью.
Вход с защитой от перебора
Реализуйте вход. Требования:
- не сообщать, что именно неверно — email или пароль;
- после 5 неудачных попыток с одного IP за 15 минут — блокировать;
- после входа менять ID сессии.
Показать решение Скрыть решение
const MAX_ATTEMPTS = 5;
const LOCK_MINUTES = 15;
function too_many_attempts(string $ip): bool
{
$stmt = db()->prepare(
'SELECT COUNT(*) FROM login_attempts
WHERE ip = ? AND attempted_at > NOW() - INTERVAL ? MINUTE'
);
$stmt->execute([$ip, LOCK_MINUTES]);
return (int) $stmt->fetchColumn() >= MAX_ATTEMPTS;
}
function attempt_login(string $email, string $password, string $ip): bool
{
$email = mb_strtolower(trim($email));
$stmt = db()->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
db()->prepare('INSERT INTO login_attempts (ip, email) VALUES (?, ?)')
->execute([$ip, $email]);
return false;
}
// Алгоритм хэширования по умолчанию мог обновиться — перехэшируем
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
db()->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
}
db()->prepare('DELETE FROM login_attempts WHERE ip = ?')->execute([$ip]);
session_regenerate_id(true); // защита от фиксации сессии
$_SESSION['user_id'] = (int) $user['id'];
return true;
}
function current_user(): ?array
{
if (empty($_SESSION['user_id'])) {
return null;
}
$stmt = db()->prepare('SELECT id, email, name FROM users WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
return $stmt->fetch() ?: null;
}
function require_auth(): array
{
$user = current_user();
if (!$user) {
header('Location: /login.php?redirect=' . urlencode($_SERVER['REQUEST_URI']));
exit;
}
return $user;
}
Обработчик входа отвечает и обычной форме, и JavaScript (статья «Авторизационное окно на JS»): если клиент прислал Accept: application/json, возвращаем JSON.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/session.php';
require_once __DIR__ . '/../src/csrf.php';
require_once __DIR__ . '/../src/auth.php';
start_session();
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$ip = $_SERVER['REMOTE_ADDR'];
if (too_many_attempts($ip)) {
$error = 'Слишком много попыток. Попробуйте через ' . LOCK_MINUTES . ' минут.';
$status = 429;
} elseif (attempt_login($_POST['email'] ?? '', $_POST['password'] ?? '', $ip)) {
// Редирект только на локальный путь — защита от open redirect
$redirect = $_POST['redirect'] ?? '/profile.php';
if (!str_starts_with($redirect, '/') || str_starts_with($redirect, '//')) {
$redirect = '/profile.php';
}
if ($wantsJson) {
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'redirect' => $redirect]);
exit;
}
header('Location: ' . $redirect);
exit;
} else {
$error = 'Неверный email или пароль';
$status = 422;
}
if ($wantsJson) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'message' => $error], JSON_UNESCAPED_UNICODE);
exit;
}
}
?>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="redirect"
value="<?= htmlspecialchars($_GET['redirect'] ?? '/profile.php') ?>">
<?php if ($error): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<input type="email" name="email" required placeholder="Email">
<input type="password" name="password" required placeholder="Пароль">
<button type="submit">Войти</button>
</form>
Выход
Напишите public/logout.php: он завершает сессию пользователя, удаляет её cookie и отправляет на страницу входа.
Выход не должен срабатывать без ведома пользователя — например, когда он открыл чужую страницу, где есть ссылка или картинка с адресом logout.php.
Показать решение Скрыть решение
Выход — только через POST с CSRF-токеном. Иначе любой сайт сможет «разлогинить» пользователя картинкой <img src="https://ваш-сайт/logout.php">.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/session.php';
require_once __DIR__ . '/../src/csrf.php';
start_session();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
csrf_verify();
$_SESSION = [];
setcookie(session_name(), '', ['expires' => time() - 3600, 'path' => '/']);
session_destroy();
header('Location: /login.php');
«Запомнить меня»
Сессия заканчивается при закрытии браузера. Сделайте так, чтобы пользователь оставался в системе 30 дней.
Показать решение Скрыть решение
Наивный способ — положить в cookie user_id — позволяет войти под любым пользователем. Правильная схема — selector + validator:
selector— открытый идентификатор записи в БД;validator— секрет; в БД хранится только его хэш.
Даже если база утечёт, по хэшам войти нельзя.
CREATE TABLE remember_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
selector CHAR(24) NOT NULL UNIQUE,
validator_hash CHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
function remember_user(int $userId): void
{
$selector = bin2hex(random_bytes(12));
$validator = bin2hex(random_bytes(32));
db()->prepare(
'INSERT INTO remember_tokens (user_id, selector, validator_hash, expires_at)
VALUES (?, ?, ?, NOW() + INTERVAL 30 DAY)'
)->execute([$userId, $selector, hash('sha256', $validator)]);
setcookie('remember', "$selector:$validator", [
'expires' => time() + 60 * 60 * 24 * 30,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
function login_from_cookie(): void
{
if (!empty($_SESSION['user_id']) || empty($_COOKIE['remember'])) {
return;
}
[$selector, $validator] = explode(':', $_COOKIE['remember'], 2) + ['', ''];
$stmt = db()->prepare(
'SELECT id, user_id, validator_hash FROM remember_tokens
WHERE selector = ? AND expires_at > NOW()'
);
$stmt->execute([$selector]);
$token = $stmt->fetch();
if ($token && hash_equals($token['validator_hash'], hash('sha256', $validator))) {
// Одноразовый токен: удаляем и выдаём новый
db()->prepare('DELETE FROM remember_tokens WHERE id = ?')->execute([$token['id']]);
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $token['user_id'];
remember_user((int) $token['user_id']);
}
}
Вызывайте login_from_cookie() сразу после start_session() на каждой странице. При выходе удаляйте токен из БД и cookie.
Чек-лист безопасности
- Пароли — только
password_hash/password_verify. - Все SQL-запросы — подготовленные.
- Весь вывод пользовательских данных — через
htmlspecialchars. - Cookie сессии:
httponly,secure,samesite. -
session_regenerate_id(true)после входа. - CSRF-токен на всех POST-формах, включая выход.
- Ограничение числа попыток входа.
- Редирект после входа — только на локальные пути.
- Сайт работает только по HTTPS.