Простая аутентификация на PHP

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Многие новички до сих пор попадают в тупик при написании простейшей аутентификации в PHP. На Тостере с завидной регулярностью попадаются вопросы о том, как сравнить сохраненный пароль с паролем полученным из формы логина. Здесь будет краткая статья-туториал на эту тему.

Disclaimer: статья рассчитана на совершенных новичков. Умудрённые опытом разработчики ничего нового здесь не найдут, но могут указать на возможные недочёты =).

Для написания системы аутентификации будем использовать базу данных MySQL/MariaDB, PHP, PDO, функции для работы с паролями, для построения интерфейса возьмём bootstrap.

Для начала создадим базу. Пусть она называется php-auth-demo. В новой базе создадим таблицу пользователей users:

CREATE TABLE `users` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `username` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
    `password` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `username` (`username`)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_general_ci;

Создадим конфиг с данными для подключения к базе.

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

config.php

<?php

return [
    'db_name' => 'php-auth-demo',
    'db_host' => '127.0.0.1',
    'db_user' => 'mysql',
    'db_pass' => 'mysql',
];

И сделаем "загрузочный" файл, который будем подключать вначале всех остальных файлов.

В реальных проектах обычно используется автозагрузка необходимых файлов. Но этот момент выходит за рамки статьи, и в демо-примере мы обойдёмся простым подключением.

В "загрузочном" файле мы будем инициализировать сессию и объявим некоторые функции-помощники.

boot.php

<?php

// Инициализируем сессию
session_start();

// Простой способ сделать глобально доступным подключение в БД
function pdo(): PDO
{
    static $pdo;

    if (!$pdo) {
        $config = include __DIR__.'/config.php';
        // Подключение к БД
        $dsn = 'mysql:dbname='.$config['db_name'].';host='.$config['db_host'];
        $pdo = new PDO($dsn, $config['db_user'], $config['db_pass']);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    return $pdo;
}

Функция pdo() даст нам доступ к объекту PDO в любом месте нашего кода.

Далее нам нужна форма регистрации. Разместим её прямо в файле index.php.

<form method="post" action="do_register.php">
  <div class="mb-3">
    <label for="username" class="form-label">Username</label>
    <input type="text" class="form-control" id="username" name="username" required>
  </div>
  <div class="mb-3">
    <label for="password" class="form-label">Password</label>
    <input type="password" class="form-control" id="password" name="password" required>
  </div>
  <button type="submit" class="btn btn-primary">Register</button>
</form>

Здесь всё просто: два поля, кнопка и форма, отправляющая запрос на файл do_register.php методом POST. Процесс регистрации пользователя опишем в файле do_register.php.

<?php

require_once __DIR__.'/boot.php';

// Проверим, не занято ли имя пользователя
$stmt = pdo()->prepare("SELECT * FROM `users` WHERE `username` = :username");
$stmt->execute(['username' => $_POST['username']]);
if ($stmt->rowCount() > 0) {
    flash('Это имя пользователя уже занято.');
    header('Location: /'); // Возврат на форму регистрации
    die; // Остановка выполнения скрипта
}

// Добавим пользователя в базу
$stmt = pdo()->prepare("INSERT INTO `users` (`username`, `password`) VALUES (:username, :password)");
$stmt->execute([
    'username' => $_POST['username'],
    'password' => password_hash($_POST['password'], PASSWORD_DEFAULT),
]);

header('Location: login.php');

В самом начале подключим наш "загрузчик".

Потом проверим, не занято ли имя пользователя. Для этого сделаем выборку из таблицы указав в условии полученное из формы имя пользователя. Обратите внимание, для запросов здесь и далее мы будем использовать подготовленные запросы, что обезопасит нас от SQL-инъекций. Для этого в тексте SQL-запроса мы указываем специальные плейсхолдеры, а при выполнении ассоциируем с ними ненадёжные данные (ненадёжными данными следует считать всё, что приходит из вне – $_GET, $_POST, $_REQUEST, $_COOCKIE). После выполнения запроса мы просто проверим количество возвращённых строк. Если их больше нуля, то имя пользователя уже занято. В этом случае мы выведем сообщение и вернём пользователя на форму регистрации.

Я написал "больше нуля", но по факту, из-за того, что поле username в таблице уникальное, rowCount() может нам вернуть лишь два возможных значения: 0 и 1.

В приведённом выше коде мы использовали функцию flash(). Данная функция предназначена для "одноразовых" сообщений. Если вызвать её со строковым параметром, то она сохранит эту строку в сессии, а если вызвать без параметров, то выведет из сессии сохранённое сообщение и затем удалит его в сессии. Добавим эту функцию в файл boot.php.

function flash(?string $message = null)
{
    if ($message) {
        $_SESSION['flash'] = $message;
    } else {
        if ($_SESSION['flash']) { ?>
          <div class="alert alert-danger mb-3">
              <?=$_SESSION['flash']?>
          </div>
        <?php }
        unset($_SESSION['flash']);
    }
}

А также вызовем её нa форме регистрации, для вывода возможных сообщений.

<h1 class="mb-5">Registration</h1>

<?php flash(); ?>

<form method="post" action="do_register.php">
    <!-- ... -->
</form>

На данном этапе простейший функционал регистрации нового пользователя готов.

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

login.php

<h1 class="mb-5">Login</h1>

<?php flash() ?>

<form method="post" action="do_login.php">
    <div class="mb-3">
        <label for="username" class="form-label">Username</label>
        <input type="text" class="form-control" id="username" name="username" required>
    </div>
    <div class="mb-3">
        <label for="password" class="form-label">Password</label>
        <input type="password" class="form-control" id="password" name="password" required>
    </div>
    <div class="d-flex justify-content-between">
        <button type="submit" class="btn btn-primary">Login</button>
        <a class="btn btn-outline-primary" href="index.php">Register</a>
    </div>
</form>

В виду простоты примера, она практически повторяет форму регистрации. Интереснее будет посмотреть на сам процесс логина в файле do_login.php.

do_login.php

<?php

require_once __DIR__.'/boot.php';

// проверяем наличие пользователя с указанным юзернеймом
$stmt = pdo()->prepare("SELECT * FROM `users` WHERE `username` = :username");
$stmt->execute(['username' => $_POST['username']]);
if (!$stmt->rowCount()) {
    flash('Пользователь с такими данными не зарегистрирован');
    header('Location: login.php');
    die;
}
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// проверяем пароль
if (password_verify($_POST['password'], $user['password'])) {
    // Проверяем, не нужно ли использовать более новый алгоритм
    // или другую алгоритмическую стоимость
    // Например, если вы поменяете опции хеширования
    if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
        $newHash = password_hash($_POST['password'], PASSWORD_DEFAULT);
        $stmt = pdo()->prepare('UPDATE `users` SET `password` = :password WHERE `username` = :username');
        $stmt->execute([
            'username' => $_POST['username'],
            'password' => $newHash,
        ]);
    }
    $_SESSION['user_id'] = $user['id'];
    header('Location: /');
    die;
}

