Как аутентифицировать пользователей с ключами API

Tip

Посмотрите статью Custom Authentication System with Guard (API Token Example), чтобы узнать о более простом гибком способе выполнить такие пользовательские задачи аутентификации, как эта.

На сегодняшний день, достаточно необычно аутентифицировать пользователя через ключ API (например, при разработке веб-сервиса). Ключ API предоставляется для каждого запроса и передаётся в качестве параметра строки запроса или через HTTP-заголовок.

Аутентификатор ключа API

Аутентификация пользователя на основании информации запроса должна быть проведена с помощью механизма предварительной аутентификации. SimplePreAuthenticatorInterface позволяет вам с лёгкостью реализовывать такую схему.

Ваша конкретная ситуация может отличаться, но в этом примере, токен считывается из параметра запроса apikey, правильное имя пользователя загружается из этого значение, а потом создаётся объект Пользователь:

// src/Security/ApiKeyAuthenticator.php
namespace App\Security;

use App\Security\ApiKeyUserProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\SimplePreAuthenticatorInterface;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
    public function createToken(Request $request, $providerKey)

        // искать параметр запроса apikey
        $apiKey = $request->query->get('apikey');

        // или, если вы хотите использовать заголовок "apikey", то сделайте что-то вроде этого:
        // $apiKey = $request->headers->get('apikey');

        if (!$apiKey) {
            throw new BadCredentialsException();

            // или, чтобы просто пропустить аутентификацию ключа api
            // вернуть null;
        }

        return new PreAuthenticatedToken(
            'anon.',
            $apiKey,
            $providerKey
        );
    }

    public function supportsToken(TokenInterface $token, $providerKey)
    {
        return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
    }

    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof ApiKeyUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of ApiKeyUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $apiKey = $token->getCredentials();
        $username = $userProvider->getUsernameForApiKey($apiKey);

        if (!$username) {
            // ВНИМАНИЕ: это сообщение будет возвращено клиенту
            // (так что не вводите здесь недоверенные сообщения / строки ошибок)
            throw new CustomUserMessageAuthenticationException(
                sprintf('API Key "%s" does not exist.', $apiKey)
            );
        }

        $user = $userProvider->loadUserByUsername($username);

        return new PreAuthenticatedToken(
            $user,
            $apiKey,
            $providerKey,
            $user->getRoles()
        );
    }
}

Как только вы всё сконфигурируете, вы сможете аутентифицировать путём добавления параметра apikey parameter в строку запроса, как http://example.com/api/foo?apikey=37b51d194a7513e45b56f6524f2d51f2.

Процесс аутентификации имеет несколько шагов и ваша реализация скорее всего будет отличаться:

1. createToken

На раннем этапе цикла запроса, Symfony вызывает createToken(). Ваша задача здесь - создать объект токена, который содержит всю информацию из запроса, которая вам нужна для аутентификации пользователя (например, параметр запроса apikey). Если этой информации нет, вызов исключения BadCredentialsException приведёт к неудаче аутентификации. Лучше вернуть null вместо того, чтобы просто пропускать аутентификацию, чтобы Symfony могла использовать резервный метод аутентификации, если он существует.

Caution

В случае, если вы возвращаете null из вашего метода createToken(), Symfony передаёт этот запрос следующему проводнику аутентификации. Если вы не сконфигурировали никакого другого проводника, включите опцию anonymous в вашем брандмауэре. Таким образом, Symfony выполняет анонимного проводника аутентификации, и вы получите AnonymousToken.

2. supportsToken

3. authenticateToken

Если supportsToken() возвращает true, Symfony вызовет authenticateToken(). Ключевым моментом является $userProvider - внешний класс, который помогает вам загружать информацию о пользователе. Вы узнаете больше о нём далее.

В этом конкретном примере, в authenticateToken() происходит следующее:

  1. Во-первых, вы используете $userProvider чтобы каким-то образом найти $username, соответствующий $apiKey;
  2. Во-вторых, вы снова используете $userProvider, чтобы загрузить или создать объект User для $username;
  3. Наконец, вы создаёте токен аутентификации (т.е. токен как минимум с одной ролью), который имеет правильные роли и присоединённый объект Пользователя (User).

