This course is archived!
The new Voter Class
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
Create a new controller - viewUserAction
- that will live at /users/{username}
with a route named user_view
:
// ... lines 1 - 10 | |
class DefaultController extends Controller | |
{ | |
// ... lines 13 - 60 | |
/** | |
* @Route("/users/{username}", name="user_view") | |
*/ | |
public function viewUserAction(User $user) | |
{ | |
// ... lines 66 - 70 | |
} | |
} |
Here's the challenge: we need to restrict who is allowed to view this page based on some complex business logic. Maybe I can view only my page... unless I'm an admin... who can view anyone's page. This is a classic situation where security isn't global, it's dependent on the object being accessed. I can see my user page but not your user page.
This is the perfect case for voters. I've been talking about these for years... and they are still underused. Repeat after me, "I do not need ACL, I need voters". And good news, voters are even easier to use in Symfony 3... I mean 2.8.
Instead of passing $username
to the action, type-hint the User
object:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
// ... lines 6 - 10 | |
class DefaultController extends Controller | |
{ | |
// ... lines 13 - 60 | |
/** | |
* @Route("/users/{username}", name="user_view") | |
*/ | |
public function viewUserAction(User $user) | |
{ | |
// ... lines 66 - 70 | |
} | |
} |
Thanks to the FrameworkExtraBundle, Symfony will
query for the User automatically
based on the username
property.
Using a Voter
Next, add if (!$this->isGranted('USER_VIEW', $user)){}
. If this is not granted,
throw $this->createAccessDeniedException('No!')
. If access is granted just
dump('Access granted', $user);die;
:
// ... lines 1 - 63 | |
public function viewUserAction(User $user) | |
{ | |
if (!$this->isGranted('USER_VIEW', $user)) { | |
throw $this->createAccessDeniedException('NO!'); | |
} | |
dump('Access granted!', $user);die; | |
} | |
// ... lines 72 - 73 |
The mysterious thing is USER_VIEW
. We usually pass things like ROLE_USER
or
ROLE_ADMIN
to isGranted()
. But you can invent whatever string you want: I made
up USER_VIEW
.
The Voter System
Whenever you call isGranted()
, Symfony asks a set of "voters" whether or not the
current user should be granted access. One of the default voters handles anything
that starts with ROLE_
. And guess what! You can also pass an object as a second
argument to isGranted()
. That's also passed to the voters.
Here's the plan: create a new voter that decides access whenever we pass USER_VIEW
to isGranted()
.
Create the Voter
In the Security
directory, create a new class called UserVoter
and make this
extend Voter
:
// ... lines 1 - 2 | |
namespace AppBundle\Security; | |
// ... lines 4 - 5 | |
use Symfony\Component\Security\Core\Authorization\Voter\Voter; | |
class UserVoter extends Voter | |
{ | |
// ... lines 10 - 18 | |
} |
This is a new class in Symfony 2.8 that's easier than the old AbstractVoter
.
Use command+n
to open the generate menu and select "Implement methods". The two methods
you need are supports
and voteOnAttribute
:
// ... lines 1 - 4 | |
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |
// ... lines 6 - 7 | |
class UserVoter extends Voter | |
{ | |
protected function supports($attribute, $object) | |
{ | |
} | |
protected function voteOnAttribute($attribute, $object, TokenInterface $token) | |
{ | |
// TODO: Implement voteOnAttribute() method. | |
} | |
} |
This is a little different than before.
Now stop! And go register this as a service. Call it user_voter
and add its class:
UserVoter
. There aren't any arguments, but you do need a tag called security.voter
:
// ... lines 1 - 5 | |
services: | |
// ... lines 7 - 11 | |
user_voter: | |
class: AppBundle\Security\UserVoter | |
tags: | |
- { name: security.voter } |
As soon as we give it this tag, the supports
method will be called every time
we call isGranted()
, asking our voter "Yo! Do you support this attribute, like ROLE_USER
or USER_VIEW
?".
I'm already logged in - so I'll head to /users/weaverryan
. Access denied! Right
now, none of the voters are voting on this: they are all saying that they don't
support the USER_VIEW
attribute. If nobody votes, access is denied.
Adding Voter Logic
So let's code: In supports()
, if (attribute != 'USER_VIEW')
, then return
false:
// ... lines 1 - 8 | |
class UserVoter extends Voter | |
{ | |
// ... lines 11 - 17 | |
protected function supports($attribute, $object) | |
{ | |
if ($attribute != 'USER_VIEW') { | |
return false; | |
} | |
// ... lines 23 - 28 | |
} | |
// ... lines 30 - 34 | |
} |
This says: "I don't know, go bother some other voter!".
Add another if
statement. Wait! Change the argument to $object
- this is the
object - if any - that's passed to isGranted()
. Some now-fixed bad PHP-Doc in Symfony
caused that issue.
Anyways, if (!$object instanceof User)
, then also return false: we only vote on
User
objects. Finally, at the bottom, return true
:
// ... lines 1 - 17 | |
protected function supports($attribute, $object) | |
{ | |
if ($attribute != 'USER_VIEW') { | |
return false; | |
} | |
if (!$object instanceof User) { | |
return false; | |
} | |
return true; | |
} | |
// ... lines 30 - 35 |
You can of course make your voter support multiple attributes like USER_EDIT
or
even multiple objects. I usually have one voter per object.
If you return true from supports()
, then voteOnAttribute()
is called:
// ... lines 1 - 8 | |
class UserVoter extends Voter | |
{ | |
// ... lines 11 - 30 | |
protected function voteOnAttribute($attribute, $object, TokenInterface $token) | |
{ | |
// ... line 33 | |
} | |
} |
This is where you shine: do whatever crazy business logic you need to and ultimately
return true for access or false to deny access. The $attribute
and $object
are
the same as before and $token
gives you access to the currently-logged-in user.
Instead of adding real logic, let's let EvilSecurityRobot
decide our fate. Add
a __construct()
method with EvilSecurityRobot
as the only argument. Create a
property and set it:
// ... lines 1 - 8 | |
class UserVoter extends Voter | |
{ | |
private $robot; | |
public function __construct(EvilSecurityRobot $robot) | |
{ | |
$this->robot = $robot; | |
} | |
// ... lines 17 - 34 | |
} |
In voteOnAttribute()
, return $this->robot->doesRobotAllowAccess();
:
// ... lines 1 - 30 | |
protected function voteOnAttribute($attribute, $object, TokenInterface $token) | |
{ | |
return $this->robot->doesRobotAllowAccess(); | |
} |
Finally, update the service in services.yml
for the new argument. But take the
lazy way out: set autowire: true
:
// ... lines 1 - 5 | |
services: | |
// ... lines 7 - 11 | |
user_voter: | |
class: AppBundle\Security\UserVoter | |
autowire: true | |
tags: | |
- { name: security.voter } |
Head to the brower and try it!
Hey, access granted! Refresh again, access not granted! Again! Not granted, again,
granted! The EvilSecurityRobot
is hard at work causing us problems with its evil
access rules.
Ok, tl;dr: use voters, they're easier than ever and you can do whatever crazy business logic you need.
Could have sense to put role name in a const inside UserVoter?
So in controller we can write $this->isGranted(UserVoter::USER_VIEW, $user);