Авторизация на сессиях своими руками. Делаем логин в админке

октябрь 25 , 2019
Исходники

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

Сначала выберем проект, в котором будем делать авторизацию. Сначала я хотел сделать отдельный, но потом подумал, а зачем? У нас есть админка интернет-магазина, которая прекрасно подойдет для экспериментов. Админка написана на vue.js, но если вы им не интересуетесь, не беспокойтесь. Мы сделаем так, чтобы авторизацию можно было прикрутить к любому проекту. Не копипастнуть, конечно, но весь код можно будет переиспользовать.

Давайте начнем. Осторожно, это прям лонгрид.

Мы будем писать код на php, как обычно, а авторизацию сделаем на сессиях. Если описать все одной строчкой кода, то вот

    $_SESSION['authorized'] = 1;

Так мы авторизовали пользователя. Серьезно, это самый важный код. Но есть и другие вопросы:

— Как выводить для неавторизованных пользователей страницу логина, а для остальных - самого сервиса?
— Как сделать форму авторизации и написать клиентский код?
— Можно ли передавать логин и пароль по сети в открытом виде?
— Какое будет серверное api?
— Логин, разлогин, какой функционал еще понадобится?
— Какие http-коды ошибок использовать?
— Как хранить пароли, в коде или в базе?
— А что если взломают ftp или уведут базу?
— В каком виде хранить пароли?
— Нужно ли их как-то шифровать?
— А как потом расшифровывать?
— Или есть еще какие-то способы?
— Хэширование? Что это вообще такое?
— Так какой все-таки способ выбрать и как его реализовать?
— Сессии, что это и как работают?
— Та строчка с $_SESSION['authorized'] - неужели это все, что нужно?
— Как закрыть не только страницы, но и апишные запросы для неавторизованных юзеров?
— Если ли какие-то особенности работы с vue и vue-cli (спойлер, у нас будут, но все решаемо)

Если я еще вас не напугал, то это очень здорово. Устраивайтесь поудобнее, сейчас мы шаг за шагом разберемся со всеми вопросами. Разговор будет долгий.

Для начала определим в общих чертах, как это будет работать у нас.


Общая схема авторизации в админке

Прямо сейчас у нас есть админка интернет-магазина на vue, которая по адресу http://{host}/admin/vue/ открывает список товаров, категорий и брендов.

Напомню, как она выглядит

Админка интернет-магазина на vue.js

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

Авторизация в админке интернет-магазина на vue.js

Это простейшая страница с двумя полями ввода: логин и пароль. И единственная кнопка "Войти в админку".

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

Если мы не будем динамически заводить пользователей, то значит, забьем их заранее. Это сильно упростит задачу, а функционал не пострадает. В конце концов, сколько там будет админов самописного интернет-магазина? Скорее всего один или два. Без разграничения прав заводить больше пользователей особого смысла нет. Вот мы и заведем всего двоих :-)

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

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

Поехали.


Заменяем index.html на index.php

Сейчас index.html - входной файл админки. Но когда мы подключаем авторизацию, быть главным файлом он уже не может. Он должен быть лишь одним из двух шаблонов, который мы покажем пользователю: шаблон самой админки (index.html) и шаблон страницы логина (login.html). Определять, авторизован ли юзер и какой из шаблонов ему показывать, будет сервер, то есть php. Поэтому стартовым файлом теперь будет index.php, а файл index.html переместим в отдельную папку templates и будем подключать его оттуда.

Создадим в корне админки (папке admin/vue/) папку templates и перенесем в нее файл index.html. А в корне создадим index.php с таким содержимым

    include_once './templates/index.html';

Теперь у нас все работает, как и раньше. В этом можно убедиться, зайдя на ваш локальный сервер, у меня это http://w-shop.lc/admin/vue/. Если php работает нормально, то мы увидим страницу товаров в админке.

Идем дальше, нужно сделать страницу логина.


Создаем страницу логина login.html

Это отдельный html-файл, который лежит в той же папке templates рядом с index.html. Давайте сделаем простую html-заготовку для нового файла. В секции head напишем так. Прошу извинить, приходится писать html-теги с тупыми _, чтобы браузер не сильно ругался.

    <_meta charset="utf-8">
    <_title>Авторизация | Webdevkin
    <_link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mini.css/3.0.1/mini-default.css">

А в body так

    <_h1>Форма логина

    <_script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js">
    <_script src="./templates/login.js">

Пока ничего лишнего, это просто заглушка. Стили для css подключаем из cdn minicss, как и во всей админке. В body просто заголовок и 2 подключенных скрипта: jquery и login.js. Содержимое login.js

    var app = (function($) {
        function init() {
            console.log('Init login');
        }
    
        return {
            init: init
        }
    })(jQuery);
    
    jQuery(document).ready(app.init);

Это заготовка модуля логина с единственной функцией init, которая выводит надпись в консоль. Давайте проверим, что новая страница login.html тоже подключается. Для этого в index.php заменим index.html на login.html, проверим - работает.

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


Заглушка для авторизации

