image
Сегодня вышел самый долгожданный компонент PHPixie 3 — Auth для авторизации пользователей. Авторизация это наиболее критическая часть любого приложения, сделать ее правильно трудно, а ошибки могут скомпрометировать множество пользователей, особенно если речь идет об оупенсорсе. Использование устарелых hash-функций, криптографически небезопасных генераторов случайных чисел, неправильная работа с кукисами встречаются слишком часто. Я уже когда-то писал о старой уязвимости в Laravel, которую кстати полностью так не исправили. Поэтому в PHPixie Auth я очень внимательно отнёсся к аутентификации, особенно к долгим сессиям и кукисам.

Кстати в конце статьи у меня для вас есть очень радостная новость (спойлер: PHPixie теперь член PHP-FIG)

Что делает PHPixie Auth безопасным:
  • использование password_hash() из PHP 5, и пакета компатибильности для более старых версий
  • аналогично с криптографически безопасным random_bytes() из PHP 7
  • следование защищенному методу работы с кукисами из jaspan.com/improved_persistent_login_cookie_best_practice


Последний пункт наиболее интересный и среди PHP фреймворков не имеет аналогов из коробки. Суть заключается в отдельной таблице для хранения токенов логина.
  1. При логине создается пара случайных строк: идентификатор серии и пароль, которые отдаются пользователю в форме куки
  2. Создается хеш серии с паролем и записывается в базу вместе с идентификатором юзера и сроком годности
  3. При повторном обращении на сайт хеш с куки сравнивается с хешем в базе, и если они совпадают то происходит логин, старый токен удаляется и пользователю создается новый, но с той же серией
  4. Если хеш не совпал, значит куки кто-то украл или пробует подобрать. В таком случае удаляются все токены с той же серией

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

Конфигурация

Как и другие компоненты PHPixie, Auth можно использовать и без фреймворка, но для простоты изложения я опишу как раз этот случай.

Во-первых вам понадобится репозиторий пользователей, каждый бандл может предоставлять свои репозитории, из которых мы выберем нужные уже в конфиг файле. Если вы используете ORM для работы с пользователями, то с Auth поставляется готовый враппер:

namespace Project\App\ORMWrappers\User;

// Враппер репозитория
class Repository extends \PHPixie\AuthORM\Repositories\Type\Login
{
    //Есть поддержка логина по нескольким полям
    // например по юзернейму и емейлу
    protected function loginFields()
    {
         return array('username', 'email');
    }
}


namespace Project\App\ORMWrappers\User;

// Враппер сущности
class Entity extends \PHPixie\AuthORM\Repositories\Type\Login\User
{
    // указываем поле с хешем пароля
    protected function passwordHash()
    {
         return $this->password;
    }
}


Не забываем зарегистрировать их в ORMWrappers.php

namespace Project\App;

class ORMWrappers extends \PHPixie\ORM\Wrappers\Implementation
{
    protected $databaseEntities = array('user');
    protected $databaseRepositories = array('user');

    public function userEntity($entity)
    {
        return new ORMWrappers\User\Entity($entity);
    }
    
    public function userRepisitory($repository)
    {
        return new ORMWrappers\User\Repository($repository);
    }
}


Теперь зарегистрируем этот репозиторий в бандле:

namespace Project\App;

class AuthRepositories extends \PHPixie\Auth\Repositories\Registry\Builder
{
    protected $builder;

    public function __construct($builder)
    {
        $this->builder = $builder;
    }

    protected function buildUserRepository()
    {
        $orm = $this->builder->components()->orm();
        return $orm->repository('user');
    }
}


namespace Project\App;

class Builder extends \PHPixie\DefaultBundle\Builder
{
    protected function buildAuthRepositories()
    {
        return new AuthRepositories($this);
    }
}


Также необходимо создать таблицу для хранения токенов (при использовании MongoDB все будет работать сразу):

 CREATE TABLE `tokens` (
  `series` varchar(50) NOT NULL,
  `userId` int(11) DEFAULT NULL,
  `challenge` varchar(50) DEFAULT NULL,
  `expires` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`series`)
)


