Интеграция аутентификации Symfony2 и трекера Jira

Здравствуйте, Хабросообщество. В этой статье хочу рассказать, как можно подружить известный фреймворк Symfony2 и не менее известный трекер Jira.

Зачем связывать Jira и Symfony2?


В компании, где я работаю, возникла необходимость связать систему саппорта и трекер задач через API, чтобы заявки от клиентов могли быть легко преобразованы в тикеты. Первостепенной проблемой, которая встала на нашем пути, была интеграция аутентификации Jira (использовался механизм “Basic Authentication”) и системы безопасности Symfony2. Для понимания механизмов аутентификации и авторизации фреймворка необходимо ознакомиться с официальной документацией: http://symfony.com/doc/current/book/security.html.


Что нужно для создания нового типа авторизации в Symfony2?


  1. Token, который будет хранить введенную пользователем информацию при аутентификации.
  2. Listener, необходимый для проверки авторизованности пользователя.
  3. Provider, непосредственно реализующий аутентификацию через Jira.
  4. User Provider, который будет запрашиваться Symfony2 Security для получения информации о пользователе.
  5. Factory, которая зарегистрирует новый способ аутентификации и авторизации.


Создаем Token


Для сохранения информации, введенной при аутентификации пользователями, и последующего ее использования в Symfony используются токены, которые наследуются от класса AbstractToken. В рассматриваемой задаче необходимо хранить 2 поля — это логин и пароль пользователя, на основе которых будет производить проверка авторизованности в Jira. Код реализации класса токена приведен ниже.

<?php

namespace DG\JiraAuthBundle\Security\Authentication\Token;

use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;

class JiraToken extends AbstractToken
{
    protected $jira_login;
    protected $jira_password;

    public function __construct(array $roles = array('ROLE_USER')){
        parent::__construct($roles);
        $this->setAuthenticated(count($roles) > 0);
    }

    public function getJiraLogin(){
        return $this->jira_login;
    }

    public function setJiraLogin($jira_login){
        $this->jira_login = $jira_login;
    }

    public function getJiraPassword(){
        return $this->jira_password;
    }

    public function setJiraPassword($jira_password){
        $this->jira_password = $jira_password;
    }

    public function serialize()
    {
        return serialize(array($this->jira_login, $this->jira_password, parent::serialize()));
    }

    public function unserialize($serialized)
    {
        list($this->jira_login, $this->jira_password, $parent_data) = unserialize($serialized);
        parent::unserialize($parent_data);
    }

    public  function getCredentials(){
        return '';
    }
}


Реализация Listener


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

<?php

namespace DG\JiraAuthBundle\Security\Firewall;

use DG\JiraAuthBundle\Security\Authentication\Token\JiraToken;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Http\Firewall\AbstractAuthenticationListener;

class JiraListener extends AbstractAuthenticationListener {
    protected function attemptAuthentication(Request $request){
        if ($this->options['post_only'] && 'post' !== strtolower($request->getMethod())) {
            if (null !== $this->logger) {
                $this->logger->debug(sprintf('Authentication method not supported: %s.', $request->getMethod()));
            }

            return null;
        }

        $username = trim($request->get($this->options['username_parameter'], null, true));
        $password = $request->get($this->options['password_parameter'], null, true);

        $request->getSession()->set(SecurityContextInterface::LAST_USERNAME, $username);
        $request->getSession()->set('jira_auth', base64_encode($username.':'.$password));

        $token = new JiraToken();
        $token->setJiraLogin($username);
        $token->setJiraPassword($password);

        return $this->authenticationManager->authenticate($token);
    }
}


Авторизация в Jira. Provider


Пришло время самого главного — непосредственной отправки данных в Jira. Для работы с rest api трекера написан простой класс, который подключается в виде сервиса. Для работы с API Jira используется библиотека Buzz.

<?php

namespace DG\JiraAuthBundle\Jira;
use Buzz\Message;
use Buzz\Client\Curl;

class JiraRest {
    private $jiraUrl = '';

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

    public function getUserInfo($username, $password){
        $request = new Message\Request(
            'GET',
            '/rest/api/2/user?username=' . $username,
            $this->jiraUrl
        );

        $request->addHeader('Authorization: Basic ' . base64_encode($username . ':' . $password) );
        $request->addHeader('Content-Type: application/json');

        $response = new Message\Response();

        $client = new Curl();
        $client->setTimeout(10);
        $client->send($request, $response);

        return $response;
    }
}


Provider должен реализовывать интерфейс AuthenticationProviderInterface и выглядит следующим образом:

<?php

namespace DG\JiraAuthBundle\Security\Authentication\Provider;

