Сессии в API на Yii c возможностью хранения в Redis

    Не так давно у меня возникла необходимость написать API на Yii Framework, одним из функциональных требований в котором является авторизация. Для механизма авторизации я решил использовать сессии.

    Вариант реализации самодельных сессий


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

    1. Клиент отправляет запрос на сервер c данными на авторизацию.
    2. В случае успешной авторизации сервер генерирует уникальный идентификатор (рандомный хеш), сохраняет его у себя в хранилище (БД, кеш и т.п.), записывает информацию о принадлежности клиента к данному идентификатору и проставляет время последнего обращения к серверу. После этого он отправляет клиенту ответ с этим идентификатором.
    3. Клиент, получив идентификатор сессии и сохранив его для дальнейших запросов, отправляет запрос на сервер с переданным идентификатором сессии (в качестве параметра или заголовка) для получения данных.
    4. Сервер, проверив идентификатор сессии, отдает данные клиенту и обновляет время последнего обращения к серверу с данным идентификатором.

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

    Вариант с использованием стандартных PHP-сессий


    А что мы получим, используя стандартные PHP-сессии?
    1) Автоматическая генерация уникального идентификатора сессии.
    2) Доступ к данным, хранимым в сессии, а также управление ими из любого места приложения.
    3) Использование стандартных PHP-функций для работы с сессиями, в том числе оберток над ними. Например, class CHttpSession Yii-фреймворка.
    4) Автоматическое восстановление сохраненного ранее окружения. Например, автоматический логин пользователя при получении идентификатора ранее созданной сессии.
    5) Автоматическое удаление сессий, у которых закончилось время жизни.

    Давайте рассмотрим, как работают сессии на основе cookie.
    image

    1. Браузер отправляет запрос на сервер на получение информации по указанному URL.
    2. Сервер возвращает ответ с заголовком “Set-Cookie”, который сообщает браузеру, что нужно записать идентификатор сессии в cookie. Пример заголовка “Set-Cookie”:
    Set-Cookie: PHPSESSID=p2799jqivvk8gnruif1lvtv5l5; path=/
    3. Браузер, успешно записав идентификатор сессии в cookie, отправляет запрос на получение нового URL, но уже с заголовком “Cookie”:
    Cookie: PHPSESSID=p2799jqivvk8gnruif1lvtv5l5
    4. Сервер отдает страницу браузеру.

    Все последующие запросы от браузера идут с заголовком “Cookie”, в котором содержится информация об идентификаторе сессии. Всё это автоматически работает в браузере, если cookie не отключены. Но что делать, если cookie отключены, или если в роли клиента выступает не браузер? В этом случае всё будет не так просто. Конечно, можно использовать прием и передачу заголовков “Set-Cookie” и “Cookie” на стороне клиента, но давайте рассмотрим иной вариант решения этой задачи, представленный ниже.

    Использование PHP-сессий в API


    Перед началом использования сессий нужно обратить внимание на параметры в php.ini связанные с сессиями. Обратите особое внимание на следующие параметры: session.use_cookies, session.use_only_cookies, session.use_trans_sid. Для того, чтобы начать использовать механизм PHP-сессий для API, нужно настроить эти параметры следующим образом:

    session.use_cookies = 0
    session.use_only_cookies = 0
    session.use_trans_sid = 1
    session.name = session
    


    Конечно же, не обязательно задавать эти настройки напрямую в php.ini, достаточно задать их через PHP-функцию ini_set. Этими настройками мы отключим возможность использования cookie для хранения идентификаторов на стороне клиента, так как подразумевается использование API не только браузером, а и другими приложениями, мобильными устройствами и т.п. Включение параметра session.use_trans_sid даст нам возможность передавать идентификатор сессии в качестве GET- или POST-параметра. Если вы собираетесь разрабатывать REST API, то передача идентификатора через POST-параметр не лучший вариант, так как в REST еще используются такие методы, как PUT и DELETE, при использовании которых передача идентификатора сессии не будет работать. Поэтому лучше передавать идентификатор в качестве GET-параметра, который будет работать с любым из методов в REST API. Также зададим название GET-параметра в параметре session.name, который по умолчанию называется PHPSESSID. URL с переданным идентификатором сессии будет выглядеть следующим образом:

    https://api.example.com/action?session=l2kkl7c9sm2dfedr767itc9966

    Использование PHP-сессий в Yii Framework


    Теперь давайте рассмотрим, как можно использовать этот механизм в Yii Framework. Для работы с сессиями в Yii предусмотрен класс CHttpSession. Чтобы использовать его, нужно прописать в конфиге в массив components следующие настройки:

    'session' => array(
        'autoStart' => true,
        'cookieMode'=>'none',
        'useTransparentSessionID' => true, 
        'sessionName' => 'session',
        'timeout' => 28800,
    ),
    


    где
    'cookieMode'=>'none' ставит настройки php.ini в session.use_cookies = 0 и session.use_only_cookies = 0
    'useTransparentSessionID' => true ставит php.ini в session.use_trans_sid = 1

    Для API с не очень большим количеством обращений этого было бы достаточно, но по умолчанию сессии хранятся в виде “Plain text” файла на диске, что может стать слабым звеном при интенсивном чтении и записи сессий в высоконагруженных API. В этом случае можно использовать один из вариантов решений:
    1) заменить диск на SSD;
    2) поставить рейд 10 уровня из SSD дисков;
    3) использовать RAM диск. Например, файловая система Tmpfs в Linux;
    4) хранение сессий в Memcached (хранение данных в оперативной памяти);
    5) хранение сессий в Redis (хранение данных в оперативной памяти).

    Хранение сессий в Redis


    Я хотел бы остановить свое внимание на Redis, в силу его разнообразных структур хранения данных. Так же хочу отметить немаловажную возможность восстановления данных (сессий в нашем случае) после перезагрузки сервера. Перед тем, как использовать Redis в качестве хранилища сессий, нужно установить Redis сервер и PHP Extension для Redis. Как установить и то, и другое, можно узнать здесь. После успешной установки появится возможность использовать PHP Session handler из PHP Extension для Redis. Для того, чтобы использовать PHP Session handler Redis-а, не меняя напрямую php.ini и имея возможность задавать Redis в качестве хранилища сессий в конфиге Yii, мне пришлось немного изменить CHttpSession, унаследовавшись от него и написав свой класс RedisSessionManager.

    Теперь конфиг для session-компонента будет выглядеть следующим образом:

    'session' => array(
        'class' => 'application.components.RedisSessionManager',
        'autoStart' => true,
        'cookieMode'=>'none', 
        'useTransparentSessionID' => true, 
        'sessionName' => 'session',
        'saveHandler'=>'redis',
        'savePath' => 'tcp://localhost:6379?database=10&prefix=session::',
        'timeout' => 28800,
    ),
    


    Использование сессий для авторизации в API


    Теперь можно использовать сессии для авторизации пользователей в API. Сделать это можно следующим образом:

    Метод login:

    public function actionLogin()
    {
        $params = $this->getRequestParams();
        $identity=new UserIdentity($params[‘username’],$params['password']);
        if($identity->authenticate()){
            $this->sendResponse(Status::OK, array(
                'session'=>Yii::app()->session->getSessionID(),
                'message'=>'Successful login',
            ));
        }else{
            $this->sendResponse(Status::UNAUTHORIZED, $identity->errorMessage);
        }
    }
    


    Что здесь происходит? Сначала мы получаем username и password, которые пришли из запроса. Затем пробуем авторизоваться с помощью этого логина и пароля и в случае успешного входа возвращаем идентификатор сессии для его дальнейшего использования при обращении к другим методам API.

    Вот так выглядит класс UserIdentity:

    class UserIdentity extends CUserIdentity
    {
        public function authenticate()
        {
            $account = Yii::app()->account->getByName($this->username);
            $password = Yii::app()->account->hashPassword($this->password);
            if(!$account || $this->username !== $account->username){
                $this->errorCode = self::ERROR_USERNAME_INVALID;
                $this->errorMessage = 'User with username '.$this->username.' not found';
                return false;
            } else if ($password !== $account->password) {
                $this->errorCode = self::ERROR_PASSWORD_INVALID;
                $this->errorMessage = 'Wrong password';
                return false;
            } else {
                $this->errorCode = self::ERROR_NONE;
                Yii::app()->user->login($this);
                Yii::app()->user->setId($account->id);
                Yii::app()->user->setName($account->nickname);
                return true;
            }
        }
    }
    


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

    Метод logout:

    public function actionLogout()
    {
        if(Yii::app()->session->destroySession()){
            $this->sendResponse(Status::OK, 'Successful logout');
        }else{
            $this->sendResponse(Status::BAD_REQUEST, 'Logout was not successful');
        }
    }
    


    Здесь всё просто. Просто уничтожаем текущую сессию со всем её содержимым.

    Также хочу отметить несколько советов по использованию сессий в API:

    1) Важно использовать шифрованное соединение для общения между клиентом и сервером, чтобы не дать возможность перехватить идентификатор сессии злоумышленнику для его дальнейшего использования. К примеру, можно использовать протокол HTTPS.

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

    3) Ставьте ограничение на время жизни сессии. Так как это время обновляется автоматически при очередном запросе к API, то я бы поставил, к примеру, 2 часа. Таким образом, при неактивности пользователя в течении 2 часов сессия уничтожается автоматически. Это уменьшит шанс переполнения хранилища сессий.

    Напоследок короткое демо-видео о том, как работает авторизация в REST API, написанном на Yii, c хранением сессий в Redis.



    Автор статьи: luxurydab
    MobiDev
    Company
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 8

      0
      сессии не самый лучший механизм для авторизации api. Намного выгоднее использовать для этого токены в заголовках.
        0
        если что, в этой статье есть небольшой абзац, указывающий на то, что хранить и обрабатывать состояния между запросами не есть хорошо. Для авторизации в rest удобно использовать что-то вроде openAuth или же wsse.
          +1
          не хотелось затрагивать тему REST API, это достойно отдельного топика. Как Вы и сказали, идеология REST API призывает нас не хранить состояние клиента на сервере, то есть не создавать сессии. В этом я вышел за рамки этой идеологии. Но из каких соображений нас призывают к этому? Из-за проблем связанных с масштабируемостью. А теперь вернёмся к статье. Я хотел показать возможность использования родного PHP механизма сессий для авторизации. Он покрывает не малое количество случаев, где нужно писать обработку самостоятельно, в случае чего либо самописного или же вникать в работу openAuth или wsse. В большинстве случаев этого достаточно для использования авторизации при разработке не REST API. Со временем могут возникнуть проблемы масштабируемости из-за того, что сессия хранится на том же сервере куда приходят запросы от клиента. Но здесь приходит на помощь Redis, который легко выносится на отдельный сервер и все остальные балансируемые веб-сервера, которые раньше хранили сессию у себя начинают обращаться к Redis-у. И этого становится достаточно еще на N-ое время. А если нет, то можно смотреть дальше в сторону Redis Cluster…
            0
            Разрабатываю похожее решение. Спасибо, статья очень помогла, а главное понял, что это адекватное решение задачи.
        0
        Если не секрет, что за проект мог потребовать такого неоправданного усложнения API?
          0
          проект не в продакшене, поэтому пока секрет
          0
          Для того, чтобы использовать PHP Session handler Redis-а, не меняя напрямую php.ini и имея возможность задавать Redis в качестве хранилища сессий в конфиге Yii, мне пришлось немного изменить CHttpSession, унаследовавшись от него и написав свой класс RedisSessionManager

          А чем вас не устроил вариант с CCacheHttpSession и CRedisCache?
            0
            Это тоже рабочий вариант, но мой велосипед лучше, тем что в качестве PHP Session handler-а я использую модуль написанный на С (redis.so), что должно быть пошустрее, чем использовать обертку из классов на PHP. А еще в перспективе на масштабируемость, я могу указать несколько серверов Redis-а в качестве хранилища сессий, указав приоритеты использования. Вот в таком виде:
            session.save_path = "tcp://host1:6379?weight=1, tcp://host2:6379?weight=2&timeout=2.5, tcp://host3:6379?weight=2"

          Only users with full accounts can post comments. Log in, please.