Функции, связанные с авторизацией, будут лежать в отдельном файле и своем пространстве имен. Создадим файл auth.php в api/v1/common - там, где уже лежит helpers.php. Если вы разбирали уроки админки, особенно третий, про серверную часть, то эти пути вам будут знакомы. Если у вас свой проект, то кладите auth.php куда удобно. Главное, потом правильно указать пути.

Содержимое auth.php

    namespace Auth;
    
    // Проверка, залогинен ли пользователь
    function isLogged() {
        return false;
    }

Пока так просто. Единственная функция isLogged, которая возвращает булево значение false - пользователь не залогинен. Реализацию мы напишем чуть позже, когда закончим с клиенсткой частью. А пока подключим и используем auth.php в главном файле index.php. До этого было так

    include_once './templates/login.html';

То есть мы подключали шаблон статично. А стало так

    include_once '../api/v1/common/auth.php';
    
    $template = \Auth\isLogged() ? 'index' : 'login';
    include_once "./templates/$template.html";

Мы подключили модуль авторизации и вывели шаблон в зависимости от результата функции isLogged. Не беда, что сейчас в ней просто return false, допишем код в нужное время. А пока можно еще раз проверить, что мы движемся верно: поменять return false на true и убедиться, что в зависимости от результата мы показываем шаблон index или login.

Как проверим, оставим return false и продолжим работать со страницей логина. Пора добавить в нее форму авторизации и написать javascript-код.


Добавляем форму логина

Как мы договаривались, в форме будут 2 инпута (логин и пароль) и кнопка "Войти". С учетом стилей фреймворка minicss это будет выглядеть так

    

Вход в админку


Авторизуйтесь

Можете не обращать внимания на всякие container, col-sm-3 и row - это сетка minicss. Верстайте форму как угодно. Но стоит присмотреться к таким вещам.

1. В форме помимо инпутов и кнопки есть mark - сообщение об ошибке авторизации. По умолчанию скрыто классом hidden
2. У некоторых элементов, а именно формы, сообщения об ошибке и инпутов, есть id. Для первых двух они понадобятся в javascript-коде, а для инпутов навешаны только для связи инпута с label
3. У инпутов есть атрибуты name - так будет удобнее сериализовать данные с формы для отправки на сервер
4. На кнопку не навешан id и даже не будет события по клику - вместо этого мы будем использовать сабмит формы

С html все, пора написать javascript-код.


Авторизация на клиенте

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

Сначала заведем объект ui, куда сложим нужные элементы dom

    var ui = {
        login: {
            $form: $('#form'),
            $error: $('#error')
        }
    };

Затем конфиг, где укажем url, по которому будем пытаться авторизоваться

    var config = {
        api: {
            login: '/admin/api/v1/auth/login'
        }
    };

Почему url не просто вбить строкой в ajax-запросе? Потому что отдельный объект конфига это чертовски удобная штука. Захотим мы завтра добавить регистрацию и восстановление пароля, а у нас опа - и уже конфиг есть. Останентся в блок api положить рядом 2 урла в полях register и restore - и все. По таким же соображениям и объект ui - пока в нем 2 элемента, но по мере усложнения формы туда можно добавить все остальное. Все лежит в одном месте, легко читается и редактируется.

Дальше вспомним про обработку ошибок, точнее про показ и скрытие ошибки. Напишем 2 вспомогательные функции

    function _showError(message) {
        ui.login.$error.text(message);
        ui.login.$error.removeClass('hidden');
    }

    function _hideError() {
        ui.login.$error.addClass('hidden');
    }

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

Дальше идем в функцию init и вместо бесполезного console.log напишем полезные навешивания событий

    function init() {
        ui.login.$form.on('submit', _login);
        ui.login.$form.find('input').on('keydown', _hideError);
    }

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

Осталось реализовать _login

   function _login(e) {
        e.preventDefault();
        _hideError();

        $.ajax({
            url: config.api.login,
            method: 'POST',
            data: ui.login.$form.serialize(),
            contentType: 'application/x-www-form-urlencoded',
            dataType: 'json',
            success: function() {
                alert('success');
                document.location.reload();
            },
            error: function(response) {
                var errorMessage = response.responseJSON.message || 'Что-то пошло не так, попробуйте еще раз';
                _showError(errorMessage);
            }
        });
    }

Что здесь интересного?

1. e.preventDefault() чтобы отменить поведение формы по умолчанию (иначе после сабмита форма перезагрузится)
2. _hideError() запускаем, чтобы опять-таки скрыть ошибки
3. url берем из конфига
4. Метод post - все по фэншую
5. data получаем через jquery-метод формы serialize() - вот и пригодились атрибуты name у инпутов. Удобнее, чем руками value собирать
6. contentType: 'application/x-www-form-urlencoded' - это приколы нашей админки на vue. Вы же можете отправить json
7. dataType: 'json' - ждем ответ от сервера в формате json
8. В success, это если получим код 200 в ответ, мы понимаем, что логин и пароль прошли, авторизация успешна. Выводим alert success для серьезности момента и перезагружаем страницу. Напомню, после перезагрузки мы будем проверять, залогинен ли пользователь и подключим нужный шаблон. То есть после success и перезагрузки юзер увидит саму админку
9. В error, это когда придет ответ 401 UNAUTHORIZED, мы вытащим сообщение об ошибке, которое вернет сервер, и выведем его в форме через _showError
10. Почему хотя у нас указан dataType в json, мы вытаскиваем данные в блоке error через хитрый response.responseJSON.message? Потому что json приходит нам вместе с 200 http-кодом, а здесь 401 код ошибки. На бекенде мы все равно вернем json для однообразия, но вытаскивать уже приходится чуть хитрее.