Целью является использование $apiKey для того, чтобы найти или создать объект User. Как вы это сделаете (например, запрос в DB) иточный класс вашего объекта User могут разниться. Эти отличия будут наиболее очевидны в вашем поставщике пользователя.

Поставщик пользователя

$userProvider может быть любим поставщиком пользователя (см. /security/custom_provider). В этом примере, $apiKey используется, чтобы как-то найти имя пользователя для пользователя. Эта работа проводится в методе getUsernameForApiKey(), который полностью создаётся для этого случая использования (т.е. это не метод, который используется базовой системой поставщика пользователей Symfony).

$userProvider может выглядеть как-то так:

// src/Security/ApiKeyUserProvider.php
namespace App\Security;

use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;

class ApiKeyUserProvider implements UserProviderInterface
{
    public function getUsernameForApiKey($apiKey)
    {
        // Искать имя пользователя на основании токена в DB через
        // вызов API, или сделать что-то абсолютно другое
        $username = ...;

        return $username;
    }

    public function loadUserByUsername($username)
    {
        return new User(
            $username,
            null,
            // роли пользователя - вы можете решить определить
            // их как-то динамически, основываясь на пользователе
            array('ROLE_API')
        );
    }

    public function refreshUser(UserInterface $user)
    {
        // это используется для сохранения аутентификации в сессии
        // но в этом примере, токен отправляется в каждом запросе,
        // так что аутентификация может быть без запоминания состояния.
        // Вызов этого исключения правильный для того, чтобы сделать всё
        // без запоминания состояния
        throw new UnsupportedUserException();
    }

    public function supportsClass($class)
    {
        return User::class === $class;
    }
}

Далее, убедитесь, что этот класс зарегистрирован, как сервис. Если вы используете конфигурацию services.yaml по умолчанию, то это происходит автоматически. Немного позже, вы будете ссылаться на этот сервис в вашей конфигурации security.yaml.

Note

Прочитайте соответствующую статью, чтобы узнать, как создать пользовательского поставщика пользователей.

Логика внутри getUsernameForApiKey() может быть на ваш вкус. Вы можете как-либо трансформировать ключ API (например, 37b51d) в имя пользователя (например, jondoe), поискав какую-то информацию в таблице DB “токен”.

То же самое относится к loadUserByUsername(). В этом примере, базовый класс Symfony User просто создаётся. Это имеет смысл, если вам не нужно хранить дополнительной информации о вашем объекте пользователя (например, firstName). Но если вам это нужно, у вас может быть ваш собственный класс пользователя, который вы создаёте и наполняете путём запросов в DB. Это позволит вам иметь пользовательские данные в объекте User.

Наконец, просто убедитесь, что supportsClass() возвращает true для объектов Пользователь, с тем же классом, как и те пользователи, которых вы возвращаете в loadUserByUsername().

Если ваша аутентификация без запоминания состояния, как в этом примере, (т.е. вы ожидаете, что пользователь будет отправлять ключ API с каждым запросом, и поэтому вы не сохраняете логин в сессии), то вы можете просто выдать исключение UnsupportedUserException в refreshUser().

Note

Если вы хотите хранить данные аутентификации в сессии так, чтобы ключ не надо было отправлять по каждому запросу, смотрите security-api-key-session.

Обработка неудачи аутентификации

Для того, чтобы ваш ApiKeyAuthenticator правильно отображал http-статус 401 при неудаче аутентификации или неправильной аккредитации, вам понадобится реализовать AuthenticationFailureHandlerInterface в вашем Аутентификаторе. Это предоставит метод onAuthenticationFailure(), который вы можете использовать для создания ошибки Response:

// src/Security/ApiKeyAuthenticator.php
namespace App\Security;

use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
{
    // ...

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        return new Response(
            // содержит информацию о том, *почему* не удалась аутентификация
            // используйте это, или верните ваше собственное сообщение
            strtr($exception->getMessageKey(), $exception->getMessageData()),
            401
        );
    }
}

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

