phpsymfonysymfony4

Custom voter does not work as expected after migrating from Symfony 3.4 to Symfony 4.4


I'm migrating an app from Symfony 3.4 to Symfony 4.4. This app gives admin users the possibility to edit the role needed to access each route, so all the roles and routes are stored in the database.

To check if the user has access to a route, a voter is called on each request, and is configured in the services.yaml file.

In Symfony 3.4, the voter was called on each request, without adding any more code. In the web profiler, i can see the list of voters, and the decision from the AccessDecisionManager ("Granted" or "Denied").

Screenshot of the Web Profiler for Symfony 3.4

In Symfony 4.4 however, the voters don't seem to be called at all. In the web profiler, my custom voter is still in the list (two times ??), but there is no decision from the AccessDecisionManager.

Screenshot of the Web Profiler for Symfony 4.4

If I check user access directly from the controller by adding this line $this->denyAccessUnlessGranted("", $request);, the voters are called and work as expected.

If someone could explain to me why I have to call the denyAccessUnlessGranted() method manually in Symfony 4.4 when it was not needed in Symfony 3.4 ? Was I using voters the wrong way in 3.4 ?

Thank you.

My custom voter class :

namespace App\Security;

use Doctrine\ORM\EntityManager;

use Symfony\Component\HttpFoundation\Request;

use Symfony\Component\Security\Core\User\UserInterface;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

class DynamicAccessVoter implements VoterInterface
{
    // Routes everyone has access to
    const PUBLIC_ROUTES = [
        "login"
    ];

    // Routes everyone who's connected has access to
    const PRIVATE_ROUTES = [
        "homepage",
        "fos_js_routing",
        "fos_js_routing_js"
    ];

    // Routes everyone has access to only in dev mode
    const DEV_ROUTES = [
        "_wdt",
        "_profiler",
        "_profiler_home",
        "_profiler_search",
        "_profiler_search_bar",
        "_profiler_phpinfo",
        "_profiler_search_results",
        "_profiler_open_file",
        "_profiler_router",
        "_profiler_exception",
        "_profiler_exception_css",
        "_twig_error_test"
    ];

    private $env;

    /**
     * Constructor
     * 
     * @param string $env - App environment (dev or prod)
     */
    public function __construct(String $env = "") {
        $this->env = $env;
    }
    
    /**
     * Custom voter
     * 
     * @param TokenInterface $token
     * @param Request $subject
     * @param array $env
     */
    public function vote($token, $subject, $attributes) {

        // Verifie si $subject est une instance de Request
        if(!$subject instanceof Request) {
            return self::ACCESS_ABSTAIN;
        }

        $route = $subject->attributes->get("_route");

        // Verifie si la route est une route publique (accessible par tout le monde)
        if(in_array($route, DynamicAccessVoter::PUBLIC_ROUTES)) {
            return self::ACCESS_GRANTED;
        }

        // Verifie si l'application est en développement et la route nécéssaire pour le debug
        if($this->env == "dev" && in_array($route, DynamicAccessVoter::DEV_ROUTES)) {
            return self::ACCESS_GRANTED;
        }

        // Verifie si $utilisateur est une instance de UserInterface
        if(!$token->getUser() instanceof UserInterface) {
            return self::ACCESS_ABSTAIN;
        }

        // Verifie si la route est une route accéssible par tout utilisateur connecté
        if(in_array($route, DynamicAccessVoter::PRIVATE_ROUTES)) {
            return self::ACCESS_GRANTED;
        }
        
        // Verifie si l'utilisateur connecté à le droit d'accéder à cette route
        if($token->getUser()->hasAccessTo($route)) {
            return self::ACCESS_GRANTED;
        }

        return self::ACCESS_DENIED;
    }
}

My custom voter configured as a service in the services.yaml file :

app.dynamic_access_voter:
    class: App\Security\DynamicAccessVoter
    arguments: ["%kernel.environment%"]
    tags:
        - { name: security.voter }

My security.yaml file, if that can help :

security:
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    encoders:
        App\Entity\Utilisateur:
            algorithm: bcrypt

    providers:
        main:
            entity:
                class: App\Entity\Utilisateur
                property: email      

    firewalls:
        main:
            anonymous: true
            provider: main
            pattern: ^/
            form_login:
                login_path: login
                check_path: login
                always_use_default_target_path: true
                default_target_path: homepage
            logout:
                path: /logout
                target: /login
            user_checker: App\Security\EnabledUserChecker

    access_control:
        - { path: ^/ }

Solution

  • As mentioned in my comment, I'am a bit skeptical that this worked in 3.4 without a custom kernel request listener. On the other hand, if you did have a such a listener then it should still be working as well.

    In any event, here is a working Symfony 4 listener that should work for you:

    namespace App\Security;
    
    use Symfony\Component\EventDispatcher\EventSubscriberInterface;
    use Symfony\Component\HttpKernel\Event\RequestEvent;
    use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
    use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
    use Symfony\Component\Security\Core\Exception\AccessDeniedException;
    
    class RequestSubscriber implements EventSubscriberInterface
    {
        private $checker;
        private $tokenStorage;
    
        public function __construct(AuthorizationCheckerInterface $checker, TokenStorageInterface $tokenStorage)
        {
            $this->checker = $checker;
            $this->tokenStorage = $tokenStorage;
        }
        public static function getSubscribedEvents(): array
        {
            return [
                RequestEvent::class => 'onKernelRequest',
            ];
        }
        public function onKernelRequest(RequestEvent $event)
        {
            if (!$event->isMasterRequest()) {
                return;
            }
            // avoid dev firewall requests
            if ($this->tokenStorage->getToken() === null) {
                return;
            }
            $route = $event->getRequest()->attributes->get('_route');
            dump('Request Subscriber ' . $route);
    
            if ($this->checker->isGranted('CAN_ACCESS_ROUTE',$event->getRequest())) {
                return;
            }
            $exception = new AccessDeniedException('Because I said so!');
            $exception->setAttributes('CAN_ACCESS_ROUTE');
            $exception->setSubject($route);
            throw $exception;
        }
    }
    

    If you have autowire and autoconfigure enabled in services.yaml then no additional service configuration is needed. If not then you will need to define a service and tag it accordingly.

    From what I can tell from just looking at your code, your voter should continue to work. However there have been some voter refinements including an abstract Voter class which simplifies things a bit. This is what I used for testing.

    namespace App\Security;
    
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Core\Authorization\Voter\Voter;
    
    class RequestVoter extends Voter
    {
        protected function supports($attribute, $subject) : bool
        {
            if ($attribute === 'CAN_ACCESS_ROUTE') {
                return true;
            }
            return false;
        }
        protected function voteOnAttribute(
            $attribute,
            $subject,
            TokenInterface $token) : bool
        {
            /** @var Request $subject */
            $route = $subject->attributes->get('_route');
            dump('Voter ' . $route);
    
            switch($route) {
                case 'default':
                case 'app_login':
                case 'app_logout':
                    return true;
    
            }
    
            return false;
        }
    }