Security and Voters in Symfony 3.4

So the other day I was talking to one of our interns on the topic why it’s important to think about security and permissions when writing a backend app, and so I thought, why not write a blog post talking about it.

If you’ve coded a little bit with Symfony, you probably already know that the security component is very useful to secure your app in many ways. There’s different concepts we need to understand before we can talk about voters.

Authentication

The first part to think of when coding a backend app is authentication.

Wether it’s through a login page or a cookie header within the request, the user will have to be authenticated to access the secured part of your application. This way you will prevent anonymous users from accessing it.

How to do this is up to you, but there’s several bundles that help you get started. The most used one is FOSUserBundle. Alternatively, you can code your own custom authentication system and User entities, implementing Symfony’s UserInterface, so you can handle all the roles, etc. More info on implementing a custom authentication system here.

One way or the other, you’ll have to define roles in your application, specifically in role_hierarchy and access_control keys in your security.yml. Let’s look into these.

So for instance, let’s imagine a case where we have an art application, where artists upload their art.

Our security.yml role hierarchy could look something like this:

#app/config/security.yml
security:
	#...
    role_hierarchy:
        ROLE_ARTIST:      ROLE_USER
        ROLE_ADMIN:       [ROLE_ARTIST, ROLE_USER]
        #...

Once you have your authentication firewall up and running, it’s all about the authorization.

Authorization

This is when an already authenticated user is allowed or not into a specific part of an app.

For example, our ACL could look something like this:

#app/config/security.yml
security:
	#...
    access_control:	
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin, role: ROLE_USER }

This means that all URIs under /admin will need some type of authentitication.

Let’s imagine we defined a CRUD page with all artists on it, with the following uri in our controller: /admin/artist

So, right now, a user that has ROLE_USER might be able to see a CRUD page with all the users in it! This is wrong! Only a user with ROLE_ADMIN should be able to see that page right? To achieve this we can call the AuthenticateCheker service from Symfony’s security component, like so:

use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

/**
 * @Route("/admin/artist")
 */
class ArtistController extends Controller
{
	/**
	 * @Route("/")
	 */
	public function indexAction(Request $request)
	{
		if (false === $this->get('security.authorization_checker')->isGranted('ROLE_ADMIN')) {
	         throw $this->createAccessDeniedException('Unable to access this page!');
	    }
	    // ...
	}
}

Or with Symfony’s base Controller’s wrapper function

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

/**
 * @Route("/admin/artist")
 */
class ArtistController extends Controller
{
	/**
	 * @Route("/")
	 */
	public function indexAction(Request $request)
	{
		$this->denyAccessUnlessGranted('ROLE_ADMIN', null, 'Unable to access this page!');
		// ...
	}
}

You can even write the annotation, and the controller will be even thinner!

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

/**
 * @Route("/admin/artist")
 */
class ArtistController extends Controller
{
	/**
	 * @Route("/")
	 * @Security("has_role('ROLE_ADMIN')")
	 */
	public function indexAction(Request $request)
	{
		// ...
	}
	// ...
}

Ok, so far so good. We now have a page which is only allowed for admins to access, great!

Let’s talk about an artist being able to edit it’s own profile page.

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

/**
 * @Route("/admin/artist")
 */
class ArtistController extends Controller
{
	// ...

	/**
	 * @Route("/edit/{id}")
	 * @Security("has_role('ROLE_ARTIST')")
	 */
	public function editAction(Request $request, Artist $artist)
	{
		// ...
	}
	// ...
}

So we’ve just updated the role and that should do right? Wrong!

What about users who have the same roles, but are not allowed to see each others’ information!!

So for instance, the artist with id 34 should be able to access the URI the /admin/artist/edit/34 but should not be able to access /admin/artist/edit/35, /admin/artist/edit/36,… etc.

Well, that’s where voters come in. This is such a key part of the application and it’s important you take it into account. Do not just secure your actions with roles, secure it with voters. The last thing you want is to have security holes in your application!

So in our previous example, artist with id 34 should only be able to access /admin/artist/edit/34 right? Let’s see how it can be done.

Security Voters

Voters are the services that decide which user can see or access what page. Conceptually, they are called voters because they vote, just like a Parliament or the Government, if a user can access certain information. They vote in favour (ACCESS_GRANTED), against (ACCESS_DENIED) or they abstain themselves (ACCESS_ABSTAIN) when they do not have enough information.

How to build a Voter

A Voter has to implement the VoterInterface. You can implement it yourself, or you can extend the abstract class Voter, which already has some logic into it.

This is what an Artist Voter would look like in our example.


namespace AppBundle\Security;

use AppBundle\Entity\Artist;
use AppBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

class ArtistVoter extends Voter
{
    // these strings are just invented: you can use anything
    const VIEW = 'view';
    const EDIT = 'edit';

    private $decisionManager;

    public function __construct(AccessDecisionManagerInterface $decisionManager)
    {
        $this->decisionManager = $decisionManager;
    }

    protected function supports($attribute, $subject)
    {
        // if the attribute isn't one we support, return false
        if (!in_array($attribute, array(self::VIEW, self::EDIT))) {
            return false;
        }

        // only vote on Artist objects inside this voter
        if (!$subject instanceof Artist) {
            return false;
        }

        return true;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        $user = $token->getUser();

        if (!$user instanceof User) {
            // the user must be logged in; if not, deny access
            return false;
        }

        // ROLE_SUPER_ADMIN can do anything! The power!
        if ($this->decisionManager->decide($token, array('ROLE_ADMIN'))) {
            return true;
        }

        // you know $subject is a Artist object, thanks to supports
        /** @var Artist $artist */
        $artist = $subject;

        switch ($attribute) {
            case self::VIEW:
                return $this->canView($artist, $user);
            case self::EDIT:
                return $this->canEdit($artist, $user);
        }

        throw new \LogicException('This code should not be reached!');
    }

