如何使用 Voter 检查用户权限
Voter 是 Symfony 管理权限最强大的方式。它们允许你集中所有权限逻辑,然后在许多地方重用它们。
然而,如果你不重用权限或你的规则很简单,你总是可以直接将该逻辑放入你的控制器中。这是一个示例,展示了如果你想让一个路由仅对“所有者”可访问,它会是什么样子
1 2 3 4 5 6 7
// src/Controller/PostController.php
// ...
// inside your controller action
if ($post->getOwner() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
从这个意义上说,本页通篇使用的以下示例是 Voter 的最小示例。
以下是 Symfony 如何与 Voter 协同工作:每次你在 Symfony 的授权检查器上使用 isGranted()
方法或在控制器中调用 denyAccessUnlessGranted()
(它使用授权检查器),或者通过 访问控制时,都会调用所有 Voter。
最终,Symfony 采用所有 Voter 的响应,并根据 应用程序中定义的策略做出最终决定(允许或拒绝访问资源),该策略可以是:affirmative、consensus、unanimous 或 priority。
Voter 接口
自定义 Voter 需要实现 VoterInterface 或扩展 Voter,这使得创建 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;
}
提示
对于执行大量权限检查的应用程序,多次检查每个 Voter 可能会很耗时。为了提高这些情况下的性能,你可以让你的 Voter 实现 CacheableVoterInterface。这允许访问决策管理器记住 Voter 支持的属性和主题类型,以便每次只调用需要的 Voter。
设置: 在控制器中检查访问权限
假设你有一个 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')]
// check for "view" access: calls all voters
#[IsGranted('view', 'post')]
public function show(Post $post): Response
{
// ...
}
#[Route('/posts/{id}/edit', name: 'post_edit')]
// check for "edit" access: calls all voters
#[IsGranted('edit', 'post')]
public function edit(Post $post): Response
{
// ...
}
}
#[IsGranted]
属性或 denyAccessUnlessGranted()
方法(以及 isGranted()
方法)都会调用“voter”系统。目前,没有 Voter 会对用户是否可以“view”或“edit”一个 Post
进行投票。但是你可以创建你自己的 Voter,它可以使用你想要的任何逻辑来决定这一点。
创建自定义 Voter
假设决定用户是否可以“view”或“edit”一个 Post
对象的逻辑非常复杂。例如,User
总是可以编辑或查看他们创建的 Post
。如果 Post
被标记为“public”,任何人都可以查看它。这种情况下的 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 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
{
// these strings are just invented: you can use anything
const VIEW = 'view';
const EDIT = 'edit';
protected function supports(string $attribute, mixed $subject): bool
{
// if the attribute isn't one we support, return false
if (!in_array($attribute, [self::VIEW, self::EDIT])) {
return false;
}
// only vote on `Post` objects
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) {
// the user must be logged in; if not, deny access
return false;
}
// you know $subject is a Post object, thanks to `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 they can edit, they can view
if ($this->canEdit($post, $user)) {
return true;
}
// the Post object could have, for example, a method `isPrivate()`
return !$post->isPrivate();
}
private function canEdit(Post $post, User $user): bool
{
// this assumes that the Post object has a `getOwner()` method
return $user === $post->getOwner();
}
}
就是这样!Voter 完成了!接下来,配置它。
回顾一下,以下是对两个抽象方法的期望
Voter::supports(string $attribute, mixed $subject)
- 当调用
isGranted()
(或denyAccessUnlessGranted()
)时,第一个参数作为$attribute
传递到这里(例如ROLE_USER
,edit
),第二个参数(如果有)作为$subject
传递(例如null
,一个Post
对象)。你的工作是确定你的 Voter 是否应该对属性/主题组合进行投票。如果你返回 true,则将调用voteOnAttribute()
。否则,你的 Voter 就完成了:应该由其他 Voter 来处理。在这个例子中,如果属性是view
或edit
并且对象是Post
实例,你将返回true
。 voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token)
- 如果你从
supports()
返回true
,则会调用此方法。你的工作是返回true
以允许访问,返回false
以拒绝访问。$token
可用于查找当前用户对象(如果有)。在这个例子中,所有复杂的业务逻辑都包含在内以确定访问权限。
配置 Voter
要将 Voter 注入到安全层,你必须将其声明为服务并标记为 security.voter
。但是如果你正在使用 默认的 services.yaml 配置,那么这会自动为你完成!当你使用 view/edit 调用 isGranted() 并传递 Post 对象时,你的 Voter 将被调用,你可以控制访问权限。
在 Voter 内部检查角色
如果你想从你的 Voter 内部调用 isGranted()
怎么办 - 例如,你想查看当前用户是否具有 ROLE_SUPER_ADMIN
。这可以通过在你的 Voter 内部使用 访问决策管理器来实现。例如,你可以使用它来始终允许具有 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): bool
{
// ...
// ROLE_SUPER_ADMIN can do anything! The power!
if ($this->accessDecisionManager->decide($token, ['ROLE_SUPER_ADMIN'])) {
return true;
}
// ... all the normal voter logic
}
}
警告
在前面的示例中,避免使用以下代码来检查是否授予了角色权限
1 2 3 4 5 6 7
// DON'T DO THIS
use Symfony\Component\Security\Core\Security;
// ...
if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
// ...
}
Voter 内部的 Security::isGranted()
方法有一个明显的缺点:它不能保证检查是在与你的 Voter 中相同的令牌上执行的。令牌存储中的令牌可能已经更改或可能在稍后更改。始终使用 AccessDecisionManager
代替。
如果你正在使用 默认的 services.yaml 配置,你就完成了!Symfony 将在实例化你的 Voter 时自动传递 security.helper
服务(感谢自动装配)。
更改访问决策策略
通常,在任何给定时间只有一个 Voter 会投票(其余的将“弃权”,这意味着他们从 supports()
返回 false
)。但理论上,你可以让多个 Voter 为一个操作和一个对象投票。例如,假设你有一个 Voter 检查用户是否是网站的成员,第二个 Voter 检查用户是否年满 18 岁。
为了处理这些情况,访问决策管理器使用你可以配置的“策略”。有四种策略可用
affirmative
(默认)- 只要有一个 Voter 授予访问权限,就授予访问权限;
consensus (共识)
- 如果授予访问权限的 Voter 比拒绝访问权限的 Voter 多,则授予访问权限。如果票数相等,则决策基于
allow_if_equal_granted_denied
配置选项(默认为true
); unanimous (一致同意)
- 只有在没有 Voter 拒绝访问权限时才授予访问权限。
priority (优先级)
- 根据第一个不弃权的 Voter 的服务优先级来授予或拒绝访问权限;
无论选择何种策略,如果所有 Voter 都放弃投票,则决策基于 allow_if_all_abstain
配置选项(默认为 false
)。
在上述场景中,两个 Voter 都应该授予访问权限,以便授予用户读取帖子的权限。在这种情况下,默认策略不再有效,应该使用 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
{
// ...
}
}
提示
如果状态码不是 403,则会抛出 HttpException。