Table Of Contents

骑驴找蚂蚁

全干工程师

symfony

Symfony Security RBAC动态权限校验实践

默认Symfony Security的授权访问是根据security.php文件中的$security->accessControl()配置的。访问授权配置支持多种校验规则: pathipportmethodshostrequest_matcherattributesroute,具体用法可以查看官方文档

为什么需要动态权限校验?

假如我们的权限是通过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设计的内容管理系统。

推荐阅读

  1. https://symfony.com/doc/current/service_container.html#service-container-services-load-example
  2. https://symfony.com/doc/current/security/voters.html#the-voter-interface
  3. https://symfony.com/doc/current/security.html
  4. https://symfony.com/doc/current/security/access_control.html

留言