    private function canView(Artist $artist, User $user)
    {
        // if they can edit, they can view
        if ($this->canEdit($artist, $user)) {
            return true;
        }

        // the Artist object could have, for example, a method isPrivate()
        // that checks a boolean $private property
        return $user === $artist->getUser();
    }

    private function canEdit(Artist $artist, User $user)
    {
        // this assumes that the data object has a getOwner() method
        // to get the entity of the user who owns this data object
        return $user === $artist->getUser();
    }
}

So the code is pretty self explanatory. There are two functions which we must implement when extending the Voter,

protected function supports($attribute, $subject)

and

protected function voteOnAttribute($attribute, $subject, TokenInterface $token)

.

The other two functions are private functions that are called in the above two. The private functions contain the logic. In our case, it’ll return true if an user is the owner of the artist entity, and false if anything else. Also, if the user has an admin role, it’ll also return true.

The supports function is telling that this Artist Voter will just vote about Artist entities, nothing else. And it’ll also vote on ‘edit’ and ‘view’ attributes, nothing else. If subject and attributes match, it’ll return the result of the vote, which will be a true or false on this case, as it’s what we need in the if section of the

public function vote(TokenInterface $token, $subject, array $attributes)

function of the abstract Voter.

Now let’s use this voter in our artist edit action:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

/**
 * @Route("/admin/artist")
 */
class ArtistController extends Controller
{
	// ...

	/**
	 * @Route("/edit/{id}")
	 * @Security("has_role('ROLE_ARTIST')")
	 */
	public function editAction(Request $request, Artist $artist)
	{
		$this->denyAccessUnlessGranted('edit', $artist);
		// ...
	}
	// ...
}

That would now protect our URI from users who are trying to edit someone else’s page.

Let’s look a bit more into the the wrapper function

$this->denyAccessUnlessGranted('edit', $artist)

from Symfony’s Controller, which uses Symfony’s ControlTrait.

namespace Symfony\Bundle\FrameworkBundle\Controller;

trait ControllerTrait
{
	//...

	/**
     * Throws an exception unless the attributes are granted against the current authentication token and optionally
     * supplied subject.
     *
     * @param mixed  $attributes The attributes
     * @param mixed  $subject    The subject
     * @param string $message    The message passed to the exception
     *
     * @throws AccessDeniedException
     *
     * @final since version 3.4
     */
    protected function denyAccessUnlessGranted($attributes, $subject = null, $message = 'Access Denied.')
    {
        if (!$this->isGranted($attributes, $subject)) {
            $exception = $this->createAccessDeniedException($message);
            $exception->setAttributes($attributes);
            $exception->setSubject($subject);

            throw $exception;
        }
    }

    //...
}

So basically, the second parameter is the subject, AKA, the object we’re trying to edit.

So calling the above function

$this->denyAccessUnlessGranted('edit', $artist);

is the same as calling

$this->get('security.authorization_checker')->isGranted('edit', $artist)

like so:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

/**
 * @Route("/admin/artist")
 */
class ArtistController extends Controller
{
	// ...

	/**
	 * @Route("/edit/{id}")
	 * @Security("has_role('ROLE_ARTIST')")
	 */
	public function editAction(Request $request, Artist $artist)
	{
		if (false === $this->get('security.authorization_checker')->isGranted('edit', $artist)) {
         	throw $this->createAccessDeniedException('Unable to access this page!');
    	}
		// ...
	}
	// ...
}

So as you can see, we’re calling the same function as at the beggining when we wanted to secure the indexAction with the function

$this->get('security.authorization_checker')->isGranted(...)

except this time, we’re seding a second parameter, the subject.

What this piece of code will do is trigger the hook of all voters that implement VoterInterface in your application and try to vote.

And if we look a bit deeper what the vote function does, you can find this code in the abstract class Voter, which we just extended to create our ArtistVoter.

namespace Symfony\Component\Security\Core\Authorization\Voter;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

/**
 * Voter is an abstract default implementation of a voter.
 *
 * @author Roman Marintšenko 
 * @author Grégoire Pineau 
 */
abstract class Voter implements VoterInterface
{

    /**
     * {@inheritdoc}
     */
    public function vote(TokenInterface $token, $subject, array $attributes)
    {
        // abstain vote by default in case none of the attributes are supported
        $vote = self::ACCESS_ABSTAIN;

        foreach ($attributes as $attribute) {
            if (!$this->supports($attribute, $subject)) {
                continue;
            }

            // as soon as at least one attribute is supported, default is to deny access
            $vote = self::ACCESS_DENIED;

            if ($this->voteOnAttribute($attribute, $subject, $token)) {
                // grant access as soon as at least one attribute returns a positive response
                return self::ACCESS_GRANTED;
            }
        }

        return $vote;
    }

    //...
}

In our example, if it supports the attribute ‘edit’ and the subject, in this case, an Artist entity, it’ll try to grant or deny the access. If it doesn’t support this attribute OR this subject, it’ll abstain itself and move on to the next voter.

The great thing about this is you can implement different voters with one same subject but different logic, and in the end, all votes will be counted and the application will decide if that user is granted or not the access, much like a democratic parliament 🙂

Hope this info is useful to understand a bit more what goes under the hood in Symfony’s security component, and how to make sure your app is always secure.

Happy coding!! 🙂

Authentication here.
Authorization here.
Security component here.
Security configuration here.
Voters documentation here.


Also published on Medium.

Leave a Reply

Your email address will not be published. Required fields are marked *