Это весь код клиентской части. Неважно, какой способ авторизации на сервере вы выберете, этот код на клиенте подойдет для любой авторизации.

А теперь минутка философии.


Можно ли передавать логин и пароль по сети в открытом виде?

Можно. Не запаривайтесь с шифрованием на клиенте и расшифровкой на сервере. HTTPS - его будет достаточно для шифрования трафика, если только вы не делаете сайт пентагону.

Поставьте ssl-сертификат и все. Это недорого, устанавливается несложно, а некоторые хостеры вообще предлагают бесплатные сертификаты, например, от Let's Encrypt. Сам так делаю и голова не болит.

Едем дальше, теперь будет только серверный код.


Роутер для авторизации на бекенде

В login.js в конфиге мы задали путь, которому будем авторизовываться - /admin/api/v1/auth/login. Давайте сделаем так, чтобы он работал. Лезем в php.

Напомню, мы делаем авторизацию для админки на vue, а там уже написан REST API на чистом php. Если вы не проходили уроки vue, то рекомендую почитать хотя бы третий урок админки на vue, где мы писали серверный код, а еще лучше до этого прочитать вводную статью - REST API на нативном php. Там все рассказывается более подробно. Без этого код роутера, возможно, будет не так понятен. Хотя если вы и сами знаете, как разрулить маршруты на сервере, то можете просто читать дальше, пробежав этот раздел одним глазком.

Итак, разбираемся с роутером auth. Для начала в файле /admin/api/v1/index.php подключим файл common/auth.php, где у нас будет вся работа с авторизацией.

    // Старый код, который уже был написан
    include_once 'lib/underscore.php';
    include_once 'common/helpers.php';
    
    // Добавляем эту строчку
    include_once 'common/auth.php';

Теперь создадим файл роутера routers/auth.php с таким содержимым

    // Роутинг, основная функция
    function route($data) {
    
        // POST /auth/login
        if (
            $data['method'] === 'POST' &&
            count($data['urlData']) === 2 &&
            $data['urlData'][1] === 'login' &&
            isset($data['formData']['user']) &&
            isset($data['formData']['password'])
        ) {
            $user = $data['formData']['user'];
            $password = $data['formData']['password'];
    
            $auth = \Auth\login($user, $password);
            if ($auth['code'] !== 'success') {
                header('HTTP/1.0 401 Unauthorized');
            }
            echo json_encode($auth);
            exit;
        }
    
        // Если ни один роутер не отработал
        \Helpers\throwHttpError('invalid_parameters', 'invalid parameters');
    }

Как я предупреждал, подробное разъяснение этих строчек было в двух указанных выше статьях. Если расшифровать, то код говорит нам такое. Если на бекенд придет post-запрос /auth/login с двумя непустыми параметрами user и password, то нужно запустить функцию login() из пространства имен Auth. Если авторизация прошла успешно (code == success), то просто отдать клиенту json с 200 http-кодом ответа (он отдается по умолчанию). Если же авторизация не успешна, то отдать тот же json, но с 401 кодом ошибки (вот его нужно явно прописывать через header)

Чтобы зарегистрировать новый роутер в системе, добавим его в белый список в файле common/helpers.php в методе проверки на валидность. Теперь он будет выглядеть так

    // Проверка роутера на валидность
    function isValidRouter($router) {
        return in_array($router, array(
            'auth',
            'categories',
            'brands',
            'products'
        ));
    }

С роутером разобрались, посмотрим на саму функцию login. Идем в файл common/auth.php и добавляем заглушку для этой функции.

    // Логин пользователя
    function login($user, $password) {
        if ($user === 'admin' && $password === 'password') {
            return array(
                'code' => 'success'
            );
        } else {
            return array(
                'code' => 'error',
                'message' => 'Неправильный логин или пароль'
            );
        }
    }

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

Если совпадает, вернем массив, который роутер преобразует в json {"code": "success"}

Если не совпадает, то выходной json будет {"code": "error", "message": "Неправильный логин или пароль"}

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

Здесь еще дополнительно можно провести разные проверки, например, чтобы логин и пароль не были пустыми или слишком короткими. И по результатам валидации выводить соответствующие ошибки клиенту. Благо что для этого предусмотрено поле message. Можно, но я доверю это вам, а в примере оставлю только общую ошибку "не шмогла"

