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

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

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

Избиратели - это наиболее мощный в 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, $subject);
    abstract protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token);
}

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
24
25
26
27
28
29
// src/Controller/PostController.php

// ...
class PostController extends AbstractController
{
    #[Route('/posts/{id}', name: 'post_show')]
    public function show($id): Response
    {
        // получить объект Post - например, запросить его
        $post = ...;

        // проверить разрешение "просмотра": вызов всех избирателей
        $this->denyAccessUnlessGranted('view', $post);

        // ...
    }

    #[Route('/posts/{id}/edit', name: 'post_edit')]
    public function edit($id): Response
    {
        // получить объект Post - например, запросить его
        $post = ...;

        // проверить разрешение "редактирования": вызов всех избирателей
        $this->denyAccessUnlessGranted('edit', $post);

        // ...
    }
}

Метод 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
// 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;

        switch ($attribute) {
            case self::VIEW:
                return $this->canView($post, $user);
            case self::EDIT:
                return $this->canEdit($post, $user);
        }

        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() изнутри вашего избирателя - например, вы хотите увидеть, имеет ли текущий пользователь 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
27
28
// src/Security/PostVoter.php

// ...
use Symfony\Component\Security\Core\Security;

class PostVoter extends Voter
{
    // ...

    private $security;

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

    protected function voteOnAttribute($attribute, $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. Вы можете установить это в конфигурации безопасности:

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

Пользовательская стратегия решений доступа

Если ни одна из встроенных стратегий вам не подходит, определите опцию strategy_service, чтобы использовать пользовательский сервис (ваш сервис должен реализовывать AccessDecisionStrategyInterface):

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

Пользовательский менеджер решения доступа

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

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