Когда у вас будет полностью настроен ApiKeyAuthenticator, вам нужно будет зарегистрировать его как сервис. Если вы используете конфигурацию services.yaml по умолчанию, то это случится автоматически.

Последний шаг - активация вашего аутентификатора и пользовательского поставщика пользователей в разделе firewalls вашей конфигурации безопасности, используя ключи simple_preauth и provider:

  • YAML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    # config/packages/security.yaml
    security:
        # ...
    
        providers:
            api_key_user_provider:
                id: App\Security\ApiKeyUserProvider
    
        firewalls:
            main:
                pattern: ^/api
                stateless: true
                simple_preauth:
                    authenticator: App\Security\ApiKeyAuthenticator
                provider: api_key_user_provider
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <!-- config/packages/security.xml -->
    <?xml version="1.0" encoding="UTF-8"?>
    <srv:container xmlns="http://symfony.com/schema/dic/security"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:srv="http://symfony.com/schema/dic/services"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            http://symfony.com/schema/dic/services/services-1.0.xsd">
        <config>
            <!-- ... -->
    
            <provider name="api_key_user_provider" id="App\Security\ApiKeyUserProvider" />
    
            <firewall name="main"
                pattern="^/api"
                stateless="true"
                provider="api_key_user_provider"
            >
                <simple-preauth authenticator="App\Security\ApiKeyAuthenticator" />
            </firewall>
        </config>
    </srv:container>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // config/packages/security.php
    
    // ...
    use App\Security\ApiKeyAuthenticator;
    use App\Security\ApiKeyUserProvider;
    
    $container->loadFromExtension('security', array(
        'providers' => array(
            'api_key_user_provider' => array(
                'id' => ApiKeyUserProvider::class,
            ),
        ),
        'firewalls' => array(
            'main' => array(
                'pattern'        => '^/api',
                'stateless'      => true,
                'simple_preauth' => array(
                    'authenticator'  => ApiKeyAuthenticator::class,
                ),
                'provider' => 'api_key_user_provider',
            ),
        ),
    ));
    

Если вы определили access_control, обязательно добавьте новую запись:

  • YAML
    1
    2
    3
    4
    5
    6
    # config/packages/security.yaml
    security:
        # ...
    
        access_control:
            - { path: ^/api, roles: ROLE_API }
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    <!-- config/packages/security.xml -->
    <?xml version="1.0" encoding="UTF-8"?>
    <srv:container xmlns="http://symfony.com/schema/dic/security"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:srv="http://symfony.com/schema/dic/services"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            http://symfony.com/schema/dic/services/services-1.0.xsd">
        <config>
            <rule path="^/api" role="ROLE_API" />
        </config>
    </srv:container>
    
  • PHP
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // config/packages/security.php
    $container->loadFromExtension('security', array(
        'access_control' => array(
            array(
                'path' => '^/api',
                'role' => 'ROLE_API',
            ),
        ),
    ));
    

Вот и всё! Теперь, ваш ApiKeyAuthenticator должен вызываться в начале каждого запроса, после чего будет происходить ваш процесс аутентификации.

Параметр конфигурации stateless предотвращает Symfony от попыток сохранить информацию аутентификации в сессии, что необязательно, так как клиент будет отправлять apikey по каждому запросу. Если вам нужно сохранить аутентификацию в сесии, то продолжайте читать!

Хранение аутентификации в сессии

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

Чтобы это работало, для начала, удалите ключ stateless из конфигурации вашего брандмауэра или установите его как false:

  • YAML
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # config/packages/security.yaml
    security:
        # ...
    
        firewalls:
            secured_area:
                pattern: ^/api
                stateless: false
                # ...
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    <!-- config/packages/security.xml -->
    <?xml version="1.0" encoding="UTF-8"?>
    <srv:container xmlns="http://symfony.com/schema/dic/security"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:srv="http://symfony.com/schema/dic/services"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            http://symfony.com/schema/dic/services/services-1.0.xsd">
        <config>
            <!-- ... -->
    
            <firewall name="secured_area"
                pattern="^/api"
                stateless="false"
            >
            </firewall>
        </config>
    </srv:container>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    // config/packages/security.php
    
    // ..
    $container->loadFromExtension('security', array(
        'firewalls' => array(
            'secured_area'       => array(
                'pattern'        => '^/api',
                'stateless'      => false,
                // ...
            ),
        ),
    ));
    