После этого кода можно уже потыкаться в форму логина и проверить связь клиента и сервера. Если все сделали правильно, то на ошибочные логин-пароль вы увидите красное сообщение. А если введете admin/password, то успешный alert('success') и перезагрузку страницы в браузере. Конечно, после перезагрузки мы все еще будем видеть страницу логина. Непосредственно авторизация пользователя будет позже.


Что делать дальше?

И вот мы подбираемся к самому интересному: а где хранить пароли и в каком виде? Давайте сначала подумаем, где их хранить.

Прямо сейчас у нас есть заглушка функции логина, где пароль забит прямо в php-коде. И знаете, я не вижу ничего плохого, чтобы для интернет-магазина домашних пирожков с одним пекарем-админом и 50 посетителями в день вот прямо так и оставить. Да, прямо логин и пароль в коде. Да, при условии, если у вас маленький сайт, и возможно, даже нет базы данных. Помните, мы делали простую админку на файлах? Вполне рабочая админка, легко интегрируется на простой сайт, позволяет менять настройки. И легко закрывается от посторонних глаз авторизацией, которую мы сейчас и рассматриваем. А прикрутить к такому сайту корзину и отправку заказов на почту - и никакой базы не нужно, все прекрасно сработает и так. Главное, чтобы заказы на пирожки шли :-)

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

Разбираемся дальше, а в каком виде?


Способы хранения паролей

Первое, что приходит в голову - это табличка users в базе с полями user и password, куда запишем логин-пароль как есть. Но это плохой способ в плане безопасности. Если у нас уведут базу, то все данные пользователей будут как на ладони. Не годится.

Да, в предыдущем примере с хранением пароля прямо в коде я не упоминал про безопасность :-) Конечно, если злобный хакер получит доступ по ftp, то наш пароль он легко найдет. Но поэтому-то мы и выбрали способ сложнее и надежнее - держать пароли в базе.

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

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

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

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

Подытожим, в тему шифрования я соваться пока не хочу и вам не советую. Есть способы надежнее и проще.


Как обойтись без шифрования. Хэширование

Фокус в том, что не нужно хранить пароли в открытом виде, но и не нужно шифровать их с возможностью расшифровки. Пароли нужно хэшировать и в базе хранить не пароль, а его хэш. Хитрым образом закодированную строку, которую нельзя расшифровать. Например, не password, а 5f4dcc3b5aa765d61d8327deb882cf99

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

Например, есть простая функция хэширования md5. Вот так она работает

    echo md5('password');
    > 5f4dcc3b5aa765d61d8327deb882cf99
    
    echo md5('123456');
    > e10adc3949ba59abbe56e057f20f883e

Вот такие наборы символов и будем хранить в базе. А сравнивать с этой дикой строкой будем не пароль, а хэш пароля. Примерно так

    md5($password) == e10adc3949ba59abbe56e057f20f883e

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


Выбираем правильное хэширование

Идею хранения паролей нашли, то есть хранения не паролей, а их хэшей. А вот какой алгоритм хэширования выбрать?

Давайте посмотрим на то, что пробовали выше - простая функция md5. Алгоритма его расшифровки нет, но тем не менее md5 не рекомендуется для использования. Почему?

Помимо сложности самого алгоритма, есть и другой момент. Да, расшифровать пароль по хэшу нельзя, но его можно подобрать. Простым брутфорсом. К тому же существуют многочисленные базы паролей md5, где всевозможные варианты хранятся тупо списком. И подбор вашего пароля по известному хэшу займет столько времени, сколько понадобится для выполнения sql-запроса

    select password from passwords where hash='e10adc3949ba59abbe56e057f20f883e';

Конечно, вы сами не будете использовать пароль 123456, но как насчет ваших пользователей? Да, 123456 можно подобрать и руками, но в таком случае никакие алгоритмы не помогут. Наша же задача максимально позаботиться о юзерах, которые выбирают пароли сложнее qwerty. Думаем дальше, гуглим.

Помимо md5 есть множество алгоритмов хэширования, sha256, sha512 и еще целая толпа. Их сложность выше, но это не отменяет опять-таки существования таблиц с готовыми паролями. Нужно что-то хитрее.


Соль

Этот кулинарный термин активно применяется в теме хэширования. В чем суть?

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

Поэтому получается, что длинный пароль - хороший пароль. Еще лучше пароль, если он состоит не только из цифр и латинских букв, но и из знаков препинания и других странных символов. Но как заставить пользователей задавать такие правильные, но сложные пароли?

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

То есть вводит юзер пароль qwerty, а мы добавляем к нему какой-то дикий набор, например,

    aFkh3JS88aJ;d>sdqwep!@#dfj_

То есть вместо qwerty получаем

    aFkh3JS88aJ;d>sdqwep!@#dfj_qwerty

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

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

Здесь возникают сразу 3 вопроса:

1. откуда брать эту соль
2. как ее хранить
3. как все-таки хэшировать, раз md5 и прочие sha512 не подходят

Разбираемся. Откуда брать соль? Можно завести ее руками, набрав случайный набор символов, как это сделал я в примере выше. А можно генерить соль динамически, чтобы для каждого пользователя она была своя. Соотвественно, в первом случае можно хранить соль прямо в коде, а во втором - в таблице юзеров рядом с хэшем пароля.

