Система пользовательской аутентификации с Guard (пример API токена)

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

Система пользовательской аутентификации с Guard (пример API токена)

Аутентификация Guard может быть использована для:

и многого другого. В этом примере, мы построим систему аутентификации API-токена, чтобы мы могли больше узнать о Guard в деталях.

Tip

была представлена в Symfony 5.1, которая в итоге заменит Guards в Symfony 6.0.

Шаг 1) Подготовьте ваш класс пользователя

Предсавьте, что вы хотите создать API там, где ваши клиенты будут отправлять заголовок X-AUTH-TOKEN по каждому запросу с их API-токеном. Ваша задача - прочитать это и найти ассоциированного пользователя (если он есть).

Для начала, убедитесь, что вы следовали основному руководству Безопасности, чтобы создать ваш класс User. Затем добавьте свойство apiToken прямо в ваш класс User (команда make:entity - хороший способ сделать это):

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

  // ...

  class User implements UserInterface
  {
      // ...

+     /**
+      * @ORM\Column(type="string", unique=true, nullable=true)
+      */
+     private $apiToken;

      // методы геттера и сеттера
  }

Не забудьте сгенерировать и выполнить миграцию:

1
2
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate

Затем, сконфигурируйте вашего "поставщика пользователей", чтобы использовать это новое свойство apiToken:

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

    providers:
        your_db_provider:
            entity:
                class: App\Entity\User
                property: apiToken

    # ...

Шаг 2) Создайте класс аутентификатора

Чтобы создать пользователькую систему аутентификации, просто создайте класс и заставьте его реализовывать GuardAuthenticatorInterface. Или, расширьте более простой класс AbstractGuardAuthenticator.

Это требует от вас реализации нескольких методов:

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;

class TokenAuthenticator extends AbstractGuardAuthenticator
{
    private $em;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    /**
     * Вызывается по каждому запросу, чтобы решить должен ли этот аутентификатор
     * быть использован для запроса. Возвращение `false` приведет к пропуску этого
     * аутентификатора.
     */
    public function supports(Request $request): bool
    {
        return $request->headers->has('X-AUTH-TOKEN');
    }

    /**
     * Вызывается по каждому запросу. Верните те сертификаты, которые вы
     * хотите передать getUser(). Возвращение "null" приведёт к пропуску
     * аутентификатора.
     */
    public function getCredentials(Request $request)
    {
        return $request->headers->get('X-AUTH-TOKEN');
    }

    public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
    {
        if (null === $credentials) {
            // Заголовок токена был пустым, аутентификация неуспешна с HTTP
            // cтатус-кодом 401 "Unauthorized"
            return null;
        }

        // Идентификатор пользователя в этом случае apiToken, см. ключевое `property`
        // вашего `your_db_provider` в `security.yaml`.
        // Если это возвращает пользователя, далее вызывается checkCredentials():
        return $userProvider->loadUserByIdentifier($credentials);
    }

