К содержимому

Авторизация на чистом 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       — функции авторизации
Задача 1 Средняя

Создать таблицу и подключение к базе

Нужны две таблицы: пользователи (email, хэш пароля, имя, дата регистрации) и журнал неудачных попыток входа — по нему в задаче 5 будем ограничивать перебор паролей. Email должен быть уникальным.

Напишите schema.sql и функцию db() в src/db.php. Требования к подключению: одно соединение PDO на весь запрос, ошибки базы — исключениями, кодировка utf8mb4, настоящие подготовленные запросы, а не их эмуляция.

Показать решение Скрыть решение
schema.sql
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)
);
src/db.php
<?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 сменит алгоритм по умолчанию.

Задача 2 Средняя

Безопасно настроить сессию

Настройки сессии по умолчанию небезопасны: cookie доступна из JavaScript и уходит по HTTP.

Показать решение Скрыть решение
src/session.php
<?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();
}
Задача 3 Средняя

Защитить формы от CSRF

Злоумышленник может разместить на своём сайте форму, которая отправит POST на ваш /logout.php или /profile.php от имени залогиненного пользователя. Защитите формы.

Показать решение Скрыть решение

Каждой сессии выдаём случайный токен. Форма отправляет его в скрытом поле, сервер сверяет.

src/csrf.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 сравнивает строки за постоянное время. Обычное === останавливается на первом несовпадающем символе, и по времени ответа теоретически можно подобрать токен.

Задача 4 Средняя

Регистрация

Примите email, имя и пароль, проверьте данные и сохраните пользователя.

Показать решение Скрыть решение
src/auth.php (часть 1)
<?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) с солью.

Задача 5 Средняя

Вход с защитой от перебора

Реализуйте вход. Требования:

  • не сообщать, что именно неверно — email или пароль;
  • после 5 неудачных попыток с одного IP за 15 минут — блокировать;
  • после входа менять ID сессии.
Показать решение Скрыть решение
src/auth.php (часть 2)
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.

public/login.php
<?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>
Задача 6 Средняя

Выход

Напишите public/logout.php: он завершает сессию пользователя, удаляет её cookie и отправляет на страницу входа.

Выход не должен срабатывать без ведома пользователя — например, когда он открыл чужую страницу, где есть ссылка или картинка с адресом logout.php.

Показать решение Скрыть решение

Выход — только через POST с CSRF-токеном. Иначе любой сайт сможет «разлогинить» пользователя картинкой <img src="https://ваш-сайт/logout.php">.

public/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');
Задача 7 Средняя

«Запомнить меня»

Сессия заканчивается при закрытии браузера. Сделайте так, чтобы пользователь оставался в системе 30 дней.

Показать решение Скрыть решение

Наивный способ — положить в cookie user_id — позволяет войти под любым пользователем. Правильная схема — selector + validator:

  • selector — открытый идентификатор записи в БД;
  • validator — секрет; в БД хранится только его хэш.

Даже если база утечёт, по хэшам войти нельзя.

schema.sql (добавить)
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
);
src/auth.php (часть 3)
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.