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

в 17:38, , рубрики: php, symfony, symfony2, метки: ,

Здравствуйте, Хабросообщество. В этой статье хочу рассказать, как можно подружить известный фреймворк 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 DGJiraAuthBundleSecurityAuthenticationToken;

use SymfonyComponentSecurityCoreAuthenticationTokenAbstractToken;

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 DGJiraAuthBundleSecurityFirewall;

use DGJiraAuthBundleSecurityAuthenticationTokenJiraToken;
use SymfonyComponentEventDispatcherEventDispatcherInterface;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use PsrLogLoggerInterface;
use SymfonyComponentSecurityCoreAuthenticationAuthenticationManagerInterface;
use SymfonyComponentSecurityCoreAuthenticationTokenTokenInterface;
use SymfonyComponentSecurityCoreExceptionAuthenticationException;
use SymfonyComponentSecurityCoreSecurityContextInterface;
use SymfonyComponentSecurityHttpFirewallAbstractAuthenticationListener;

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 DGJiraAuthBundleJira;
use BuzzMessage;
use BuzzClientCurl;

class JiraRest {
    private $jiraUrl = '';

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

    public function getUserInfo($username, $password){
        $request = new MessageRequest(
            '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 MessageResponse();

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

        return $response;
    }
}

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

<?php

namespace DGJiraAuthBundleSecurityAuthenticationProvider;

use DGJiraAuthBundleEntityUser;
use DGJiraAuthBundleJiraJiraRest;
use DGJiraAuthBundleSecurityAuthenticationTokenJiraToken;
use SymfonyComponentSecurityCoreAuthenticationProviderAuthenticationProviderInterface;
use SymfonyComponentSecurityCoreAuthenticationTokenTokenInterface;
use SymfonyComponentSecurityCoreExceptionAuthenticationException;
use SymfonyComponentSecurityCoreUserUserProviderInterface;

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 DGJiraAuthBundleUser;


use DGJiraAuthBundleEntityUser;
use DGJiraAuthBundleJiraJiraRest;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationSessionSession;
use SymfonyComponentSecurityCoreExceptionUnsupportedUserException;
use SymfonyComponentSecurityCoreExceptionUsernameNotFoundException;
use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSecurityCoreUserUserProviderInterface;
use SymfonyComponentSecurityCoreSecurityContextInterface;

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 === 'DGJiraAuthBundleEntityUser';
    }
}

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

Для использования созданных классов необходимо их зарегистрировать в конфигурации в виде сервисов. Пример services.yml приведен ниже. Отмечу, что параметр jira_url должен быть определен в parameters.yml и содержать url адрес до Jira.

parameters:
    dg_jira_auth.user_provider.class: DGJiraAuthBundleUserJiraUserProvider
    dg_jira_auth.listener.class: DGJiraAuthBundleSecurityFirewallJiraListener
    dg_jira_auth.provider.class: DGJiraAuthBundleSecurityAuthenticationProviderJiraProvider
    dg_jira_auth.handler.class: DGJiraAuthBundleSecurityAuthenticationHandlerJiraAuthenticationHandler
    dg_jira.rest.class: DGJiraAuthBundleJiraJiraRest

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 DGJiraAuthBundleDependencyInjectionSecurityFactory;

use SymfonyBundleSecurityBundleDependencyInjectionSecurityFactoryAbstractFactory;
use SymfonyComponentConfigDefinitionBuilderNodeDefinition;
use SymfonyComponentDependencyInjectionContainerBuilder;
use SymfonyComponentDependencyInjectionDefinitionDecorator;
use SymfonyComponentDependencyInjectionReference;

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. Для работы бандла, необходимо зарегистрировать его в AppKernel.php и добавить секцию

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

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

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

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

Надеюсь статья будет вам полезна!

Автор: GrizliK1988

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js