    public function checkCredentials($credentials, UserInterface $user): bool
    {
        // Проверить идентификационные данные - например, убедиться, что пароль валиден.
        // В случае API-токена, проверка идентификационных данных не нужна.

        // Вернуть `true`, чтобы вызвать успех аутентификации
        return true;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response
    {
        // при успехе, позвольте запросу продолжаться
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            // сначала вы можете захотеть настроить или запутать сообщение
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData())

            // или перевести это сообщение
            // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

    /**
     * Вызывается когда нужна аутентификаци, но не отправляется
     */
    public function start(Request $request, AuthenticationException $authException = null): Response
    {
        $data = [
            // вы можете перевести это сообщение
            'message' => 'Authentication Required'
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

    public function supportsRememberMe(): bool
    {
        return false;
    }
}

Хорошая работа! Каждый метод разъясняется ниже: Методы аутентификатора Guard.

Шаг 3) Сконфигурируйте аутентификатор

Чтобы закончить это, убедитесь, что ваш аутентификатор зарегистрирован, как сервис. Если вы используете конфигурацию services.yml по умолчанию , то это происходит автоматически.

Наконец, сконфигурируйте ваш ключ firewalls в security.yml, чтобы использовать этот аутентификатор:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# config/packages/security.yaml
security:
    # ...

    firewalls:
        # ...

        main:
            anonymous: true
            lazy: true
            logout: ~

            guard:
                authenticators:
                    - App\Security\TokenAuthenticator

            # if you want, disable storing the user in the session
            # stateless: true

            # ...

Вы сделали это! Теперь у вас есть полностью функциональная система аутентификации API-токена. Если ваша домашняя страница требовала ROLE_USER, то вы можете начинать тестировать её при разных условиях:

1
2
3
4
5
6
7
8
9
10
11
# тестировать без токена
curl http://localhost:8000/
# {"message":"Authentication Required"}

# тестировать с плохим токеном
curl -H "X-AUTH-TOKEN: FAKE" http://localhost:8000/
# {"message":"Username could not be found."}

# тестировать с рабочим токеном
curl -H "X-AUTH-TOKEN: REAL" http://localhost:8000/
# the homepage controller is executed: the page loads normally

Теперь, узнайте больше о том, что делает каждый метод.

Методы аутентификатора Guard

Каждый аутентификатор требует следующие методы:

supports(Request $request)
Вызывается при каждам запросе, и ваша работа - решить. должен ли быть использован аутентификатор для этого запроса (вернуть true), или его нужно пропустить (вернуть false).
getCredentials(Request $request)
Ваша задача - считывать токен (или то, что является вашей информацией "аутентификации") из запроса и возвращать его. Эти идентификационные данные передаются getUser().
getUser($credentials, UserProviderInterface $userProvider)
Если getCredentials() возвращает ненулевое значение, то вызывается этот метод, и его значение передаётся, как аругмент $credentials. Ваша задача - вернуть объект, реализующий UserInterface. Если вы это сделаете, то будет вызван checkCredentials(). Если вы вернёте null (или вызовете исключение AuthenticationException), то аутентификация будет неуспешной.
checkCredentials($credentials, UserInterface $user)
Если getUser() возвращает объект Пользователя, то будет вызван этот метод. Ваша задача - убедиться, что сертификаты правильные. В форме входа, именно тут вы будете проверять, правильный ли пароль для этого пользователя. Чтобы пройти аутентификацию, передайте true. Если вы вернёте что-либо другое (или вызовете исключение AuthenticationException), то аутентификация будет неуспешной.
onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
Вызывается после успешной аутентификации и ваша задача - либо вернуть объект Response, который будет отправлен клиенту, или null, чтобы продолжить дальше (например, позволить вызов маршрута или контроллера, как обычно) Так как это - API, в котором каждый запрос аутентифицируется сам, то вы хотите вернуть null.
onAuthenticationFailure(Request $request, AuthenticationException $exception)
Вызывается, если аутентификация неуспешна. Ваша задача - вернуть объект Response, который должен быть отправлен клиенту. $exception сообщит вам о том, что пошло не так во время аутентификации.
start(Request $request, AuthenticationException $authException = null)
Вызывается, если клиент заходит на URI/ресурс, который требует аутнетификации, но детали аутентификации не были отправлены (т.е. вы вернули null из getCredentials()). Ваша задача - вернуть объект Response, который помогает пользователю аутентифицироваться (например, ответ 404, сообщающий "токен отсутствует!").
supportsRememberMe()
Если вы хотите поддержать функцию "запомнить меня", верните true из этого метода. Вам всё равно надо будет активировать remember_me в вашем брандмауэре, чтобы он работал. Так как это API без запоминания состояния, то вы не хотите поддерживать функцию "запомнить меня" в этом примере.
createAuthenticatedToken(UserInterface $user, string $providerKey)
Если вы реализуете GuardAuthenticatorInterface вместо расширения класса AbstractGuardAuthenticator, то вам нужно реализовать этот метод. Он будет вызван после успешной аутентифакции, чтобы создать и вернуть токен (класс, реализующий GuardTokenInterface) для пользователя, который был поставлен в качестве первого аргумента.

Изображение ниже отображает, как Symfony вызывает методы аутентификатора Guard:

Настраивание сообщений об ошибке

Когда вызвыается onAuthenticationFailure(), он передаётся AuthenticationException, который описывает как произошла неудача аутентификации через метод $e->getMessageKey()$e->getMessageData()). Сообщения будут разными, в зависимости от того, где потерпит неудачу аутентификация (т.е. getUser() против checkCredentials()).

Однако вы можете с лёгкостью вернуть пользовательское сообщение, вызвав CustomUserMessageAuthenticationException. Вы можете вызвать его из getCredentials(), getUser() или checkCredentials(), чтобы вызвать неудачу:

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
// src/Security/TokenAuthenticator.php
namespace App\Security;

// ...

use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;

class TokenAuthenticator extends AbstractGuardAuthenticator
{
    // ...

    public function getCredentials(Request $request)
    {
        // ...

        if ($token == 'ILuvAPIs') {
            throw new CustomUserMessageAuthenticationException(
                'ILuvAPIs is not a real API key: it\'s just a silly phrase'
            );
        }

        // ...
    }