Нессмотря на то, что токен хранится в сессии, аккредитация - в этом случае ключ API (т.е. $token->getCredentials()) - не хранится в сессии по причинам безопасности. Чтобы воспользоваться преимуществами сессии, обновите ApiKeyAuthenticator, чтобы увидеть, имеет ли сохранённый токен валидный объект Пользователь, который можно использовать:

// src/Security/ApiKeyAuthenticator.php

// ...
class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
    // ...
    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof ApiKeyUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of ApiKeyUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $apiKey = $token->getCredentials();
        $username = $userProvider->getUsernameForApiKey($apiKey);

        // User - это сущность, которая представляет вашего пользователя
        $user = $token->getUser();
        if ($user instanceof User) {
            return new PreAuthenticatedToken(
                $user,
                $apiKey,
                $providerKey,
                $user->getRoles()
            );
        }

        if (!$username) {
            // это сообщение будет возвращено клиенту
            throw new CustomUserMessageAuthenticationException(
                sprintf('API Key "%s" does not exist.', $apiKey)
            );
        }

        $user = $userProvider->loadUserByUsername($username);

        return new PreAuthenticatedToken(
            $user,
            $apiKey,
            $providerKey,
            $user->getRoles()
        );
    }
    // ...
}

Сохранение информации аутентификации в сесси работает так:

  1. В конце каждого запроса, Symfony сериализирует объект токена (возвращённого из authenticateToken()), который также сериализирует объект User (так как он установлен в свойстве токена);
  2. В следующем запросе токен десериализируется и десерилизованный объект User передаётся функции refreshUser() поставщика пользователя.

Второй шаг очень важен: Symfony вызывает refreshUser() и передаёт вам объект пользователя, который был сериализован в сессии. Если ваши пользователи хранятся в DB, то вы можете захотеть повторно запросить свежую версию пользователя, чтобы убедиться, что он не устарел. Но вне зависимости от ваших требований, refreshUser() теперь должен возвращать объект пользователя:

// src/Security/ApiKeyUserProvider.php

// ...
class ApiKeyUserProvider implements UserProviderInterface
{
    // ...

    public function refreshUser(UserInterface $user)
    {
        // $user - это User, который вы установили в токене внутри authenticateToken()
        // после того, как он был десериализован из сессии

        // вы можете использовать $user, чтобы запросить свежего пользователя у DB
        // $id = $user->getId();
        // используйте $id, чтобы сделать запрос

        // если вы *не* считываете с DB и просто создаёте
        // объект User (как в этом примере), вы можете просто вернуть его
        return $user;
    }
}

Note

Вы также захотите убедиться, что ваш объект User сериализируется правильно. Если ваш объект User имеет частные свойства, PHP не может их сериализовать. В таком случае, вы можете получить обратно объект Пользователя, который имеет значение null для каждого свойства. Чтобы увидеть пример, смотрите /security/entity_provider.

Аутентификация только для определённых URL

Эта статья предполагала, что вы хотите искать аутентификацию apikey в каждом запросе. Но в некоторых ситуациях (как в потоке OAuth), вам нужно на самом деле искать информацию аутентификации только тогда, когда пользователь достиг определённого URL (например, URL перенаправления в OAuth).

К счастью, справиться в этой ситуацией легко: просто проверьте, какой текущий URL перед тем, как создавать токен в createToken():

// src/Security/ApiKeyAuthenticator.php

// ...
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\HttpFoundation\Request;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
    protected $httpUtils;

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

    public function createToken(Request $request, $providerKey)
    {
        // установите один URL, где мы должны искать информацию авторизации
        // и возвращать токен только, если мы на этом URL
        $targetUrl = '/login/check';
        if ($request->getPathInfo() !== $targetUrl)
            return;
        }

        // ...
    }
}

Вот и всё! Повеселитесь!

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.