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

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

Система користувацької аутентифікації з 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:

  • YAML
  • XML
  • PHP
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
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, щоб використовувати цей аутентифікатор:

  • YAML
  • XML
  • PHP
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

            # якщо ви хочете, відключіть зберігання користувача у сесії
            # 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/
# виконується контролер домашньої сторінки: сторінка виглядає нормально

Тепер дізнайтеся більше про те, що робить кожний метод.

Методи аутентифікатора 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
{
    // ГАРНА поведінка: аутентифікувати (тобто повертати 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 та створити ваш власний аутентифікатор(и) (так само, як у цій статті).