Чтобы повысить надежность, можно комбинировать подходы. Например, прибавить к паролю статичную соль и динамическую, которую затем положить в таблицу. Динамическую соль можно получить, например, как md5(user). Да, сам по себе md5 небезопасен, но добавляя соли и применяя md5 несколько раз, надежность хэширования увеличивается.

Здесь уже можно извращаться как угодно. Вот варианты для хэша пароля

    md5(password + salt)
    md5(md5(password + salt))
    md5(md5(password) + md5(user))

Вариантов, как видим, множество, все зависит только от нашей фантазии и паранойи. Еще раз уточню, если будете использовать такой подход, то выбирайте вместо md5 что-нибудь понадежнее, например, sha512. Ранние версии вордпресса вообще использовали просто md5(password), но со временем сложность хэширования возрастала. Где-то читал, что некоторые cms применяли md5 к паролю аж 64 раза! Основательный подход.


Как же хэшировать нам?

Подход с формулой вроде md5(md5(password) + md5(user)) мне кажется вполне адекватным. Прямо такую брать не стоит, наверняка для нее существуют подборки паролей. Но можно ее усложнить, применив хэш еще несколько раз.

Можно, но мы пойдем другим путем. В php есть 2 стандартные функции - password_hash и password_verify. Именно их мы и будем использовать для хэширования и проверки пароля. Чем они хороши?

password_hash по умолчанию использует сильный алгорим шифрования, который меняется на еще более сильные в новых версиях php. Для каждого пароля генерируется случайная соль, которую не нужно хранить отдельно. Соль можно задать и руками, но это категорически не рекомендуется. В конце-то концов, люди, создавшие эти алгоритмы, соображают в безопасности гораздо больше меня, который разобрался только в самых азах. Зачем городить свое, если можно воспользоваться наработками специалистов?

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

    password_hash($password, PASSWORD_DEFAULT);

А потом сравнить пароли так

    password_verify($passwordFromUser, $passwordFromDatabase);

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

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

Но есть и другие. Те, которым интересно разобраться, какие вообще бывают подходы и почему мы пришли именно к такому. Для вас я и пишу такие простыни текста и надеюсь, что кому-то это будет полезно или интересно. Или откроете что-то новое для себя. А если сделаете еще лучше, чем реализовал я, то это вообще классно. Вот этом случае статья точно будет написана не зря.

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


Добавляем таблицу admin_users и пользователей с паролями

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

    CREATE TABLE `admin_users` (
      `id` int(10) unsigned NOT NULL,
      `user` varchar(255) NOT NULL,
      `hash` varchar(255) NOT NULL,
      PRIMARY KEY (`id`)
    ) DEFAULT CHARSET=utf8;

Простейшая таблица из трех полей:
1. id - первичный ключ, автоинкремент не делаем, сами проставим.
2. user - имя пользователя
3. hash - хэш пароля, тот самый

Теперь добавим в таблицу 2 строки:

    INSERT INTO `admin_users` (`id`,`user`,`hash`) VALUES (1,'admin','$2y$10$uWEWei5BFR5/2sWVo9Rjp.0bHdO3l8Nofq4WE5arMj2VMxkupgzoG');
    INSERT INTO `admin_users` (`id`,`user`,`hash`) VALUES (2,'developer','$2y$10$RomPT15GCJDwqr5p5gmtQ.1x.SAb2FMu1V//wBQ2nqAcvp7nVU4/C');

Первый юзер - admin с паролем password, а второй - developer с паролем qwerty. Как я получил хэши? Зашел в первый попавшийся php онлайн редактор и забил туда

    echo password_hash('password', PASSWORD_DEFAULT);
    echo '
'; echo password_hash('qwerty', PASSWORD_DEFAULT);

Обратите внимание, у вас получатся другие значения. Я не знаю, как работает под капотом password_hash, но каждый его вызов для одной и той же строки генерирует разный хэш - и это очень хорошо. Врукопашную играя с формулами типа md5(md5(password) + md5(user)), было бы гораздо сложнее. Поэтому хорошо, что мы разобрались, как работают велосипеды для хэширования, но не стали их использовать.

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


Реализуем функцию логина

Идем в файл common/auth.php и смотрим на заглушку

    // Логин пользователя
    function login($user, $password) {
        if ($user === 'admin' && $password === 'password') {
            return array(
                'code' => 'success'
            );
        } else {
            return array(
                'code' => 'error',
                'message' => 'Неправильный логин или пароль'
            );
        }
    }

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

Я не буду описывать функции подключения к базе, это все расписано в серверном уроке админки. Да, знаю, в пятый раз уже даю ссылку, но это лучше, чем копипастить сюда все объяснение кода, совсем не маленькое. Поэтому мы воспользуемся готовым, написанным ранее кодом. Впрочем, при необходимости вы сами легко вытащите данные из базы любым удобным способом. У нас же обновленный код функции получится таким

    // Логин пользователя
    function login($user, $password) {
        $pdo = \Helpers\connectDB();
        $query = 'select id, hash from admin_users where user=:user';
        $data = $pdo->prepare($query);
        $data->bindParam(':user', $user);
        $data->execute();
        $item = $data->fetch();
    
        if (password_verify($password, $item['hash'])) {
            return array(
                'code' => 'success'
            );
        } else {
            return array(
                'code' => 'error',
                'message' => 'Неправильный логин или пароль'
            );
        }
    }