use DG\JiraAuthBundle\Entity\User;
use DG\JiraAuthBundle\Jira\JiraRest;
use DG\JiraAuthBundle\Security\Authentication\Token\JiraToken;
use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class JiraProvider implements AuthenticationProviderInterface {

    private $userProvider;
    private $jiraRest;

    public function __construct(UserProviderInterface $userProvider, $providerKey, JiraRest $jiraRest)
    {
        $this->userProvider = $userProvider;
        $this->jiraRest = $jiraRest;
    }

    public function supports(TokenInterface $token)
    {
        return $token instanceof JiraToken;
    }

    public function authenticate(TokenInterface $token)
    {
        $user = $this->checkUserAuthentication($token);
        $token->setUser($user);

        return $token;
    }

    public function checkUserAuthentication(JiraToken $token){
        $response = $this->jiraRest->getUserInfo($token->getJiraLogin(), $token->getJiraPassword());
        if(!in_array('HTTP/1.1 200 OK', $response->getHeaders())){
            throw new AuthenticationException( 'Incorrect email and/or password' );
        }
        $userInfo = json_decode($response->getContent());
        $user = new User();
        $user->setUsername($userInfo->name);
        $user->setBase64Hash(base64_encode($token->getJiraLogin() . ':' . $token->getJiraPassword()));
        $user->setEmail($userInfo->emailAddress);
        $user->addRole('ROLE_USER');
        return $user;
    }
}


Как видно из реализации — данные о пользователе хранятся в сущности User. Этого можно не делать, чтобы Doctrine не создавала лишнюю таблицу в базе данных, но в будущем можно в данную таблицу складывать информацию о пользователях из Jira, чтобы подстраховать себя от временной недоступности трекера. Подобная “страховка” выходит за рамки статьи, но может быть весьма полезна.

Предоставление информации об авторизованном пользователе


Система Security во фреймворке запрашивает информацию о пользователе для проверки авторизации. Понятно, что подобная информация находится в Jira, поэтому мы должны ее получать именно от трекера. Можно, конечно, кешировать ответы от Jira, но пока это не будем брать в расчет. Код провайдера приведен ниже.

<?php

namespace DG\JiraAuthBundle\User;


use DG\JiraAuthBundle\Entity\User;
use DG\JiraAuthBundle\Jira\JiraRest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\SecurityContextInterface;

class JiraUserProvider implements UserProviderInterface {

    private $jiraRest;

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

    public function loadUserByUsername($username)
    {
    }

    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }

        $decodedUserData = base64_decode($user->getBase64Hash());
        list($username, $password) = explode(':', $decodedUserData);
        $userInfoResponse = $this->jiraRest->getUserInfo($username, $password);
        $userInfo = json_decode($userInfoResponse->getContent());

        $user = new User();
        $user->setUsername($user->getUsername());
        $user->setEmail($userInfo->emailAddress);
        $user->setBase64Hash($user->getBase64Hash());
        $user->addRole('ROLE_USER');
        return $user;
    }

    public function supportsClass($class)
    {
        return $class === 'DG\JiraAuthBundle\Entity\User';
    }
}


Заполнение конфигурации


Для использования созданных классов необходимо их зарегистрировать в конфигурации в виде сервисов. Пример services.yml приведен ниже. Отмечу, что параметр jira_url должен быть определен в parameters.yml и содержать url адрес до Jira.
parameters:
    dg_jira_auth.user_provider.class: DG\JiraAuthBundle\User\JiraUserProvider
    dg_jira_auth.listener.class: DG\JiraAuthBundle\Security\Firewall\JiraListener
    dg_jira_auth.provider.class: DG\JiraAuthBundle\Security\Authentication\Provider\JiraProvider
    dg_jira_auth.handler.class: DG\JiraAuthBundle\Security\Authentication\Handler\JiraAuthenticationHandler
    dg_jira.rest.class: DG\JiraAuthBundle\Jira\JiraRest

services:
    dg_jira.rest:
        class: %dg_jira.rest.class%
        arguments:
            - '%jira_url%'

    dg_jira_auth.user_provider:
        class: %dg_jira_auth.user_provider.class%
        arguments:
            - @dg_jira.rest

    dg_jira_auth.authentication_success_handler:
        class: %dg_jira_auth.handler.class%

    dg_jira_auth.authentication_failure_handler:
        class: %dg_jira_auth.handler.class%

    dg_jira_auth.authentication_provider:
        class: %dg_jira_auth.provider.class%
        arguments: [@dg_jira_auth.user_provider, '', @dg_jira.rest]

    dg_jira_auth.authentication_listener:
        class: %dg_jira_auth.listener.class%
        arguments:
            - @security.context
            - @security.authentication.manager
            - @security.authentication.session_strategy
            - @security.http_utils
            - ''
            - @dg_jira_auth.authentication_success_handler
            - @dg_jira_auth.authentication_failure_handler
            - ''
            - @logger
            - @event_dispatcher


Регистрация нового метода аутентификации и авторизации в Symfony


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

<?php

namespace DG\JiraAuthBundle\DependencyInjection\Security\Factory;

use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractFactory;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\DefinitionDecorator;
use Symfony\Component\DependencyInjection\Reference;

class JiraFactory extends AbstractFactory {

    public function __construct(){
        $this->addOption('username_parameter', '_username');
        $this->addOption('password_parameter', '_password');
        $this->addOption('intention', 'authenticate');
        $this->addOption('post_only', true);
    }