Теперь сам конфиг файл. Самый популярный подход будет выглядеть вот так:

// /assets/auth.php

return array(
    'domains' => array(
        'default' => array(

            // репозиторий user из бандла app
            'repository' => 'app.user',
            'providers'  => array(

                // включаем поддержку сессий
                'session' => array(
                    'type' => 'http.session'
                ),

                // поддержка кукисов (для "remember me")
                'cookie' => array(
                    'type' => 'http.cookie',
                    
                    // при логине сказать провайдеру session
                    // чтобы тот запомнил юзера
                    'persistProviders' => array('session'),
                    
                    // где сохранять токены
                    'tokens' => array(
                        'storage' => array(
                            'type'            => 'database',
                            'table'           => 'tokens',
                            'defaultLifetime' => 3600*24*14 // две недели
                        )
                    )
                ),
                
                // поддержка логина паролем
                'password' => array(
                    'type' => 'login.password',
                    
                    // запомнить пользователя в сессии.
                    // заметьте что в этом массиве нет 'cookies'
                    // ведь мы будем делать "remember me" логин
                    // не всегда, а только когда юзер сам попросит
                    'persistProviders' => array('session')
                )
            )
        )
);


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

// /assets/auth.php

return array(
    'domains' => array(
        'default' => array(
               'cookie' => array(
                    'type' => 'http.cookie',

                    // где сохранять токены
                    'tokens' => array(
                        'storage' => array(
                            'type'                 => 'database',
                            'table'                => 'tokens',
                            'defaultLifetime' => 3600*24*14,
                            'refresh'             => false
                        )
                    )
                ),
                
                'password' => array(
                    'type' => 'login.password',
                    'persistProviders' => array('cookie')
                )
            )
        )
);


Использование

Cоздадим простенький процессор, чтобы попробовать как это все вместе работает:

namespace Project\App\HTTPProcessors;

class Auth extends \PHPixie\DefaultBundle\Processor\HTTP\Actions
{
    protected $builder;

    public function __construct($builder)
    {
        $this->builder = $builder;
    }

    // Смотрим залогинен ли пользователь в домене
    public function defaultAction($request)
    {
        $user = $this->domain()->user();

        return $user ? $user->username : 'not logged';
    }
    
    // екшн для добавления пользователя в базу
    public function addAction($request)
    {
        $query = $request->query();
        $username = $query->get('username');
        $password = $query->get('password');

        $orm = $this->builder->components()->orm();
        $provider = $this->domain()->provider('password');

        $user = $orm->createEntity('user');

        $user->username     = $username;

        // хешыруем пароль используя провайдер
        $user->passwordHash = $provider->hash($password);

        $user->save();

        return 'added';
    }
    
    // Логиним пользователя по паролю
    public function loginAction($request)
    {
        $query = $request->query();
        $username = $query->get('username');
        $password = $query->get('password');

        $provider = $this->domain()->provider('password');

        $user = $provider->login($username, $password);
        
        if($user) {

              // generate persistent cookie
              $provider = $this->domain()->provider('cookie');
              $provider->persist();
        }
        return $user ? 'success' : 'wrong password';
    }
    
    // логаут
    public function logoutAction($request)
    {
        $this->domain()->forgetUser();
        return 'logged out';
    }
     
    protected function domain()
    {
        $auth = $this->builder->components()->auth();
        return $auth->domain();
    }
}


Теперь заходим по урлах и смотрим результат:

  1. /auth — пользователь не залогинен
  2. /auth/add?username=jigpuzzled&password=5 — создаем пользователя
  3. /auth/login?username=jigpuzzled&password=5 — логинимся
  4. /auth — проверяем логин
  5. /auth/logout — логаут


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

Свои провайдеры

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

PHPixie теперь член в PHP-FIG


Спасибо SamDark PHPixie уже от завтра будет членом PHP-FIG! Полный тред голосования можно увидеть тут. И еще огромное спасибо всем пользователям фреймворка, так как именно популярность и количество загрузок один из главных критериев отбора ^__^

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


  1. dewil
    18.09.2015 16:10

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


    1. BupycNet
      18.09.2015 16:26

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


      1. dewil
        18.09.2015 16:33

        > в момент запроса потеряет соединение.
        если честно я не понял.
        юзер может сделать запрос в любое время с любого ip, и сопроводить его своим токеном.

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


        1. jigpuzzled
          18.09.2015 16:41

          Чтобы токен нельзя было использовать при краже ( например стандартная MITM аттака). Так же это исключает брутфорс токена, так как при первой неверной попытке сотрется любой токен из той же серии.

          К слову, мопед не мой, тема «remember me» токенов давно обсуждалась, так я и нашел ту статью.


          1. Arik
            19.09.2015 08:17

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


            1. jigpuzzled
              19.09.2015 11:09

              это при каком из конфигов? При том что с сессией или куки-онли?


      1. jigpuzzled
        18.09.2015 16:38

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


    1. jigpuzzled
      18.09.2015 16:37

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


      1. dewil
        18.09.2015 16:53

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


      1. dewil
        18.09.2015 17:05

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


        1. jigpuzzled
          18.09.2015 17:10

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


          1. Fesor
            19.09.2015 17:43

            То есть если у нас в качестве клиента не браузер — компонент либо бесполезен либо нам приходится хэндлить еще и куки/сессии, и, если конечно сессии не хранятся в каком-нибудь кластере в редис или еще где, горизонтальное масштабирование сильно усложняется… так?


            1. jigpuzzled
              19.09.2015 17:49

              Не совсем понял, компонент http авторизации конечно же подразумевает браузер. Для провайдера авторизации по паролю канал совсем не важен.

              Я так понял у вас ввиду авторизация например токеном каким то? Если да, то придется написать свой првайдер ( на пару строк буквально, так как можно использовать уже готовую логику с токенами)


              1. Fesor
                19.09.2015 19:54

                Понятно. Вообще восхищаюсь вашим энтузиазмом в написании велосипедов. Есть ще symfony/security, куча готовых библиотек и прочее… почему бы не потратить ментальную энергию на контрибьюцию туда, улучшить уже имеющиеся решения… Хотя пожалуй это риторический вопрос.


                1. jigpuzzled
                  19.09.2015 21:59

                  ну например Symfony2 по дефолту в кук пишет хеш пароля с солью, что намного меньше безопасно и поддается брутфорсу. Они сами рекомендуют использовать токен сервис из Доктрині например. А в пиксе безопасно из коробки


                  1. Fesor
                    19.09.2015 22:44

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


                    1. jigpuzzled
                      20.09.2015 00:12

                      Ну вот теперь у вас есть выбор получить те же токены только без доктрины в проекте. Плюс в симфони токены тесно связаны как раз с «remember me» функционалом ( даже в том неймспейсе лежат), а в пиксе токены отдельная подсистема которую можно где-нибудь использовать


                      1. Fesor
                        20.09.2015 00:42

                        Потому что в Symfony токены нужны исключительно для того что бы привязать сессию в контексте remember me и только в контексте не stateless реализации.

                        Опять же, все это решается введением своих провайдеров. Я это к тому что бы делать разработку полностью с нуля можно было бы реализовать просто парочку интерфейсов из symfony/security и тд. Symfony/security не сказать что идеален, там много стремных моментов, он сложный… но для такой критической вещи как безопасность, управление правами и т.д. лучше иметь одну-две хороших реализации нежели тысячу так себе. Поддерживать один критически важный компонент намного проще чем два или три.

                        Просто по фану — да, клево, и реализация ничего так, но использовать ее в живых проектах как-то стремно.


                        1. jigpuzzled
                          20.09.2015 01:56

                          когда дело доходит напрмер к работе с паролями то и пикси и симфони используют password_hash() из PHP и ту же библиотеку компатибильности. Вот если я бы ее переписал, вот это в тогда велосипед был =)


  1. romeOz
    20.09.2015 00:13

    использование password_hash() из PHP7

    Наверное, имелось ввиду 5.5. Для более ранних версий существует fallback от Энтони, автора rfc password_hash.



    1. jigpuzzled
      20.09.2015 01:57

      Спасибо, да в статье ошибся, уже поправил =)