Как создать пользовательского поставщика аутентификации

Как создать пользовательского поставщика аутентификации

Tip

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

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

Знакомьтесь, WSSE

Следующая статья демонстрирует, как создавать пользовательского поставщика аутентификации для WSSE-аутентификации. Протокол безопасности для WSSE имеет несколько преимуществ безопасности:

  1. Шифрование имени пользователя / пароля
  2. Защита от повторных атак
  3. Не требуется конфигурация веб-сервера

WSSE очень полезна для защиты веб-сервисов, будь они SOAP или REST.

Существует масса отличной документаци по WSSE, но эта статья сфокусируется не на протоколе безопасности, а скорее на способе, которым в ваше приложение Symfony может быть добавлен пользовательский протокол. Основой WSSE является то, что заголовок запроса проверяется на зашифрованные учётные данные, проверяется с использованием временной метки и nonce и аутентифицируется для запрашиваемого пользователя, используя дайджест пароля.

Note

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

Токен

Роль токена в контексте безопасности Symfony очень важна. Токен представляет данные аутентификации пользователя, имеющиеся в запросе. Когда запрос аутенифицирован, токен удерживает данные пользователя и доставляет данные через контекст безопасности. Вначале вы создадите ваш класс токена. Это позволит передачу всей релевантной информации вашему поставщику аутентификации:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/Security/Authentication/Token/WsseUserToken.php
namespace App\Security\Authentication\Token;

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

class WsseUserToken extends AbstractToken
{
    public $created;
    public $digest;
    public $nonce;

    public function __construct(array $roles = array())
    {
        parent::__construct($roles);

        // Если пользователь имеет роли, считайте его аутентифицированным
        $this->setAuthenticated(count($roles) > 0);
    }

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

Note

Класс WsseUserToken расширяет класс компонента Безопасность AbstractToken, который предоставляет базовую функциональность токена. Реализуйте TokenInterface в любом классе, чтобы использовать токен.

Слушатель

Далее, вам будет нужно, чтобы слушатель слушал брандмауэр. Слушатель отвечает за направление запросов к брандмауэру и вызов поставщика аутентификации. Слушатель должен быть экземпляром ListenerInterface. Слушатель безопасности должен обрабатывать событие GetResponseEvent и устанавливать аутентифицированный токен в хранилище токенов при успехе:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/Security/Firewall/WsseListener.php
namespace App\Security\Firewall;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Firewall\ListenerInterface;
use App\Security\Authentication\Token\WsseUserToken;

class WsseListener implements ListenerInterface
{
    protected $tokenStorage;
    protected $authenticationManager;

    public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager)
    {
        $this->tokenStorage = $tokenStorage;
        $this->authenticationManager = $authenticationManager;
    }

    public function handle(GetResponseEvent $event)
    {
        $request = $event->getRequest();

        $wsseRegex = '/UsernameToken Username="([^"]+)", PasswordDigest="([^"]+)", Nonce="([a-zA-Z0-9+\/]+={0,2})", Created="([^"]+)"/';
        if (!$request->headers->has('x-wsse') || 1 !== preg_match($wsseRegex, $request->headers->get('x-wsse'), $matches)) {
            return;
        }

        $token = new WsseUserToken();
        $token->setUser($matches[1]);

        $token->digest  = $matches[2];
        $token->nonce   = $matches[3];
        $token->created = $matches[4];

        try {
            $authToken = $this->authenticationManager->authenticate($token);
            $this->tokenStorage->setToken($authToken);

            return;
        } catch (AuthenticationException $failed) {
            // ... здесь вы можете логировать что-то

            // Чтобы отказать в аутентификации, очистите токен. Это перенаправит на странцу входа.
            // Убедитесь, что вы очистили только ваш токен, а не токены других слушателей аутентификации.
            // $token = $this->tokenStorage->getToken();
            // если ($token instanceof WsseUserToken && $this->providerKey === $token->getProviderKey()) {
            //     $this->tokenStorage->setToken(null);
            // }
            // вернуть;
        }

        // По умолчанию отказать в авторизации
        $response = new Response();
        $response->setStatusCode(Response::HTTP_FORBIDDEN);
        $event->setResponse($response);
    }
}

Слушатель проверяет запрос на ожидаемый заголовок X-WSSE, сопоставляет значение, возвращённое для ожидаемой WSSE информации, создаёт токен, используя эту информацию и передает токен к менеджеру аутентификации. Если нужная информация не была предоставлена, менеджер аутентификации выдаёт AuthenticationException, возвращается ответ 403.

Note

