Как использовать избирателей для проверки разрешений пользователей

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

Как использовать избирателей для проверки разрешений пользователей

Избиратели - это наиболее мощный в Symfony способ управлять разрешениями. Они позволяют вам централизовать всю логику разрешений, а затем использовать ее повторно во множестве мест.

Однако, если вы не используете разрешения повторно, или ваши правила общие, вы всегда можете разместить такую логику прямо в вашем контроллере. Вот пример того, как это может выглядеть, если вы хотите сделать маршрут доступным только для "владельца" (owner):

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
    {
        // ...
    }
}

Атрибут #[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
// 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, 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, благодаря `supports()`
        /** @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
    {
        // это предполагает, что объект Post имеет метод `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() изнутри вашего избирателя - например, вы хотите увидеть, имеет ли текущий пользователь ROLE_SUPER_ADMIN. Это возможно с помощью внедрения 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.