Как использовать избирателей для проверки разрешений пользователей
Дата обновления перевода 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.