Многие новички до сих пор попадают в тупик при написании простейшей аутентификации в 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, $_COOKIE). После выполнения запроса мы просто проверим количество возвращённых строк. Если их больше нуля, то имя пользователя уже занято. В этом случае мы выведем сообщение и вернём пользователя на форму регистрации.

Я написал "больше нуля", но по факту, из-за того, что поле 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, <?=htmlspecialchars($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.


Полный код примера доступен на гитхабе: ссылка на Github.

Комментарии (77)


  1. dimas846
    13.05.2022 16:23
    +3

    Есть замечания по коду:

    • check_auth() лучше переименовать в is_auth()

    • к типам лучше приводить явно, например через (bool) а не через двойное отрицание !!($_SESSION['user_id'] ?? false)


    1. MyraJKee
      13.05.2022 17:57

      В чем проблема с двойным отрицанием?)


      1. delphinpro Автор
        13.05.2022 17:59
        +1

        Проблемы нет. Эти претензии больше к стилистике кода.


        1. MyraJKee
          13.05.2022 18:12
          +2

          Тогда например для функции flash добавить возвращаемое void?)


      1. dimas846
        13.05.2022 19:50
        +2

        Проблема в том, что это использование инструмента не по назначению. Вам нужно не само отрицание, а лишь его побочный эффект.


        1. MyraJKee
          13.05.2022 20:10
          +1

          Зато короткая и понятная запись


          1. sden77
            14.05.2022 00:30
            +6

            Явное приведение типов понятнее


          1. pOmelchenko
            14.05.2022 17:23
            +3

            Еще более короткая и еще более понятная запись была бы return isset($_SESSION['user_id']);


  1. FanatPHP
    13.05.2022 19:16
    +10

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


    У меня тоже будет несколько мелких замечаний.


    1. Во-первых, я бы не стал приучать новичков к статической магии. Потом всё равно придется отучаться. Тем более что в таком простом примере она и не нужна, область видимости везде одна и та же. Вполне можно обойтись обычной переменной $pdo.
    2. Выводить конфиг за пределы сервера не обязательно, а вот что не помешает — это положить его копию в гит, добавив при этом простой код, чтобы на новом сервере легко можно было запуститься


      if (!file_exists('config.php'))
      {
          $msg = 'Создайте и настройте config.php на основе config.sample.php';
          trigger_error($msg,E_USER_ERROR);
      }

    3. Вместо rowCount() я предпочитаю использовать fetch(). Это конечно вкусовщина, но так, все-таки, чуть более универсально — далеко не все базы данных возвращают эту бессмысленную цыферку.
    4. Если пользователь с таким логином не найден, то обращение к $user['password'] выдаст ошибку. Я бы добавил полученный массив в условие, if ($user && password_verify($_POST['password'], $user['password'])) {
    5. Использование функции flash() для валидации данных представляется мне спорным решением. Все-таки, более традиционным подходом является раздельное информирование об ошибках для каждого поля ввода, а с flash() этого не получится.
    6. Я согласен с тем что кастинг двойным отрицанием выглядит анахронизмом. Учитывая, что при этом все равно происходит неявное преобразование int->bool, то я бы всё-таки делал явное, стандартными средствами.
    7. Обновление таблицы по username представляется мне спорным. Ну то есть понятно, что будет работать, но мне кажется что обращение по первичному ключу должно быть просто на автомате.
    8. Я бы чуть больше внимания уделил разделению логики приложения и логики отображения, пусть даже с использованием пресловутых header.php и footer.php. А то ж новички, как только начнут внедрять, тут же налетят на Headers already sent.


    1. delphinpro Автор
      13.05.2022 20:29
      +1

      1. Я так понимаю речь о функции pdo(). Тут я с вами полностью согласен, но цель статьи, не научить новичка всё делать правильно, а показать, как правильно делать аутентификацию.

      4. Тут я не очень понял. Обращение в данному ключу массива в моём примере идет только в одном месте, и то, только после проверки на существование пользователя.

      5. Это не валидация данных. Валидации я вообще не уделил внимание в этой статье. Это просто вспомогательная функция для вывода сообщений.
      Её можно расширить, сделать ключ 'flash' массивом, и писать туда сколько угодно сообщений. Я так и хотел сделать. Но в этом простом примере не было возможности продемонстрировать несколько сообщений об ошибках.

      6. Двойное отрицание, равно как и именование функции check_auth() – это следствие проф. уклона в моей работе. Первое – я много работаю с джаваскрипт, и там двойное отрицание для приведения к булю не порицается, второе – это из ларки, там аналогичный метод называется Auth::check(). По большому счету – вкусовщина.

      8. Про разделение логики и представления я может быть черкану отдельную статью, если меня сильно заденет данная тема на тостере =). Здесь это явно выходит за границы поднятой темы.

      В целом все замечания дельные. Новичкам стоит обратить на них внимание.


      1. FanatPHP
        13.05.2022 21:12
        +1

        По 4. да, это я зевнул, извиняюсь.
        По 1. всетаки непонятно, без pdo() обучение аутентификацию как раз и будет проще. Но в прочем это мелочи.


    1. olku
      14.05.2022 16:20

      А мне вот static понравился. Самая простая реализация сервиса без лапши классического синглтона с getInstance.


      1. FanatPHP
        14.05.2022 16:30

        Синглтон был "классическим" 20 лет назад. И сейчас уже совсем не в моде. Вся индустрия давно уже ушла от всей этой магии "ресурсов, берущихся ниоткуда" в сторону явного объявления всех зависимостей.


        1. olku
          14.05.2022 16:38

          Не понял про зависимости и моду. Экземпляр сервиса в DI все равно синглтон.


          1. franzose
            14.05.2022 18:09
            +2

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


          1. FanatPHP
            14.05.2022 19:19
            +1

            Разница принципиальная. Классический "синглтон с getInstance" берется из воздуха. Те же global, только в профиль. Глобальная зависимость, связность уровня "прибито гвоздями".
            "Синглтон" в DI явно прописан и явно передается в параметрах, можно четко видеть откуда ноги растут, подменить, замокать и так далее.


          1. SerafimArts
            15.05.2022 04:13
            +2

            А ещё, помимо того что написал FanatPHP — синглтон может не являться таковым ну вообще. Это лишь указание на то какое поведение требуется в рамках контекста.


            В частности, если мы говорим об объекте Connection, который вроде как является синглтоном в рамках какого-то обработчика (контроллера, например) — он может быть вполне себе получаться из фекори, которое зависит от объекта контекста обработки (например реквеста или сессии пользователя): $pool->getConnection($request), что позволяет обрабатывать несколько запросов одновременно изолируя транзакции одного пользователя от другого.


            Однако в рамках отдельной "сессии" такие объекты продолжат быть синглтонами.


            Так что синглтон в рамках DI-контейнера и синглтон в качестве архитектурного паттерна — разные вещи.


            P.S. И это если ещё не упоминать про "ленивые" синглтоны, которые хранятся в WeakMap и могут зависеть от объекта, который пока что лежит в памяти: Например, аутентификация пользователя, которая зависит от объекта сессии, которая зависит от объекта реквеста. Как только реквест исчезает из области видимости (т.е. отправлен респонз) — все зависимости в рамках контекста каскадно удаляются.


  1. Rsa97
    13.05.2022 19:46
    +1

    Маленькое замечание. В продакшине при добавлении пользователя таблицу стоит блокировать во избежание состояния гонки.
    Поток A: SELECT… WHERE username = 'vasya'
    Поток B: SELECT… WHERE username = 'vasya'
    Поток A: Имя не найдено, INSERT… username = 'vasya'
    Поток B: Имя не найдено, INSERT… username = 'vasya'
    И либо в потоке B ошибка, либо в таблице два Васи.


    1. delphinpro Автор
      13.05.2022 20:13
      +1

      Мне кажется, это выходит за рамки статьи.


    1. TheAndrey7
      13.05.2022 20:31
      +2

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


    1. FanatPHP
      13.05.2022 21:08

      Вот кстати да, причем можно даже не блокировать, а просто ловить исключение при добавлении


      1. delphinpro Автор
        13.05.2022 21:14
        +3

        Там никак не будет два Васи. Username уникален. БД не позволит. Будет выброшено исключение.


        1. FanatPHP
          14.05.2022 09:08

          Ну вот кстати здесь соглашусь. Вероятность такого совпадения стремится к нулю, а в том гипотетическом случае, когда двое Серёж все-таки столкнутся лбами, второму просто выпадет стандартная 501 ошибка, он обновит страницу и получит свою ошибку о том что имя занято.


  1. WFF
    13.05.2022 20:53

    1. Backend дает возможность зарегистрироваться с пустым именем и паролем (да, я видел required, это ничего не значит).

    2. Наверное, имеет смысл при хранении приводить имя пользователя к одному регистру.


    1. delphinpro Автор
      13.05.2022 21:01
      +1

      Вы абсолютно правы. Я совершенно не уделил внимания вопросу валидации данных.
      Но по второму пункту я не согласен. Возможно для большинства пользователей не имеет значения написали они «Вася» или «вася». Но эта проблема, я считаю, лежит не в компетенции программиста. Тут всё будет решать ТЗ. Если бизнесу не нужно различать такие мелочи, то нужно добавить приведение к общему регистру, если нет, то нет. Вопрос спорный, и непосредственно к программированию отношения мало имеет.


      1. WFF
        13.05.2022 21:13

        Ок.

        1. У вас в качестве имени пользователя может быть кусок javascript-а. Это уже "дыра в безопасности".


        1. delphinpro Автор
          13.05.2022 21:35

          Ok. Как это помешает пользователю зарегистрироваться, или залогиниться?


          1. WFF
            13.05.2022 21:40

            Смотрите, скорее всего где-то внутри вашей системы (предположительно) есть админка, куда имеют доступ только администраторы. Предположительно, там есть модуль для просмотра зарегистрированных пользователей. Таким образом я могу украсть сессионные коды администратора просто потому, что администратор решил посмотреть список зарегистрированных пользователей.


            1. delphinpro Автор
              13.05.2022 21:49

              Справедливо. Но давайте вспомним о том, что это обучающий материал по аутентификации, а не обо всём сопутствующем. Просто тогда бы текста было бы намного больше.
              Пока вы стажер – никто вам не даст пилить админку, или какие-то серьёзные вещи.
              А если вы хотя бы миддл, то либо вы используете в проекте какой-то шаблонизатор, который по умолчанию экранирует весь вывод, либо самостоятельно это делаете в каждом месте. В результате ваше имя пользователя, содержащее javascript код просто будет выведено текстом.


              1. WFF
                13.05.2022 21:55

                Простите, но ваш код в таком виде опасен. Его нельзя публиковать в таком виде. Добавьте хотя-бы strip_tags к имени пользователя. В противном случае можно получить проблемы, просто выполняя SQL запрос в phpMyAdmin.


                1. delphinpro Автор
                  13.05.2022 21:57

                  Почему же strip_tags, а не htmlspecialchars?
                  И где конкретно – на входе или на выходе?


                  1. WFF
                    13.05.2022 22:05

                    Если выбирать между этими двумя, я бы использовал strip_tags. Так как результат htmlspecialchars можно случайно скопировать и вставить куда-то, где нет экранирования.


                    1. delphinpro Автор
                      13.05.2022 22:13

                      Я переформулирую вопрос. Где вы предлагаете резать теги -- перед сохранением в бд, или перед выводом на страницу.

                      Спойлер: Я уже ответил на оба варианта ответа.


                      1. WFF
                        13.05.2022 22:15
                        -2

                        Я считаю, что нельзя хранить в базе уязвимости. Поэтому перед вставкой в базу.


                      1. delphinpro Автор
                        13.05.2022 22:24

                        Вам фанат ниже написал. Это не уязвимость сама по себе.

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

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


                      1. WFF
                        13.05.2022 22:28
                        -2

                        Это просто классическая без изысков XSS уязвимость


                      1. delphinpro Автор
                        13.05.2022 22:38

                        xss здесь только в одном месте - index.php, там где выводится имя пользователя, после welcome back. Учитывая, что доступ к этой странице имеет только сам пользователь, ценность этой уязвимости стремится к нулю.


                      1. FanatPHP
                        13.05.2022 22:50
                        +2

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


                      1. delphinpro Автор
                        13.05.2022 23:04

                        Ваши ответы всегда внушают мне уважение, но сейчас либо вы во второй раз невнимательны, либо я чего то не понимаю. После авторизации на эту страницу может может попасть только сам пользователь. Как он может навредить другим?

                        Я не показывал общедоступную страницу /users. Где могли бы выводится имена всех пользователей, и где можно было бы воспользоваться этой уязвимостью.

                        В любом случае я благодарен пользователю @WFF за поднятую тему. Эти комментарии будут полезны начинающим разработчикам


                      1. FanatPHP
                        13.05.2022 23:11
                        +2

                        Это неважно, кто куда попадает. Я выше пользователю WFF как раз и говорил о том, что эти мелочи вообще не должны нас волновать. Должно быть простое правило, которые всегда выполняется. Как в том анекдоте, "Рот есть? значит берет". Выводим? Значит экранируем. Правило простое, как валенок. А от всех этих рассусоливаний "тут защищаем, тут не защищаем", "ну тут в данном уникальном случае мы скорее всего не пострадаем" как раз все уязвимости и появляются.


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


                1. FanatPHP
                  13.05.2022 21:57
                  +1

                  Добавьте хотя-бы strip_tags к имени пользователя. В противном случае можно получить проблемы, просто выполняя SQL запрос в phpMyAdmin.

                  Извините, но это уже совсем странно. Какая связь между strip_tags и SQL запросами в phpMyAdmin?


                  1. WFF
                    13.05.2022 22:03

                    Да, перебор, там все экранируется


        1. FanatPHP
          13.05.2022 21:56
          +1

          Ну нет, здесь я не соглашусь. Валидация важна, но не она отвечает за безопасность.
          С таким же успехом можно сказать, что в качестве имени может быть кусок SQL. Но это никого не парит, потому что мы правильно работаем с SQL в своем коде.
          И точно так же, если мы будем правильно работать в своем коде с HTML, то никакой дыры тоже не будет.
          И сессионные коды никто не украдёт, потому что при формировании списка пользователей в виде HTML, этот самый HTML будет экранироваться.


          1. WFF
            13.05.2022 22:09

            Это мы с вами сейчас помним, что имя пользователя надо экранировать. Но для нового разработчика это не очевидно. Т.е., вообще говоря, проблема возникнет практически гарантированно.


            1. FanatPHP
              13.05.2022 22:30
              +4

              Имя пользователя специально не надо экранировать. И помнить ничего не надо.
              Просто экранировать надо вообще всё.


              Ещё раз: отсутствие валидации — это не уязвимость.
              Уязвимость — это отсутствие экранирования на выходе.
              Валидация не должна использоваться для обеспечения безопасности. потому что невозможно придумать валидацию на все случаи жизни.


              Поймите, хотя ваши идеи и могут вам казаться очень правильными, но вся индустрия уже много лет как ушла в совсем другом направлении, безопасность гарантируется на выходе, а не на входе.
              И пример с SQL это очень хорошо доказывает: точно так же, как "там все экранируется" в SQL, всё должно экранироваться и в HTML.


              Добавлю, что я не топлю против валидации. Валидация очень нужна и важна. Сделали? Молодцы. Не сделали? Не страшно — за безопасность отвечают другие механизмы.


              1. WFF
                13.05.2022 22:44

                Вы мне зачем-то навязываете спор о точке, где мы сделаем все безопасным. Я же вам говорю, что практика хранить в базе вредоносный код означает, что у вас появляется далеко не нулевая вероятность, что:
                1. он будет выполнен;
                2. вы об этом узнаете слишком поздно.


                1. FanatPHP
                  13.05.2022 22:51

                  Ладно, не буду спорить. В веб-разработке слишком много мифов и заблуждений, всех не переубедишь.


                1. delphinpro Автор
                  13.05.2022 23:18
                  +5

                  А как вы определяете, что код вредоносный?

                  Здесь, на Хабре, например, можно постить куски кода

                  Но если экранировать их на выходе, проблемы не будет. И наоборот, если их прогнать через strip_tags на входе, то на выходе получится шняга.


  1. sasmoney
    13.05.2022 21:41

    Ещё проще можно сделать через отдельный обработчик и js, вместо всяких форм


  1. delphinpro Автор
    13.05.2022 22:39

    Ребята, давайте релевантные комментарии писать. И хорошо бы грамотные. Ну просто xss к затронутой теме не относится, а уж аргументация вообще уровня пре-джуниора


    1. FanatPHP
      13.05.2022 23:01
      +2

      Не нужно ограничивать комментаторов :)
      Это всё равно не работает. На комментарий, который кажется нерелевантным, можно просто не отвечать ;)


      А xss, хотя и не относится к теме, но тем не менее относится к общему эффекту от статьи. Любое руководство не должно одной рукой лечить, а другой калечить. Точно так же можно было наплевать и на SQL инъекции — ведь тема-то статьи не работа с SQL, а авторизация!
      Вся беда плохих руководств как раз в этой отмазке "уровня пре-джуниора": "я совсем про другое писал!". Мне это сто раз в лицо кидали, когда я говорил что код уязвим к SQL инъекциям.


      Экранирование вывода должно делаться на уровне условного рефлекса. И в первую очередь у писателя статьи.


      1. delphinpro Автор
        13.05.2022 23:55

        Вот именно поэтому комментарии на Хабре особо ценны, и являются обязательными к прочтению.


        1. FanatPHP
          14.05.2022 07:17
          +3

          Не только "к прочтению".
          Я вот не понимаю эту позицию, "указать на недочеты вы можете, но я все равно положу на них с прибором".
          Для чего пишется этот туториал? Для удовлетворения собственного эго? Чтобы повисел недельку? Нет? чтобы люди приходили и учились? Ну так зачем учить их плохому-то? И главное, в чем причина такой принципиальной позиции, "сдохну, но не исправлю ни буквы в тексте"? По большинству спорных вопросов никто и не настаивает, но реальный косяк надо взять и исправить, а не писать 100500 нелепых оправданий "я не то имел в виду" и "я вообще про другое".


  1. nefone
    13.05.2022 23:15
    +2

    А я думал на Хабре за такие статьи тухлыми помидорками закидают

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


    1. delphinpro Автор
      14.05.2022 00:26
      +1

      Вам не повезло.

      А если серьёзно, то если прямо сейчас отрыть поисковик и сделать запрос "авторизация на php", то на первой странице выдаче будет инфа 10-летней давности с sqlинъекциями и хеширование md5. Вы правда думаете, что это лучшая информация?


      1. nefone
        14.05.2022 07:16

        Я не думаю, что то что есть сейчас в поиске, что это лучшая информация.

        Скажу больше, даже некоторые, на курсах по PHP за которые беруг деньги, умудряются в 2022 году учить, что пароль надо хранить в md5 + соль.


      1. Andreyika
        14.05.2022 08:13

        Наверное так происходит потому, что реализовывать авторизацию никому не нужно?
        Одним не нужно потому, что она уже есть в различных cms и фрейморках, другим - потому, что они совсем уж начинающие?

        Но допустим я хочу реализовать авторизацию, первый раз увидев пхп.
        Подскажите, насколько важно, чтобы у формы у дочерних дивов класс был mb-3? насколько упадет безопасность при использовании mb-4?

        И в целом то понятно, что если из статьи весь не имеющий отношения к проверке пароля, то статья по объему превратится в ответ на вопрос на тостере, только без вопроса, но в текущем виде все это похоже на реферат по информатике от девятиклассника из 2009 года, найденный и исправленный девятиклассником в 2022ом (заменой функции проверки пароля с md5()===password на password_verify)


        1. FanatPHP
          14.05.2022 08:25

          Ну всё-таки, такие рефераты нужны. Потому что на одного девятиклассника, способного написать такой реферат, приходится сто неспособных даже на такое. Кроме password_verify и password_needs_rehash здесь нормальная обработка ошибок и защита от SQL инъекций. Это уже большой шаг вперёд по сравнению с тем, что мы обычно видим в похапе.


          Придирка к mb-3 совсем уж левая. Да, в статье много лишнего, но поставить автору в вину использование бутстрапа — это уж совсем из серии "докопаться до столба". На тостере никогда не пишут законченный вариант — только по отдельности, регистрацию там или авторизацию. Полный гайд от и до всё равно нужен.


          1. Andreyika
            14.05.2022 09:57

            "Вчера" это реферат, сегодня оно на хабре, "завтра" оно - основной доклад на phpconf2025. Мне вроде бы и чужого места на диске и чужого времени на прочтение данной статьи не жалко, удивило кол-во комментариев. Если бы не это - просто бы прошел мимо.

            Придирка к mb-3 совсем уж левая.

            Про mb-3 это не про использование бутстапа, а про не имеющий отношения к сути проблемы код и текст.
            Если рассматривать эту писанину как статью, то возникает вопрос каждому абзацу - а зачем оно тут?
            Зачем тут SQL со структурой базы/таблицы? Она какая-то сверхоптимальная? В ней учтены какие-то возможные ошибки, которые совершают новички?
            А может быть доступ к конфигу include config.php какой-то эталон безопасности или сам представленный конфиг как-то сильно влияет на авторизацию (она же аутентификация, ну да не суть)?
            На сколько прибавится баллов к безопасности, если обернуть pdo в функцию?
            Может быть использование pdo+ps вместо mysqli как-то увеличит чего-то там? Пропадут потенциальные SQL иньекции? А авторизация тут причем?
            Зачем тут шаблоны, верстка, бутстрап и прочее и прочее?

            Не хватает еще двух абзацев - как зарегить бесплатный хостинг и загрузить файлы по ftp

            Но если отпилить все ненужное, то статьи просто по кол-ву символов не получится, вот и долили воды до реферата.

            В итоге получается, что это не статья про "аутентификацию на пхп", а готовый продукт - хоть сейчас в zip архив и публиковать в pear. Либо даже почти готовая книга "самоучитель пхп+бутстрап", судя по кол-ву подробно освещенных тем.


            1. FanatPHP
              14.05.2022 10:07

              Хотя я и согласен по поводу большинства претензий, я не считаю их принципиальными.
              А количество комментариев как раз-таки и говорит об актуальности темы (:


            1. delphinpro Автор
              14.05.2022 13:16

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

              Выше вы совершенно верно подметили, - это и есть ответ на вопрос на тостере, расширенный до формата небольшой статьи.


    1. FanatPHP
      14.05.2022 06:41

      А я думал, что когда закидывают тухлыми помидорами, то пишут конкретно, что не так ;)


      Каким образом эта авторизация подобная и как через неё можно почистить объявления других пользователей?


      1. nefone
        14.05.2022 07:05

        <?php

        if (check_auth()) {
        deleteItem($id);
        }
        ?>

        Где $id передается через $_GET или $_POST и банальной заменой на другое число будет успешно удалять объявления других пользователей.


        1. FanatPHP
          14.05.2022 07:24
          +1

          Но ведь в коде нет никакого deleteItem($id); Мне кажется, эта претензия притянута за уши. Проверка прав доступа к записи — это авторизация, а не аутентификауция.


  1. FanatPHP
    14.05.2022 08:14
    +3

    И кстати,


    а при выполнении ассоциируем с ними ненадёжные данные (ненадёжными данными следует считать всё, что приходит из вне – $_GET, $_POST, $_REQUEST, $_COOKIE).

    — это дичайшая ересь. У которой ноги растут как раз из вот этого "я не про это писал". Так что XSS здесь не случайность, а закономерность.


    Потому что вместо беспрекословного следования простым правилам безопасности, "Отправляем данные в SQL? Через плейсхолдер. Отправляем данные в HTML? Через экранирование" начинаем учёный совет с ковырянием в носу: "Тээкс, эти данные у нас откуда? "Извне"? Ненадёжненькие, защищаем. А эти откуда? Из базы данных? Ну это ж свои родимые, пихаем как есть (Pwned!)".


    Мало того что такие рассуждения сами по себе не имеют смысла — датабазе абсолютно фиолетово, откуда пришли данные — ей важно, чтобы они не поломали запрос. А из какого "источника" они пришли — её вообще никак не интересует, от слова "совсем". Но главное — именно такие глубокомысленные рассуждения об источниках и приводят в итоге к инъекциям. Потому что даже писатели, мнящие себя гуру, не в состоянии внятно сформулировать, какой источник является "опасным", а уж про новичков и говорить-то нечего (этот хоть спросить догадался, а сколько таких не спросило?). И в итоге именно этот, дифференцированный подход к защите, "здесь защищаем, здесь не защищаем, здесь селёдку заворачиваем" — и является причиной большинства уязвимостей.


    В общем я уже жалею обо всех плюсах, которые поставил в этой дискуссии — и из-за косяков статьи, но главное — из-за отношения автора к ним.


  1. Vottakonotak
    14.05.2022 08:38

    Спасибо за статью. Я как раз новичок в этом деле и мне интересно всё что вы обсуждали. Я учусь сам и поэтому может быть не прав. Я сделал чистку ввода до отправки и после уже перед вставкой в базу данных. Теперь никакой js код не проникнит, но вот php код пройдёт. Нужно ли очистить ввод от $ или фрагменты php кода добавленые в get и post запросах просто вызовут ошибку и не смогут навредить сайту?


    1. FanatPHP
      14.05.2022 08:54

      Какую ошибку?


      По поводу чистки, здесь уже обсуждали, что к безопасности она не имеет отношения. Для безопасности данные надо не "чистить", а форматировать, и не "до отправки", а перед использованием.


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


  1. kostin
    14.05.2022 10:01

    Спасибо. Актуальные статьи для новичков по типовым практическим задачам - это очень полезная штука.

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

    Надеюсь, что продолжите писать подобное.


  1. fomiash
    14.05.2022 12:42
    +1

    Мне тоже показалось простым, когда решил сделать свою самую первую авторизацию, и решив сделать самый минимум, все же зашел намного дальше. Сейчас тем более пользователи привыкли ко многим мелочам авторизации, без которых можно вызвать их возмущение. Как например:

    1) Восстановление пароля (возможно по email или номеру телефона)

    2) Опция "запомнить меня" на данном устройстве (через кукисы)

    3) Предотвращение брутфорса (капча плюс другие методы), это может вызвать недовольство пользователей только при взломе, в остальном они против.

    4) Подтвержение email (или номера телефона)

    5) Возможность пользователю выйти, а также выйти на всех устройствах.


  1. doomguy49
    14.05.2022 17:13
    -1

    А чем плох md5 с солью, как, собственно, и без нее? По-моему, во всех современных cms так и хранится. Я понимаю, что они сами по себе легаси, но все же, best pratices что предлагаете?


    1. andreymal
      14.05.2022 17:49
      +1

      md5 слишком быстрый и позволяет подбирать не слишком длинные пароли на видеокарте в разумные сроки (особенно хорошо если есть майнинг-ферма под рукой). То же самое про sha1/sha256/sha512. Поэтому для хэширования паролей изобретают специальные алгоритмы pbkdf2/bcrypt/scrypt/argon2: задача их всех — максимально замедлить подбор


      и без нее

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


      По-моему, во всех современных cms так и хранится.

      Ваши знания устарели лет на пятнадцать


      best pratices что предлагаете?

      Используемый в статье password_hash по умолчанию использует алгоритм bcrypt


      1. doomguy49
        14.05.2022 20:33
        -1

        Ваши знания устарели лет на пятнадцать

        То есть, например, в Wordpress пароли хэшируются не в md5? Утверждать не буду, но по-моему несколько месяцев назад именно он там и был. Я почти уверен, что и сейчас используется, если в последних апдейтах ничего не меняли.

        md5 слишком быстрый и позволяет подбирать не слишком длинные пароли на видеокарте в разумные сроки (особенно хорошо если есть майнинг-ферма под рукой).

        То есть хороший пароль по-прежнему проблема подобрать даже с md5.

        Разве же пароли вида qwerty становятся проблемными для подбора, если используется bcrypt?

        Я уже давно не в теме, но раньше успех определялся короткой базой более-менее популярных паролей, а не базой всех возможных паролей, а сейчас реально подбирать сложные пароли на фермах?

        Мне кажется, что это по-прежнему проблема, тянуть такие ресурсы на брутфорс бессмысленно для большинства средних проектов, да и успех не гарантирован, а у +/- выросших из легаси есть двухфакторка или вообще логин по айпишнику закрыт, то есть опять же, даже зная пароль, что с ним делать? Где я не прав?


        1. andreymal
          14.05.2022 21:03
          +2

          То есть, например, в Wordpress пароли хэшируются не в md5?

          Судя по исходникам на гитхабе — не просто md5, а много раз повторяемый в цикле, что тоже замедляет перебор. А ещё комментарий к коду намекает, что md5 используется не от хорошей жизни, так что не думаю, что пример вордпресса стоит принимать во внимание


          становятся проблемными для подбора

          Если хэш достаточно медленный, то даже словарные пароли перебрать будет несколько более затруднительно (если пароль не из какого-нибудь топ-100 конечно). А несловарные, но всё ещё короткие пароли можно будет считать даже более-менее безопасными и не заставлять пользователя выдумывать сверхдлинное непонятно что


          даже зная пароль, что с ним делать?

          Пытаться войти с этим или похожим паролем в другие критичные для пользователя сервисы (почту, банки, соцсети), если пользователь оказался достаточно глуп, чтобы использовать одинаковые или похожие пароли на разных сервисах (как я, например)


          Вот моя личная история: несколько лет назад хэш моего 13-символьного пароля (несловарный, с разным регистром, цифрами и спецсимволами) утёк с какого-то сайта (не знаю, с какого конкретно, потому что я слишком глуп) и был успешно взломан на сервисе для взлома паролей Hashes.org — с тех пор мой пароль гуляет по интернету в базах словарных паролей. К сожалению, я не знаю, какой алгоритм хэширования использовался, и спросить уже не у кого — сайт hashes.org уже пару лет не работает. Однако это намекает на то, что md5, вероятно, окажется слишком быстрым даже для несловарных 13-символьных паролей


          1. doomguy49
            15.05.2022 00:01
            -1

            Пытаться войти с этим или похожим паролем в другие критичные для пользователя сервисы (почту, банки, соцсети), если пользователь оказался достаточно глуп, чтобы использовать одинаковые или похожие пароли на разных сервисах (как я, например)

            Из этого списка разве что почта может (а может и нет) оказаться без двухфакторной. Да и такой рандомайз — это как иголку в стоге сена искать. Обычно нужно что-то конкретное для таргета, что вообще имеет смысл перебирать, а то выбирать ценное из пула спама, где ничего ценного может и не быть, тянет на еще одну задачу.

            В целом я понял, спасибо за комментарии. Хотя и ситуации мне кажутся синтетическими, допускаю, что где-то это может иметь смысл


  1. iyurip
    15.05.2022 08:43

    Xxs позволяет получить сессию любого пользоватетеля который авторизовался на твком кривом сайте как ваш. Поэтому вывод должен быть экранирован. Sql инекции в pdo маловероятны.


  1. ProgrammSM
    16.05.2022 02:25

    Пара мелочей, как дополнение на тему, что можно сделать ещё:

    1. По поводу конфига. Можно сделать чуть интереснее. Вместо хардкода значений в скрипте использовать переменные окружения. А сами значения вынести или в .env файл (под .gitignore) или на веб-сервер или на уровень ОС. Таким образом в коде получится просто getenv('DB_NAME');, getenv('DB_HOST'); и тд. Это чуть сложнее, чем в вашем варианте. С другой стороны код будет выглядеть полноценным, настраивать который в разных окружениях возможно разными способами. На мой взгляд это предпочтительнее, чем проверять или надеяться на существование файла с конфигом.

    2. Если нужно просто проверить существование пользователя (или любой другой сущности) без необходимости получать саму запись, тогда select * будет лишним. Полей может быть сколько угодно в таблице и нет смысла получать их все для простой проверки. Достаточно ограничить выборку полем id или иным легковесным. Или просто указав select 1 from ... where ... Который вернёт только единицу, если запись существует.


    1. FanatPHP
      16.05.2022 07:34
      +1

      Насчет переменных окружения верно, только надо не забывать, что если писать в .env файл, то во-первых, его надо выносить не только из гита, но в первую очередь — из корневой папки веб-сервера, а во-вторых, надо будет подключать к проекту код, который, собственно, умеет читать эти файлы :)
      Так что в целом, для данного уровня это будет перебор.