Як використовувати виборців для перевірки доступів користувачів

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

Як використовувати виборців для перевірки доступів користувачів

Виборці - це найпотужніший в Symfony спосіб управляти дозволами. Вони дозволяють вам централізувати всю логіку дозволів, а потім використовувати її повторно у багатьох місцях.

Однак, якщо ви не використовуєте дозволи повторно, або ваші правила загальні, ви завжди можете розмістити таку логіку прямо у вашому контролері. Ось приклад того, як це може виглядати, якщо ви хочете зробити маршрут доступним лише для "власника":

1
2
3
4
5
6
7
// src/Controller/PostController.php
// ...

// всередині вашої дії контролера
if ($post->getOwner() !== $this->getUser()) {
    throw $this->createAccessDeniedException();
}

У такому сенсі, наступний приклад, використовуваний далі у цій статті, є мінімумом для виборців.

Ось як Symfony працює з виборцями: Всі виборці викликаються кожний раз, коли ви використовуєте метод isGranted() у перевірці автоирзації Symfony або викликаєте denyAccessUnlessGranted() у контролері (який викоритовує перевірку авторизації) або через контроль доступу .

В кінцевому рахунку, Symfony бере відповіді від усіх виборців та приймає остаточне рішення (про те, дозволити чи заборонити доступ до ресурсу) відповідно до
стратегії, визначеної у додатку , яка може бути: універсальною, консенсусною або одноголосною.

Інтерфейс виборця

Користувацький виборець повинен реалізовувати VoterInterface або розширювати Voter, що робить створення виборця ще легшим:

1
2
3
4
5
6
7
8
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

abstract class Voter implements VoterInterface
{
    abstract protected function supports(string $attribute, mixed $subject): bool;
    abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool;
}

Tip

Перевірка кожного виборця декілька разів може вимагати дуже багато часу для додатків, що виконують багато перевірок дозволів. Щоб покращити продуктивність у таких випадках,, ви можете зробити так, щоб ваші виборці реалізовували CacheableVoterInterface. Це надає доступ до менеджеру рішень, щоб запамʼятати атрибут та тип субʼєкта, який підтримується виборцем, для того, щоб викликати лише необхідних виборців кожний раз.

Установка: Перевірка доступу у контролері

Уявіть, що у вас є обʼєкт Post і вам потрібно вирішити, чи може поточний користувач редагувати або переглядати обʼєкт. У вашому контролері ви перевірите доступ з наступним кодом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/Controller/PostController.php

// ...
use Symfony\Component\Security\Http\Attribute\IsGranted;

class PostController extends AbstractController
{
    #[Route('/posts/{id}', name: 'post_show')]
    // перевірити на наявність доступу для "view": викликає всіх виборців
    #[IsGranted('view', 'post')]
    public function show(Post $post): Response
    {
        // ...
    }

    #[Route('/posts/{id}/edit', name: 'post_edit')]
    // перевірити на наявність доступу для "edit": викликає всіх виборців
    #[IsGranted('edit', 'post')]
    public function edit(Post $post): Response
    {
        // ...
    }
}

Метод denyAccessUnlessGranted() (а також метод isGranted()) робить виклик до системи "виборців". Зараз жодний виборрець не проголосує про те, чи може користувач "переглядати" або "редагувати" Post. Але ви можете створити вашого власного виборця, який вирішує це, викоирстовуючи будь-яку бажану вами логіку.

Створення користувацького виборця

Уявіть, що логіка для вирішення, чи може користувач "перегладяти" або "редагувати" обʼєкт Post, достатньо складна. Наприклад, User може завжди переглядати або редагувати Post, який він створил. А якщо Post відмічено як "публічний", то його може переглядати хто завгодно. Виборець для цієї ситуації виглядатиме так:

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

use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PostVoter extends Voter
{
    // ці рядки були просто придумані: ви можете використати що завгодно
    const VIEW = 'view';
    const EDIT = 'edit';

    protected function supports(string $attribute, $subject): bool
    {
        // якщо це не один з підтримуваних атрибутів, повертається false
        if (!in_array($attribute, [self::VIEW, self::EDIT])) {
            return false;
        }

        // голосувати лише за обʼєктами Post всередині цього виборця
        if (!$subject instanceof Post) {
            return false;
        }

        return true;
    }

    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();

        if (!$user instanceof User) {
            // користувач має бути у системі; якщо ні - відмовити у доступі
            return false;
        }

        // ви знаєте, що $subject - це обʼєкт Post, завдяки підтримці
        /** @var Post $post */
        $post = $subject;

        return match($attribute) {
            self::VIEW => $this->canView($post, $user),
            self::EDIT => $this->canEdit($post, $user),
            default => throw new \LogicException('This code should not be reached!')
        };
    }

    private function canView(Post $post, User $user): bool
    {
        // якщо вони можуть переглядати, то вони можуть редагувати
        if ($this->canEdit($post, $user)) {
            return true;
        }

        // обʼєкт Post може мати, наприклад, метод isPrivate(),
        return !$post->isPrivate();
    }

    private function canEdit(Post $post, User $user): bool
    {
        // припускає, що обʼєкт даних має метод getOwner(),
        return $user === $post->getOwner();
    }
}

Ось і все! Виборець готовий! Далі сконфігуруйте його .

Щоб підсумувати, ось те, що очікується від двох абстрактних методів:

Voter::supports(string $attribute, $subject)
Коли викликається isGranted() (або denyAccessUnlessGranted()), перший аргумент передається як $attribute (наприклад, ROLE_USER, edit), а другий аргумент (якщо він є) - як $subject (наприклад, null, обʼєкт Post). Ваша задача - визначити, чи повинен ваш виборець голосувати за комбінацією атрибут/субʼєкт. Якщо ви повернете "true", то voteOnAttribute() буде викликано. В іншому випадку, ваш виборець закінчив: якись інший виборець має це обробити. У цьому прикладі, ви повертаєте true, якщо атрибут - view або edit, і якщо обʼєкт - екземпляр Post.
voteOnAttribute(string $attribute, $subject, TokenInterface $token)
Якщо ви повертаєте true з supports(), то викликається цей метод. Ваша задача проста: повернути true, щоб дозволити доступ, і false, щоб його заборонити. $token може бути використано, щоб знайти поточний обʼєкт користувача (якщо він є). У цьому прикладі, вся складна бізнес-логіка включена для того, щоб визначити доступ.

Конфігурація виборця

Щоб впровадити вибораця у шар безпеки, ви повинні оголосити його як сервіс та тегувати його за допомогою security.voter. Але якщо ви використовуєте конфігурацію services.yml за замовчуванням , то це робиться за вас автоматично! Коли ви викликаєте isGranted() з переглядом/редагуванням та передаєте обʼєкт Post , ваш виборець буде виконано і ви зможете контролювати доступ.

Перевірка ролей всередині виборця

Що, якщо ви хочете викликати isGranted() зсередини вашого виборця - наприклад, за допомогою впровадження AccessDecisionManager у вашого виборця. Ви можете використати це для того, щоб, наприклад, завжди дозволяти доступ користувачу з ROLE_SUPER_ADMIN:

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/PostVoter.php

// ...
use Symfony\Bundle\SecurityBundle\Security;

class PostVoter extends Voter
{
    // ...

    public function __construct(
        private Security $security,
    ) {
    }

    protected function voteOnAttribute($attribute, mixed $subject, TokenInterface $token): bool
    {
        // ...

        // ROLE_SUPER_ADMIN може робити що завгодно! Оце сила!
        if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
            return true;
        }

        // ... вся логіка нормального виборця
    }
}

Якщо ви використовуєте конфігурацію services.yml за замовчуванням , то ви закінчили! Symfony автоматично передасть сервіс security.helper при інстанціюванні вашого виборця (завдяки автомонтуванню).

Зміна стратегії рішень доступу

Зазвичай, один виборець голосувати у будь-який заданий час (а решта будуть "утримутиватися", що означає, що вони повернуть false з supports()). Але в теорії, ви можете змусити декілька виборців голоссувати за однією дією та обʼєктом. Наприклад, уявіть, що у вас є один виборець, який перевіряє, чи є користувач членом цього сайту, і другий, який перевіряє, чи є вік цього користувача понад 18 років.

Щоб обробити ці випадки, менеджер рішень доступу використовує "стратегію", яку ви можете сконфігурувати. Існує три доступні стратегії:

affirmative (за замовчуванням)
Гарантує доступ, як тільки є один виборець, що гарантує доступ;
consensus
Гарантує доступ, якщо більше виборців гарантують доступ, ніж відмовляють у ньому. У випадку нічиєї, рішення засновується на опції конфігурації allow_if_equal_granted_denied (за замовчуванням true);
unanimous
Гарантуєе доступ лише якщо немає виборців, що його забороняють.
priority
Гарантує або відмовляє у доступі за першим виборцем, який не утримується, засновуючись на його пріоритетності сервісів;

Незалежно від обраної стратегії, якщо всі виборці утримуються від голосування, рішення засновується опції конфігурації allow_if_all_abstain (за замовчуванням false).

У вищеописаному сценарії, обидва виборця повинні гарантувати доступ, щоб гарантувати користувачу доступ до читання запису. У такоому випадку, стратегія за замовчуванням не валідна, і замість неї повинна бути використана unanimous. Ви можете встановити це у конфігурації безпеки:

1
2
3
4
5
# config/packages/security.yaml
security:
    access_decision_manager:
        strategy: unanimous
        allow_if_all_abstain: false

Користувацька стратегія рішення доступу

Якщо жодна із вбудованих стратегій не підходить для вашого випадку, визначте опцію strategy_service, щоб використати користувацький сервіс (ваш сервіс має реалізовувати AccessDecisionStrategyInterface):

1
2
3
4
5
# config/packages/security.yaml
security:
    access_decision_manager:
        strategy_service: App\Security\MyCustomAccessDecisionStrategy
        # ...

Користувацький менеджер рішення доступу

Якщо вам потрібно надати абсолютно користувацький менеджер рішення доступу, визначте опцію service, щоб використати користувацький сервіс в якості менеджера рішення доступу (ваш сервіс має реалізовувати AccessDecisionManagerInterface):

1
2
3
4
5
# config/packages/security.yaml
security:
    access_decision_manager:
        service: App\Security\MyCustomAccessDecisionManager
        # ...

Зміна повідомлення та повернутого статус-коду

За замовчуванням, атрибут #[IsGranted] буде викликати виключення AccessDeniedException і поверне статус-крд http 403 зі значенням Access Denied як повідомлення.

Однак, ви можете змінити цю поведінку, вказавши повідомлення і статус-код, що повертається:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/Controller/PostController.php

// ...
use Symfony\Component\Security\Http\Attribute\IsGranted;

class PostController extends AbstractController
{
    #[Route('/posts/{id}', name: 'post_show')]
    #[IsGranted('show', 'post', 'Post not found', 404)]
    public function show(Post $post): Response
    {
        // ...
    }
}

Tip

Якщо статус-код відрізняється від 403, то натомість буде викликано HttpException.