    protected function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId)
    {
        $provider = 'dg_jira_auth.authentication_provider.'.$id;
        $container
            ->setDefinition($provider, new DefinitionDecorator('dg_jira_auth.authentication_provider'))
            ->replaceArgument(1, $id)
        ;

        return $provider;
    }

    protected function getListenerId()
    {
        return 'dg_jira_auth.authentication_listener';
    }

    public function getPosition()
    {
        return 'form';
    }

    public function getKey()
    {
        return 'jira-form';
    }

    protected function createListener($container, $id, $config, $userProvider)
    {
        $listenerId = parent::createListener($container, $id, $config, $userProvider);

        if (isset($config['csrf_provider'])) {
            $container
                ->getDefinition($listenerId)
                ->addArgument(new Reference($config['csrf_provider']))
            ;
        }

        return $listenerId;
    }

    protected function createEntryPoint($container, $id, $config, $defaultEntryPoint)
    {
        $entryPointId = 'security.authentication.form_entry_point.'.$id;
        $container
            ->setDefinition($entryPointId, new DefinitionDecorator('security.authentication.form_entry_point'))
            ->addArgument(new Reference('security.http_utils'))
            ->addArgument($config['login_path'])
            ->addArgument($config['use_forward'])
        ;

        return $entryPointId;
    }
}


Для регистрации в бандле, необходимо в метод build у класса бандла добавить строку

$extension->addSecurityListenerFactory(new JiraFactory());


Окончательное внедрение


Все, теперь мы готовы тестировать работу с Jira. Добавим созданный JiraUserProvider в security.yml в секцию providers в виде строк

        jira_auth_provider:
            id: dg_jira_auth.user_provider


Далее необходимо добавить в firewalls новую секцию, полагая, что все страницы, адреса которых начинаются с /jira/ по умолчанию закрыты от неавторизованных пользователей:

    jira_secured:
        provider:               jira_auth_provider
        switch_user:            false
        context:                user
        pattern:                /jira/.*
        jira_form:
            check_path:         dg_jira_auth_check_path
            login_path:         dg_jira_auth_login_path
            default_target_path: dg_jira_auth_private
        logout:
            path:               dg_jira_auth_logout
            target:             dg_jira_auth_public
        anonymous:              true


Последний штрих — добавление строк в секцию access_controls, определяющих роли пользователей, необходимый для просмотра страниц. Примерный вид строк может имеет вид

- { path: ^/jira/public, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/jira/private/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/jira/private(.*)$, role: ROLE_USER }


PS


Весь код, приведенный в статье, можно установить в виде бандла из пакета «dg/jira-auth-bundle» в composer или с github. Для работы бандла, необходимо зарегистрировать его в AppKernel.php и добавить секцию

_jira_auth:
    resource: "@DGJiraAuthBundle/Resources/config/routing.yml"
    prefix:   /jira/


в routing.yml. После этого можно зайти на страницу /jira/public и протестировать авторизацию через Jira.

Для закрепления материала


В Symfony Cookbook есть так же инструкция, как внедрить аутентификацию через сторонний веб сервис.

Надеюсь статья будет вам полезна!
Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 11
  • 0
    Почему бы вам не оформить этот способ авторизации как бандл на гитхабе? Думаю многим пригодится.
    • +3
      Спасибо. Добавил в статье ссылку на репозиторий в github.
      • 0
        Описаны действия, но почти не описаны причины. То есть по многим пунктам нет ответа на вопрос: зачем?
        • +1
          в SF2.4 стало гораздо проще добавлять свои методы авторизации

          And here comes the interesting part. Instead of creating a custom token, factory, listener, and provider, let's use the new Symfony\Component\Security\Core\Authentication\SimpleFormAuthenticatorInterface interface instead


          symfony.com/blog/new-in-symfony-2-4-customize-the-security-features-with-ease
          • 0
            Спасибо большое!

            Плохо, что такого не было раньше, когда столкнулся с задачей.
            • 0
              В 2.4 уже не мало добавлено, что упрощает разработку. Но на данный момент, наверное лучше будет писать под 2.3, так как это стабильная версия, поддержка которой будет точно еще полтора года, в результате, большинство ее и используют.
          • 0
            Насколько я помню(говорил Joseph Rouff из SensioLabs на конфе слайды с доклада), то не обязательно создавать новый класс токена, ведь в симфони уже есть UsernamePasswordToken его можно переиспользовать
            • 0
              Хотелось пройти весь путь от начала и до конца. Так же можно дописать еще поля из жиры в токен — это может оказаться полезным.
            • 0
              Вроде как существует уже бандл для авторизации по OAuth (https://github.com/hwi/HWIOAuthBundle). В списке поддерживаемых провайдеров значится JIRA.

              Задача бы сводилась к кастомизации провайдера, который автоматически создавал пользователя при первом коннекте. Если это не требуется, тогда вообще все из коробки работает.
              • 0
                Вот, кстати, OAuth так завести для жиры и не удалось с помощью приведенного бандла. Правда там уже правок было немного после внедрения http basic аутентифиации, так что на oauth несложно было перейти.

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