Класс, не ипользуемый выше, AbstractAuthenticationListener
  • это очень полезный базовый класс, который предоставляет часто необходимую

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

Note

Слишком раннее возвращение из слушателя релевантно только если вы хотите связать поставщиков аутентификации (например, чтобы разрешить анонимных пользователей). Если вы хотите запретить доступ к анонимным пользователя и иметь красивую ошибку 403, вам стоит установить статус-кодответа до его возвращения.

Поставщик аутентификации

Поставщик аутентификации проведёт верификацию WsseUserToken. А именно, поставщик верифицирует валидность значения заголовка Created в течение пяти минут, уникальность значения заголовка Nonce в течение пяти минут и совпадает ли значение заголовка PasswordDigest с паролем пользователя:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// src/Security/Authentication/Provider/WsseProvider.php
namespace App\Security\Authentication\Provider;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\NonceExpiredException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use App\Security\Authentication\Token\WsseUserToken;

class WsseProvider implements AuthenticationProviderInterface
{
    private $userProvider;
    private $cachePool;

    public function __construct(UserProviderInterface $userProvider, CacheItemPoolInterface $cachePool)
    {
        $this->userProvider = $userProvider;
        $this->cachePool = $cachePool;
    }

    public function authenticate(TokenInterface $token)
    {
        $user = $this->userProvider->loadUserByUsername($token->getUsername());

        if ($user && $this->validateDigest($token->digest, $token->nonce, $token->created, $user->getPassword())) {
            $authenticatedToken = new WsseUserToken($user->getRoles());
            $authenticatedToken->setUser($user);

            return $authenticatedToken;
        }

        throw new AuthenticationException('The WSSE authentication failed.');
    }

    /**
     * Эта функция характерна исключительно для аутентификации WSSE и используется только для помощи в этом примере
     * Чтобы узнать больше информации об особенной логике здесь, см.
     * https://github.com/symfony/symfony-docs/pull/3134#issuecomment-27699129
     */
    protected function validateDigest($digest, $nonce, $created, $secret)
    {
        // Проверить, чтобы созданное время не было в будущем
        if (strtotime($created) > time()) {
            return false;
        }

        // Временная метка истекает через 5 минут
        if (time() - strtotime($created) > 300) {
            return false;
        }

        // Попробовать извлечь объкт кеша из пула
        $cacheItem = $this->cachePool->getItem(md5($nonce));

        // Валидировать, что nonce *не* в кеше
        // если он там, это может быть повторной атакой
        if ($cacheItem->isHit()) {
            throw new NonceExpiredException('Previously used nonce detected');
        }

        // Сохранить объект в кеше на 5 минут
        $cacheItem->set(null)->expiresAfter(300);
        $this->cachePool->save($cacheItem);

        // Валидировать секрет
        $expected = base64_encode(sha1(base64_decode($nonce).$created.$secret, true));

        return hash_equals($expected, $digest);
    }

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

Note

AuthenticationProviderInterface требует метода authenticate() в токене пользователя и метода supports(), который сообщает менеджеру аутентификации использовать ли этого поставщика для данного токена. В случае нескольких поставщиков, менеджер аутентификации будет потом перемещён к следующему поставщику в списке.

Note

Несмотря на то, что функция hash_equals была представлена в PHP 5.6, вы можете спокойно использовать её в любой PHP-версии вашего приложения Symfony. В версиях PHP до 5.6, Symfony Polyfill (который включен в Symfony) будет определять функцию за вас.

Фабрика

Вы создали пользовательский токен, пользовательского слушателя и пользовательского поставщика. Теперь вам нужно связать их между собой. Как сделать так, чтобы уникальный поставщик был доступен для каждого брандмауэра? Ответ: используя фабрику. Фабрика - это то, где вы подключаетесь к компоненту Безопасность, сообщаете ему имя вашего поставщика и любые опции конфигурации, доступные для него. Для начала, вы должны создать класс, реализующий SecurityFactoryInterface.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// src/DependencyInjection/Security/Factory/WsseFactory.php
namespace App\DependencyInjection\Security\Factory;

use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
use App\Security\Authentication\Provider\WsseProvider;
use App\Security\Firewall\WsseListener;

class WsseFactory implements SecurityFactoryInterface
{
    public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
    {
        $providerId = 'security.authentication.provider.wsse.'.$id;
        $container
            ->setDefinition($providerId, new ChildDefinition(WsseProvider::class))
            ->replaceArgument(0, new Reference($userProvider))
        ;

        $listenerId = 'security.authentication.listener.wsse.'.$id;
        $listener = $container->setDefinition($listenerId, new ChildDefinition(WsseListener::class));

        return array($providerId, $listenerId, $defaultEntryPoint);
    }

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

