Symfony Security RBAC动态权限校验实践
默认Symfony Security
的授权访问是根据security.php
文件中的$security->accessControl()
配置的。访问授权配置支持多种校验规则: path
、ip
、 port
、methods
、host
、request_matcher
、 attributes
、 route
,具体用法可以查看官方文档。
为什么需要动态权限校验?
假如我们的权限是通过security.php
来配置的,我们就需要事先设计好应用系统的角色以及它对应的权限配置。如果我们的权限数据都是从其它的地方获取而来,角色和角色权限都是可以变化的,那么就需要动态权限的校验,security.php
配置的方式就不能满足需要。
如何实现动态权限?
Symfony
Security组件提供一个投票者的接口来实现系统自定义的访问校验。
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;
}
假设用户是否可以 "查看 "或 "编辑 "帖子的逻辑非常复杂。 例如,用户总是可以编辑或查看自己创建的帖子。 如果帖子被标记为 “公开”,任何人都可以查看。 这样我们通过自定义投票人实现:
// 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;
}
// 对象不是帖子, 返回 false
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;
}
/** @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();
}
}
根据上面的投票者实现,在控制中就可以使用denyAccessUnlessGranted
方法请求授权访问权限。
// src/Controller/PostController.php
// ...
use App\Security\PostVoter;
class PostController extends AbstractController
{
#[Route('/posts/{id}', name: 'post_show')]
public function show(Post $post): Response
{
// check for "view" access: calls all voters
$this->denyAccessUnlessGranted(PostVoter::VIEW, $post);
// ...
}
#[Route('/posts/{id}/edit', name: 'post_edit')]
public function edit(Post $post): Response
{
// check for "edit" access: calls all voters
$this->denyAccessUnlessGranted(PostVoter::EDIT, $post);
// ...
}
}
配置投票者
要将投票者注入安全层,必须将其声明为服务,并标记为security.voter
。 但如果你使用的是默认的services.php
配置开启了自动注入,这将会自动完成! 比如上面的PostVoter
,当你使用 view/edit
调用 isGranted()
或者denyAccessUnlessGranted()
并传递一个Post
对象时,你的投票者将被调用,你可以控制访问权限。
动态菜单访问控制实践
动态权限大部分使用场景都是在管理后台方面,后台的权限控制颗粒度较小,管理的角色和权限都是随之变化的。比如为管理后台实现动态菜单。为不同角色保存不同的接口,比如dev
角色保存的接口地址["/a1", "/b/c", "/e"]
, beta
角色保存的接口地址["/b/c", "/c"]
,只需要在投票者这里将登录的用户角色取出来,再根据当前请求URL
匹配
是否在角色的权限列表中。
<?php
namespace OctopusPress\Bundle\Security;
use OctopusPress\Bundle\Entity\User;
use OctopusPress\Bundle\Repository\OptionRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class PermissionVoter extends Voter
{
private RouterInterface $router;
private OptionRepository $optionRepository;
public function __construct(
RouterInterface $router,
OptionRepository $optionRepository,
) {
$this->router = $router;
$this->optionRepository = $optionRepository;
}
protected function supports(string $attribute, mixed $subject): bool
{
// TODO: Implement supports() method.
if ($subject instanceof Request) {
return true;
}
return false;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
if (!$subject instanceof Request) {
return false;
}
$name = $subject->attributes->get("_route");
if (empty($name)) {
return false;
}
if (($user = $token->getUser()) == null || !$user instanceof User) {
return false;
}
$route = $this->router->getRouteCollection()->get($name);
if ($route == null) {
return false;
}
if (empty($route->getOption('name'))) {
return true;
}
$roles = ($roleNames = $token->getRoleNames())
? array_map(function ($role) {
return (int) str_replace('ROLE_', '', $role);
}, $roleNames)
: [];
// 获取角色列表
$roleCapabilities = $this->optionRepository->value('roles');
$capabilities = [];
// 合并用户的角色权限
foreach ($roles as $roleIndex) {
if (!isset($roleCapabilities[$roleIndex - 1])) {
continue;
}
$capabilities = array_merge($capabilities, $roleCapabilities[$roleIndex - 1]['capabilities']);
}
// 当前URL是否在权限列表中.
return isset($capabilities[$route->getPath()]);
}
}
上面代码可以通过这里获取。octopuspress-bundle是基于Symfony
bundle设计的内容管理系统。