В первых 6-ти строках подключение к базе через PDO и выполнение sql-запроса. В 6-й строке вытаскиваем данные в ассоциативный массив $item. Этот массив будет выглядеть так

    array(
        'id' => {id},
        'hash' => {hash}
    )

Если пользователя с таким именем в базе нет, то массив будет пустой.

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

    if ($user === 'admin' && $password === 'password') 

напишем честную проверку хэша через password_verify

    if (password_verify($password, $item['hash'])) 

Вот так. Теперь функция login возвращает нам такие же данные, но идет нормальная проверка на совпадение логина и пароля. Можно попробовать в интерфейсе и убедиться, что admin/password по прежнему подходят, хоть мы и перенесли данные в базу. Но теперь еще подходит и вторая пара developer/qwerty. Все работает как надо.

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


Что такое сессии?

Попробую рассказать в паре абзацев.

Сессии - это механизм, который позволяет однозначно идентифицировать пользователя. По-человечески это значит, что для каждого посетителя сайта можно создать уникальное "хранилище", к которому будет доступ только у этого самого посетителя. Хранилище это хранится в файле на сервере. Для каждого пользователя свой уникальный файл, в который мы можем записать какие угодно данные по конкретному юзеру. Например, авторизован он или нет. Как раз то, что нам нужно.

На самом деле сессии хранятся не обязательно в файлах. Например, cms Modx хранит их базе данных. Но сейчас нам это не важно, главное, что сессии предоставляют удобный способ работать с данными, уникальными для пользователя. Для нас сессия выглядит как обычный ассоциативный массив под названием $_SESSION, в разные поля которого можно записать хоть число, хоть строку, хоть сериализованный объект. И конечно, можно записать $_SESSION['authorized'] = 1. Таким образом, мы запомним пользователя, ведь сессия не пропадет при перезагрузке страницы. Сессию можно удалить тремя способами:

1. Ее может сбросить сам пользователь, удалив куку PHPSESSID
2. Сессия протухнет сама через определенное время, когда мы закроем браузер. Обычно по дефолту 24 минуты
3. Мы сами сбросим сессию через php-код. Этим приемчиком мы воспользуемся, когда будем реализовывать разлогинивание

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

Давайте же пробовать на практике.


Пишем код непосредственной авторизации на сессиях

Сначала сделаем так, чтобы при успешной проверке логина-пароля в сессию записывались данные об авторизации. Смотрим опять функцию login, там, где идет проверка условия password_verify. И в блоке if перед возвратом массива с code = success дописываем такую строку

    $_SESSION['userId'] = $item['id'];

То есть блок if будет выглядеть так

    if (password_verify($password, $item['hash'])) {
        $_SESSION['userId'] = $item['id'];

        return array(
            'code' => 'success'
        );
    } else {
        return array(
            'code' => 'error',
            'message' => 'Неправильный логин или пароль'
        );
    }

Именно эта новая строка и "логинит" пользователя. В сессию мы запишем id юзера. В нашей админке можно было обойтись просто булевым флагом authorized, но часто бывает, что айдишник оказывается очень нужной штукой. Например, если нам понадобится вытащить из базы email пользователя, чтобы отправить ему письмо, или имя, чтобы написать в шапке сайта что-то вроде "Привет, Вася!"

Есть соблазн закинуть все нужные данные в сессии, но не стоит увлекаться этим. Сессии не резиновые и занимают место. Лучше сохранить только id, а при необходимости доставать нужные данные из базы.

Но это мы немного отвлеклись. Данные в сессию мы сохранили, но еще нужно стартовать сам механизм сессий. Делается это с помощью функции session_start. У ней есть одна хитрость. Вызывать ее нужно до того, как мы вывели в браузер хоть что-то, хоть html, хоть json, хоть пустой пробел. То есть ставьте вызов session_start как можно выше. Часто его ставят просто в начале php-шного файла.

В нашем случае все прекрасно сработает, если мы поставим session_start прямо перед $_SESSION['userId'] = $item['id']. Но так как у нас все запросы проходят через главный файл /admin/api/v1/index.php, то лучше стартовать сессии именно там. Прямо в самом начале файла.

Вообще без неободимости запускать сессии не стоит. То есть пока у нас сессия используется только в функции login, вроде бы и стартовать сессии нужно только там. Но я чуть забегу вперед и скажу, что нам понадобится, чтобы сессии работали при любом апишном запросе. Чуть позже разберем, почему, а пока просто зафигачим код в начало admin/api/v1/index.php

    // Открываем сессию
    session_start();

