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.
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
.
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()
.
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.
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.
Yea, I think this is a great idea actually! You won't be able to use the constant in Twig, but you can use it everywhere else, and it's also documented (via being present as a constant).
Thanks again for that awesome tutorial! One question:
I want to have the profile url directly after the main url of the site: site.com/publicUsername
The solution I came up with is:
MainController.php
/**
* @Route("/{user}", name="user_profile")
*/
public function profileAction(Request $request, User $user)
{
...
But... EXPLOSION!
Now if I try to access site.com/register of course it says that AppBundle\Entity\User object not found instead of using the route for user_register.
I can't find any way to "prioritize" the routes, what would you suggest?
How can I have the profiles directly accessible after the site url? www.site.com/publicUsername
Hey Mike P.!
The only way I know to prioritize urls is to define them first, you would have to put all your routes before
/**
* @Route("/{user}", name="user_profile")
*/
If you are going that way, would be better to use a YML format, so you can prioritize them easier.
UPDATE
I just found the "ChainRouter", I've never used it, but look's like it might help you achieving your goal
https://symfony.com/doc/current/cmf/components/routing/chain.html
Cheers!
Thanks Diego, I want to share the solution:
Tt seems that "define them first" was the right track, but instead of changing the order of the controller action, I defined it as last entry in the routing.yml like proposed here: https://stackoverflow.com/q...
Is there a way to do a "allowUnlessDenied" test rather than isGranted?
I have a case where I'd like to allow users access unless they are specifically denied by a voter.
Hey Geoff Maddock
I think you can just invert the logic when checking for denied access role
// someController.php
public function someAction() {
// If this is true, throw exception
if ($this->isGranted('ROLE_ACCESS_DENIED')) {
throw $this->createAccessDeniedException('You were banned from this action!');
}
}
Cheers!
If I add a Voter for the user view action, do I see it right that the @SECURITY annotation of the controller action is no longer necessary?
(/**
* @Security("is_granted('ROLE_USER)
*/)
Yo Mike P.!
Whether you've created a custom voter or not.. you still need to call either the "is granted" function somewhere: either in the @Security
annotation or in your controller (via $this->denyAccessUnlessGranted()
or $this->isGranted()
). So, it depends on what you mean by "add a Voter for the user view action". Are you calling isGranted (in one of these forms) in that action? If you are not, then the security system (and your voter) are never called.
Cheers!
// composer.json
{
"require": {
"php": ">=5.5.9",
"symfony/symfony": "3.0.*", // v3.0.0
"doctrine/orm": "~2.5@dev", // 2.7.x-dev
"doctrine/doctrine-bundle": "~1.6@dev", // 1.10.x-dev
"doctrine/doctrine-cache-bundle": "~1.2@dev", // 1.3.2
"symfony/security-acl": "~3.0@dev", // dev-master
"symfony/swiftmailer-bundle": "~2.3", // v2.3.8
"symfony/monolog-bundle": "~2.7@dev", // dev-master
"sensio/distribution-bundle": "~5.0@dev", // v5.0.22
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.11
"incenteev/composer-parameter-handler": "~2.0", // v2.1.2
"doctrine/doctrine-fixtures-bundle": "^2.3", // v2.4.1
"composer/package-versions-deprecated": "^1.11" // 1.11.99
},
"require-dev": {
"sensio/generator-bundle": "~3.0", // v3.0.0
"symfony/phpunit-bridge": "~2.7" // v2.7.6
}
}
Could have sense to put role name in a const inside UserVoter?
So in controller we can write $this->isGranted(UserVoter::USER_VIEW, $user);