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

Дата обновления перевода 2023-07-24

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

Tip

Посмотрите статью Система користувацької аутентифікації з Guard (приклад API токена), чтобы узнать о более простом гибком способе выполнить такие пользовательские задачи аутентификации, как эта.

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

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

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

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

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
// 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

Після того, як Symfony викличе createToken(), вона викличе supportsToken() у вашому класі (і будь-яких інших слухачів аутентифікації), щоб з'ясувати, хто має працювати з токеном. Це просто спосіб дозволити кільком механізмам автентифікації бути використаними для одного брендмауера (отже, ви, наприклад, можете спочатку спробувати автентифікувати користувача через сертифікат або API-ключ, а як резерв - через форму входу).

В основному, вам потрібно просто переконатися, що цей метод повертає "true" для токена, який був створений createToken(). Ваша логіка, швидше за все, має виглядати так само, як цей приклад.

3. authenticateToken

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

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

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

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

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

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

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

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
// 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

Если вы хотите хранить данные аутентификации в сессии так, чтобы ключ не надо было отправлять по каждому запросу, смотрите Як аутентифікувати користувачів з ключами API.

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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:

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

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

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

    access_control:
        - { path: ^/api, roles: ROLE_API }

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

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

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

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

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

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

    firewalls:
        secured_area:
            pattern: ^/api
            stateless: false
            # ...

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

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
// 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() теперь должен возвращать объект пользователя:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 для каждого свойства. Чтобы увидеть пример, смотрите Як завантажувати користувачів безпеки з DB (постачальник сутностей).

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

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

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

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
// 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;
        }

        // ...
    }
}

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