Есть еще один момент. Сессии работают так, что запускать их нужно только один раз. Повторный запуск session_start выкинет ошибку. У нас работает все правильно, запускаем только один раз, но я предпочитаю подстраховаться и перед запуском проверить, а не была ли сессия уже запущена. Делается это с помощью функции session_id(), вот так

    // Открываем сессию
    if (session_id() == '') {
        session_start();
    }

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

Теперь пользователь залогинен, осталось научиться это понимать. Вспомним функцию isLogged, которую мы написали в самом начале, поставив там заглушку return false. Давайте это исправим и напишем реальный код

    function isLogged() {
        return isset($_SESSION['userId']) && $_SESSION['userId'] != '';
    }

Вот так, ничего сложного. Функция проверяет, существует ли сессия userId и не равна ли она пустой строке. Напомню, userId у нас всегда число.

Идем дальше. Ищем файл admin/vue/index.php, тот самый главный, который разруливает, какую страницу показывать: админки или форму логина. Вот он

    include_once '../api/v1/common/auth.php';
    
    $template = \Auth\isLogged() ? 'main' : 'login';
    include_once "./templates/$template.html";

Мы научили функцию \Auth\isLogged() делать нормальную проверку залогиненности, но этому index.php тоже нужно стартовать сессию, чтобы isLogged нормально отработал. Делаем это точно так же, как и выше - добавляем session_start с проверкой в начало файла. Теперь он выглядит так

    // Стартуем сессию
    if (session_id() == '') {
        session_start();
    }
    
    include_once '../api/v1/common/auth.php';
    
    // Выбираем и подключаем нужный шаблон
    $template = \Auth\isLogged() ? 'main' : 'login';
    include_once "./templates/$template.html";

Последнее, что осталось сделать перед проверкой логина - это зайти в login.js и убрать alert('success') из раздела success. Alert нам больше не нужен. Если логин и пароль мы ввели правильно, то страница просто перезагрузится и сразу покажет нам админку. А если нет, то по-прежнему в форме логина выведет сообщение об ошибке.

Вот теперь можно опять проверять логин - все работает, как надо.


Разлогинивание

В любой порядочной админке нужно уметь не только входить, но и выходить из нее. После того, что мы сделали для авторизации, разлогин покажется нам очень простым. Сначала определимся с роутером. Заведем новый роут для /auth/logout в файле routers/auth.php

    // GET /auth/logout
    if (
        $data['method'] === 'GET' &&
        count($data['urlData']) === 2 &&
        $data['urlData'][1] === 'logout'
    ) {
        \Auth\logout();
        header('Location: /admin/vue');
        exit;
    }

То есть сначала мы вызываем \Auth\logout(), в ней собственно разлогиниваемся (удаляем сессию), а затем редиректим на /admin/vue - главную страницу админки. То есть то же самое, что и при логине на клиенте, где мы перезагружаем страницу через document.location.reload(). Здесь же тоже нужно направить на стартовую страницу, но уже кодом на бекенде.

Дальше пишем код для logout в файле common/auth.php

    // Разлогин
    function logout() {
        unset($_SESSION['userId']);
        session_destroy();
    }

API для выхода готово, осталось только разместить соответствующую кнопку в шапке админки. В компоненте AppHeader.vue после табов разместим ссылку

    Выйти

Готово, можно пробовать разлогиниться. И логиниться заново под вторым пользователем.


Закрываем API для неавторизованных пользователей

Мы молодцы и в админку теперь можно попасть только зная логин и пароль. Но апишные запросы, те самые получения списка товаров и брендов и все редактирование брендов остается открытым. Можете проверить. Сначала выйдите из админки, то есть разлогиньтесь. При заходе на http://w-shop.lc/admin/vue/ вы увидите форму логина, а вот если дернете прямо в браузере http://w-shop.lc/admin/api/v1/products, то получите полный список товаров. То же самое и с остальными запросами.

Конечно, это плохо и оставлять так нельзя. Файл admin/vue/index.php, который главная страница админки, мы закрыли проверками, а вот /admin/api/v1/index.php - входную точку для апи-запросов, еще нет. Давайте это исправим.

Напишем вот такой код в /admin/api/v1/index.php после подключения всех файлов, но перед проверкой роутера на валидность

    // Закрываем API для неавторизованных пользователей
    if ($router !== 'auth' && !\Auth\isLogged()) {
        header('HTTP/1.0 403 Forbidden');
        exit();
    }

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

Вот теперь попробуйте выйти из админки и дернуть тот самый запрос за списком товаров - http://w-shop.lc/admin/api/v1/products. Получим в ответ 403 Forbidden. Все лишнее закрыто от посторонних глаз. Отлично.

Мы сделали все, что касается авторизации, но остался один момент. У нас админка на vue, а работает ли оно все как надо?


Проверяем работоспособность vue

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

Но попробуем пересобрать админку, чтобы убедиться, что все хорошо. Сначала в production режиме

    npm run build

Все собирается, открываем http://w-shop.lc/admin/vue/ (или какой у вас хост), проверяем работу админки, разлогин, попадаем на форму логина, логинимся - все в порядке. То есть наш код готов к выкладыванию в продакшен. Это хорошо.

