Як аутентифікувати користувачів з ключами API

Дата оновлення перекладу 2023-05-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 в рядок запиту як 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. Нарешті, ви створюєте токен аутентифікації (тобто токен як мінімум з однією роллю), який має правильні ролі та приєднаний об'єкт Користувача.

Ціллю є використання $apiKey для того, щоб знайти або створити об'єкт User. Як ви це зробите (наприклад, запит до бази даних) та точний клас вашого об'єкта 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)
    {
        // Шукати ім'я користувача на підставі токена в базі даних через
        // виклик 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), пошукавши якусь інформацію в таблиці бази даних "токен".

Те ж саме відноситься до loadUserByUsername(). У цьому прикладі, базовий клас Symfony User просто створюється. Це має сенс, якщо вам не потрібно зберігати додаткової інформації про ваш об'єкт користувача (наприклад, firstName). Але якщо вам це потрібно, у вас може бути ваш власний клас користувача, який ви створюєте та наповнюєте шляхом запитів до бази даних. Це дозволить вам мати користувацькі дані в об'єкті 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() та передає вам об'єкт користувача, який був сериалізований в сесії. Якщо ваші користувачі зберігаються у базі даних, то ви можете захотіти повторно запитати свіжу вресію користувача, щоб переконатися, що він не застарів. Але незалежно від ваших вимог, 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, щоб запитати свіжого користувача у бази даних
        // $id = $user->getId();
        // використайте $id, щоб зробити запит

        // якщо ви *не* зчитуєте з бази даних і просто створюєте
        // об'єкт 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;
        }

        // ...
    }
}

Ось і все! Розважайтеся!