    public function getKey()
    {
        return 'wsse';
    }

    public function addConfiguration(NodeDefinition $node)
    {
    }
}

SecurityFactoryInterface требует следующих методов:

create()
Метод, который добавляет слушателя и поставщика аутентификации в контейнер DI для правильного контекста безопасности.
getPosition()
Возвращается, когда нужно вызвать поставщика. Это может быть один из pre_auth, form, http или remember_me.
getKey()
Метод, который определяет ключ конфигурации, использованный для ссылки на поставщика в конфигурации брандмауэра.
addConfiguration()
Метод, который используется для определения опций конфигурации под ключом конфигурации в вашей конфигурации безопасности. Установка опций конфигурации объясняется далее в этой статье.

Note

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

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

Note

Вы можете думать: "почему мне нужен специальный класс фабрики, чтобы добавлять слушателей и поставщиков в контейнер внедрения зависимостей?". Это очень хороший вопрос. Причина в том, что вы можете использовать ваш брандмауэр много раз, чтобы обезопасить несколько частей вашего приложения. Из-за этого, каждый раз, когда используется ваш брандмауэр, создаётся новый сервис в контейнере DI. Фабрика - это то, что создаёт эти новые сервисы.

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

Пора посмотреть на вашего поставщика аутентификации в действии. Вам нужно будет сделать несколько сещей, чтобы это сработало. Во-первых, добавьте вышеназыванные сервисы в контейнер DI. Ваш класс фабрики выше ссылается на id сервисов, которые ещё могут не существовать: App\Security\Authentication\Provider\WsseProvider и App\Security\Firewall\WsseListener. Пора определить эти сервисы.

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
# config/services.yaml
services:
    # ...

    App\Security\Authentication\Provider\WsseProvider:
        arguments:
            $cachePool: '@cache.app'
        public: false

    App\Security\Firewall\WsseListener:
        arguments: ['@security.token_storage', '@security.authentication.manager']
        public: false

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Kernel.php
namespace App;

use App\DependencyInjection\Security\Factory\WsseFactory;
// ...

class Kernel extends BaseKernel
{
    public function build(ContainerBuilder $container)
    {
        $extension = $container->getExtension('security');
        $extension->addSecurityListenerFactory(new WsseFactory());
    }

    // ...
}

Вы закончили! Теперь вы можете определять части вашего приложения под защитой WSSE.

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
# config/packages/security.yaml
security:
    # ...

    firewalls:
        wsse_secured:
            pattern:   ^/api/
            stateless: true
            wsse:      true

Поздравляем! Вы написали вашего собственного поставщика аутентификации безопасности!

Немного дополнительного

Как на счёт того, чтобы сделать вашего поставщика аутентификации WSSE более интересным? Возможности бесконечны. Почему бы не начать с добавления блеска?

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

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

Вначале вам нужно будет редактировать WsseFactory и определить новую опцию в методе addConfiguration().

1
2
3
4
5
6
7
8
9
10
11
12
class WsseFactory implements SecurityFactoryInterface
{
    // ...

    public function addConfiguration(NodeDefinition $node)
    {
      $node
        ->children()
            ->scalarNode('lifetime')->defaultValue(300)
        ->end();
    }
}

Теперь, в методе фабрики create(), аргумент $config будет содержаться ключ lifetime, установленный на 5 минут (300 секунд), разве что в конфигурации не будет установлено другое. Передайте этот аргумент вашему поставщику аутентификации для того, чтобы использовать его в деле.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use App\Security\Authentication\Provider\WsseProvider;

class WsseFactory implements SecurityFactoryInterface
{
    public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
    {
        $providerId = 'security.authentication.provider.wsse.'.$id;
        $container
            ->setDefinition($providerId, new ChildDefinition(WsseProvider::class))
            ->replaceArgument(0, new Reference($userProvider))
            ->replaceArgument(2, $config['lifetime']);
        // ...
    }

    // ...
}

Note

Класс WsseProvider теперь также будет должен принять третий аргумент конструктора - время жизни - который он должен использовать вместо жёстко закодированных 300 секунд. Этот шаг тут не показан.

Время жизни каждого WSSE-запроса теперь можно сконфигурировать и установить в любое желаемое значение для каждого брандмауэра.

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
# config/packages/security.yaml
security:
    # ...

    firewalls:
        wsse_secured:
            pattern:   ^/api/
            stateless: true
            wsse:      { lifetime: 30 }

Остальное зависит от вас! Любые релевантные объекты конфигурации могут быть определены в фабрике и использованы или переданы другим классам в контейнере.