Но при разработке нам гораздо важнее режим девелопера, который предоставляет vue-cli. Попробуем запустить его

    npm run dev 

Опа, открылся http://localhost:8080/, но вернул 404 ошибку с текстом "Cannot GET /". Что за фигня?

Дело в том, что vue-cli при запуске ищет входной файл index.html, который располагается в корневой папке /admin/vue, рядом с конфигом вебпака. А у нас там нет index.html, только index.php. А index.html вместе с login.html мы вынесли в отдельную папку templates.

Почему же, когда мы сбилдили приложение в production режиме и зашли через нормальный хост http://w-shop.lc/admin/vue/, то все было хорошо? Скорее всего, потому что в настройках вашего домена в nginx или apache первым читается файл index.php. Чаще всего так и делают. Например, в nginx у меня было так

    server {
        listen   80;

        root /home/sn8/www/webdevkin_shop;
        
        // Вот эта строка
        index index.php index.html;

        server_name w-shop.lc;
        
        ...
    }

То есть, в первую очередь веб-сервер должен искать index.php, если его нет, то index.html.

Так вот, первая проблема, что vue-cli работает в обход внешнего веб-сервера и ищет index.html. Ему пофиг, что в nginx указан index.php. А html-файл у нас лежит в templates.

Вторая проблема - когда я разрабатываю админку, мне особо и не нужна авторизация. Я предпочту в dev режиме сразу видеть админку без страницы логина. С админкой мы работаем гораздо чаще, чем с авторизацией. Так что же делать с обеими проблемами?

Я перепробовал кучу вариантов с настройками vue и вебпака, но вынужден признать, что я все еще дурачок в сборке vue и вебпаке. Ну или по крайней мере все еще учусь. Мне казалось, что это простейшая задача - научить vue брать исходный index.html из другого места, ан нет. Адекватно это сделать так и не удалось.

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

index.html и login.html мне пришлось вытащить из папки templates наружу - в корень. Кладем их прямо рядом с index.php. До кучи вытащим из templates и login.js и переместим его в папку src - пусть login.js не будет одинок. А папку templates сносим на фиг.

Теперь у нас в корне лежат index.php, index.html и login.html. Не забудем поправить пути в index.php до html-файлов с учетом выноса их из templates , то есть вот так

    // Выбираем и подключаем нужный шаблон
    $template = \Auth\isLogged() ? 'index' : 'login';
    include_once "./$template.html";

Да, еще в login.html нужно поправить путь до login.js, мы же его переместили в src

    <_script src="./src/login.js">

Вот теперь будет работать. Сначала пробуем в режиме production, через хост. Мой https://w-shop.lc/admin/vue/ открывает index.php и начинает разруливать авторизацию. Запускаем в dev режиме localhost:8080 - vue сразу подхватывает index.html в обход авторизации. Отлично.

Но нас ждет еще один сюрприз. В dev режиме шаблон-то подхватывается, но админка пустая, все апишные запросы отдают 403 Forbidden. Почему? Давайте посмотрим на код в /admin/api/v1/index.php, где это может быть. А вот здесь

    // Закрываем API для неавторизованных пользователей
    if ($router !== 'auth' && !\Auth\isLogged()) {
        header('HTTP/1.0 403 Forbidden');
        exit();
    }

Правильно, мы не проходим второе условие isLogged, проверку на залогиненность. Вот даже комментарий оставили "Закрываем API для неавторизованных пользователей". А сейчас мы в dev режиме открыли админку в обход авторизации. А чтобы заработали апи-запросы, обернем этот код еще в одно условие

    // Не проверяем авторизацию в dev-режиме
    if ($_SERVER['HTTP_ORIGIN'] !== 'http://localhost:8080') {
        // Закрываем API для неавторизованных пользователей
        if ($router !== 'auth' && !\Auth\isLogged()) {
            header('HTTP/1.0 403 Forbidden');
            exit();
        }
    }

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

Вот и все. Больше кода в этом уроке не будет. Поздравляю, если дочитали до конца :-)


Вместо заключения

Проблемы с dev режимом во vue - это неприятный момент. Мы хотим и апишечку подергать, и все удобства vue-cli использовать. И на елку влезть, и ничего не ободрать. Возможно, есть более изящный способ обойти эти проблемы в dev режиме, но я их пока не нашел. Поэтому приходится подпирать код лишними условиями.

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

Именно поэтому, разбираясь с проблемами dev режима, я начал жалеть, что вместе с vue не стал изучать, например, laravel. Это отличный php-фреймворк, который дружит с vue. Думаю, в этой связке проблем с авторизацией нет. Или они решаются более приличным способом.

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

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

Здесь исходники интернет-магазина, админки и кода авторизации - скачать.

Всем спасибо и до встречи.

Исходники
Заходите в группу в контакте - https://vk.com/webdevkin
Анонсы статей, обсуждения интернет-магазинов, vue, фронтенда, php, гита.
Истории из жизни айти и обсуждение кода.
Как Вам статья? Оцените!