Як створити користувацького постачальника аутентифікації

Дата оновлення перекладу 2023-06-15

Як створити користувацького постачальника аутентифікації

Tip

Створення користувацької системи аутентифікації - це складно, і цей запис проведе вас через цей процес. Але залежно від ваших потреб, можливо ви зможете вирішити вашу проблему простішим шляхом або через суспільний пакет:

Якщо ви читали статтю про Security, то ви розумієте різницю, яку 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 розширює клас компонента Security 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) визначатиме функцію за вас.

Фабрика

Ви створили користувацький токен, користувацького слухача і користувацького постачальника. Тепер вам потрібно зв'язати їх між собою. Як зробити так, щоб унікальний постачальник був доступний для кожного брандмауера? Відповідь: використовуючи фабрику. Фабрика - це те, де ви підключаєтеся до компонента Security, повідомляєте йому ім'я вашого постачальника і будь-які опції конфігурації, доступні для нього. Для початку, ви повинні створити клас, який реалізує 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. Час визначити ці сервіси.

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.

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-запиту тепер можна конфігурувати і встановити у будь-яке бажане значення для кожного брандмауера.

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

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

Решта залежить від вас! Будь-які релевантні об'єкти конфігурації можуть бути визначені у фабриці та використані або передані іншим класам у контейнері.