flash('Пароль неверен');
header('Location: login.php');

Здесь есть важный момент. Мы не запрашиваем пользователя из таблицы по паре username/password, а используем только username. Дело в том, что даже если вы захешируете пришедший из формы логина пароль и попробуете сравнить новый хеш с сохранённым в базе, вы ничего не получите. Password_hash() использует автоматически генерируемую соль для паролей и хеши будут всегда получаться разные. Вот результат функции password_hash, вызванной несколько раз для пароля "123":

$2y$10$loqucup11.3DL1fgDWanoettFpFJuFFd0fY6BZyiP698ZqvA4tmuy
$2y$10$.LF3OzmQRtJvuZZWeWF.2u80x3ls6OEAU5J9gLHDtcYrFzJkRRPvq
$2y$10$iGj/nOCavShd2vbMZTC4GOMYCqDj2YSc8qWoeqjVbD1xaKU2CgAfi

Именно поэтому необходимо использовать функцию password_verify для проверки пароля. Кроме того, данная функция использует специальный алгоритм проверки и является безопасной для атак по времени.

Также хорошо будет проверить пароль на необходимость обновления хеша, в случае, если в проекте вы измените алгоритм хеширования или его опции.

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

Для проверки того факта, что пользователь залогинен, нужно будет проверить наличие данного идентификатора в сессии. Мы для удобства напишем функцию-помощник и разместим ее в том же файле boot.php.

function check_auth(): bool
{
    return !!($_SESSION['user_id'] ?? false);
}

Теперь можем добавить проверки и изменить вывод на главной странице, если пользователь аутентифицирован.

<?php
require_once __DIR__.'/boot.php';

$user = null;

if (check_auth()) {
    // Получим данные пользователя по сохранённому идентификатору
    $stmt = pdo()->prepare("SELECT * FROM `users` WHERE `id` = :id");
    $stmt->execute(['id' => $_SESSION['user_id']]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);
}
?>
<?php if ($user) { ?>

    <h1>Welcome back, <?=$user['username']?>!</h1>

    <form class="mt-5" method="post" action="do_logout.php">
        <button type="submit" class="btn btn-primary">Logout</button>
    </form>

<?php } else { ?>

    <h1 class="mb-5">Registration</h1>

    <?php flash(); ?>

    <form method="post" action="do_register.php">
        <!-- ... -->
    </form>

<?php } ?>

А также закрыть доступ к форме логина, если пользователь уже вошёл:

login.php

<?php

require_once __DIR__.'/boot.php';

if (check_auth()) {
    header('Location: /');
    die;
}
?>
<!-- Далее форма логина -->

Осталось добавить возможность "выйти". Форму для выходы вы можете видеть в коде выше. Сама процедура выхода простейшая, и заключается в очистке сессии.

<?php

require_once __DIR__.'/boot.php';

$_SESSION['user_id'] = null;
header('Location: /');

Заключение

Итого:

  • Используем PDO/MySQLi и подготовленные запросы для работы с базой данных.

  • В базе данных обязательно храним только хеш пароля.

  • Для хеширования пароля используем специальную функцию password_hash.

  • Для проверки пароля не делаем сравнение хешей, а используем специальную функцию password_verify.


Полный код примера доступен на гитхабе: https://github.com/delphinpro/php-auth-demo

Источник: https://habr.com/ru/post/665602/


Интересные статьи

Интересные статьи

На текущий момент я работаю в компании Мегафон тим лидом фронта. С начала этого года мы в команде Мегафона разрабатываем собственную платформу Интернета вещей. И так как,...
Возможность интеграции с «1С» — это ключевое преимущество «1С-Битрикс» для всех, кто профессионально занимается продажами в интернете, особенно для масштабных интернет-магазинов.
Существует традиция, долго и дорого разрабатывать интернет-магазин. :-) Лакировать все детали, придумывать, внедрять и полировать «фишечки» и делать это все до открытия магазина.
Тема статьи навеяна результатами наблюдений за методикой создания шаблонов различными разработчиками, чьи проекты попадали мне на поддержку. Порой разобраться в, казалось бы, такой простой сущности ка...
Мы публикуем видео с прошедшего мероприятия. Приятного просмотра.