    // ...
}

В этом случае, так как "ILuvAPIs" - нелепый API-ключ, вы можете включить сюрприз для пользователя, в виде пользовательского сообщения, если кто-то попробует так сделать:

1
2
curl -H "X-AUTH-TOKEN: ILuvAPIs" http://localhost:8000/
# {"message":"ILuvAPIs - не настоящий API-ключ: это просто глупая фраза"}

Аутентификация пользователя вручную

Иногда вам может захотеться вручную аутентифицировать пользователя - например, после того, как пользователь завершит регистрацию. Чтобы сделать это, используйте ваш аутентификатор и сервис под названием GuardAuthenticatorHandler:

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/Controller/RegistrationController.php
namespace App\Controller;

// ...
use App\Security\LoginFormAuthenticator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Guard\GuardAuthenticatorHandler;

class RegistrationController extends AbstractController
{
    public function register(LoginFormAuthenticator $authenticator, GuardAuthenticatorHandler $guardHandler, Request $request): Response
    {
        // ...

        // после валидации пользователя и его сохранения в базу данных
        // аутентифицируйте пользователя и используйте onAuthenticationSuccess в аутентификаторе
        return $guardHandler->authenticateUserAndHandleSuccess(
            $user,          // объект Пользователь, который вы только что создали
            $request,
            $authenticator, // аутентификатор, чей onAuthenticationSuccess вы хотите использовать
            'main'          // имя вашего файерволла в security.yaml
        );
    }
}

Избежание аутентификации в браузере по каждому запросу

Если вы создаете систеу входа Guard, которая используется браузером, и вы испытываете проблемы с сессией или CSRF-токенами, причиной может быть плохое поведение вашего аутентификатора. Когда аутентификатор Guard должен быть использован браузером, вы не должны аутентифицировать пользователя по каждому запросу. Другими словами, вам нужно убедиться, что метод supports() возвращает true только тогда, когда вам действительно нужно аутентифицировать пользователя. Почему? Потому что когда supports() возвращает true (а аутентификация соответственно успешна), из соображений безопасности, сессия пользователя "мигрирует" на новый id сессии.

Это пограничный случай, и если у вас нет проблем с сессией или CSRF-токенами, вы можете игнорировать это. Вот пример хорошего и плохого поведения:

1
2
3
4
5
6
7
8
9
10
public function supports(Request $request): bool
{
    // ХОРОШЕЕ поведение: аутентифицировать (т.e. возвращать true) только по конкретному маршруту
    return 'login_route' === $request->attributes->get('_route') && $request->isMethod('POST');

    // например, ваша система входа аутентифицирует по IP-адресу пользователя
    // ПЛОХОЕ поведение: итак, вы решили *всегда* возвращать true, чтобы првоерять
    // IP-адрес пользователя по каждому запросу
    return true;
}

Проблема возникает, когда ваш аутентификтор, основанный на браузере, пытается аутентифицировать пользователя по каждому запросу - вроде как в примере, основанном на IP-адресе, выше. Существует 2 возможных решения:

  1. Если вам не нужно хранить аутентификацию в сессии, установите stateless: true под вашим файерволлом.
  2. Обновите ваш аутентификатор, чтобы он избегал аутентификации, если пользователь уже аутентифицирован:
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
// src/Security/MyIpAuthenticator.php
  // ...

+ use Symfony\Component\Security\Core\Security;

  class MyIpAuthenticator
  {
+     private $security;

+     public function __construct(Security $security)
+     {
+         $this->security = $security;
+     }

      public function supports(Request $request): bool
      {
+         // если уже существует аутентифицированный пользователь (скорее всего благодаря сессии)
+         // вернуть false и пропустить аутентификацию: в ней нет необходимости.
+         if ($this->security->getUser()) {
+             return false;
+         }

+         // пользователь не выполнил вход в систему, поэтому аутентификтор должен продолжать
+         возвращать true;
      }
  }

Если вы используете автомонтирование, сервис Security будет автоматически передан вашему аутентификатору.

Часто задаваемые вопросы

Может ли у меня быть несколько аутентификаторов?
Да! Но если это ваш случай, то вам понадобиться выбрать один из них, чтобы он был вашей точкой входа ("entry_point"). Это означает, что вам понадобится выбрать, какой метод аутентификатора start() должен быть вызван, когда анонимный пользователь пытается получить доступ к защищённому ресурсу. Чтобы узнать больше, смотрите Как использовать несколько аутентификаторов защиты.
Могу ли я использовать это с form_login?
Да! form_login - это один из способов аутентифицировать пользователя, так что вы можете использовать его и потом добавить один или более аутентификаторов. Использование аутентификатора защиты не конфликтует ни с одним другим способом аутентификации.
Могу ли я использовать это с FOSUserBundle?
Да! На самом деле, FOSUserBundle не работает с безопасностью, он просто предоставляет вам объект User и некоторые маршруты и контроллеры, чтобы помочь с выполнением входа, регистрацией, забытым паролем и т.д. Когда вы используете FOSUserBundle, вы обычно используете form_login для аутентификации пользователя. Вы можете продолжать делать это (смотрите предыдущий вопрос), или использовать объект User их FOSUserBundle и создать ваш собственный authenticator(s) (так же, как в этой статье).