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

Дата оновлення перекладу 2025-09-15

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

Виборці - це найпотужніший в 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
9
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
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, ?Vote $vote = null): bool;
}

7.3

Аргумент $vote методу voteOnAttribute() був представлений в Symfony 7.3.

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

Уявіть, що у вас є обʼєкт 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
    {
        // ...
    }
}

Атрибут #[IsGranted] або метод 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
67
68
69
70
71
72
73
74
75
76
// 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\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

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

    protected function supports(string $attribute, mixed $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, mixed $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, ?Vote $vote): bool
    {
        // припускає, що обʼєкт Post має метод `getAuthor()`
        if ($user === $post->getAuthor()) {
            return true;
        }

        $vote?->addReason(sprintf(
            'The logged in user (username: %s) is not the author of this post (id: %d).',
            $user->getUsername(), $post->getId()
        ));

        return false;
    }
}

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

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

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\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

class PostVoter extends Voter
{
    // ...

    public function __construct(
        private AccessDecisionManagerInterface $accessDecisionManager,
    ) {
    }

    protected function voteOnAttribute($attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
    {
        // ...

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

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

Warning

У попередньому прикладі уникайте використання наступного коду для перевірки того, чи надано ролі дозвіл:

1
2
3
4
5
6
7
// НЕ РОБІТЬ ТАК
use Symfony\Component\Security\Core\Security;
// ...

if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
    // ...
}

Метод Security::isGranted() всередині виборця має суттєвий недолік: він не гарантує, що перевірка виконується на тому ж токені, що і у вашому виборці. Токен у сховищі токенів міг змінитися або може змінитися за цей час. Завжди використовуйте AccessDecisionManager натомість.

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

Покращення продуктивності виборця

Якщо ваш додаток визначає багато виборців і перевіряє дозволи на багато об'єктів під час одного запиту, це може вплинути на продуктивність. У більшості випадків виборці цікавляться лише конкретними дозволами (атрибутами), такими як EDIT_BLOG_POST, або конкретними типами об'єктів, такими як User або Invoice. Ось чому Symfony може кешувати рішення виборця (тобто рішення застосувати або пропустити виборця для даного атрибуту або об'єкта).

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

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
use App\Entity\Post;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
// ...

class PostVoter extends Voter
{
    const VIEW = 'view';
    const EDIT = 'edit';

    protected function supports(string $attribute, mixed $subject): bool
    {
        // ...
    }

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

    // цей метод повертає true, якщо виборець застосовується до заданого атрибуту;
    // якщо він повертає false, Symfony не викличе його знову для цього атрибуту
    public function supportsAttribute(string $attribute): bool
    {
        return in_array($attribute, [self::VIEW, self::EDIT], true);
    }

    // цей метод повертає true, якщо виборець застосовується до заданого класу/типу обʼєкта;
    // якщо він повертає false, Symfony не викличе його знову для типу обʼєкта
    public function supportsType(string $subjectType): bool
    {
        // ви не можете використати просте порівняння Post::class === $subjectType,
        // тому що тип субʼєкта може бути класом проксі Doctrine
        return is_a($subjectType, Post::class, true);
    }
}

Зміна повідомлення та статус-кода, що повертаються

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

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

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.

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

Зазвичай, один виборець голосувати у будь-який заданий час (а решта будуть "утримутиватися", що означає, що вони повернуть 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
        # ...