If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeTo make the security system understand what it means when we check for EDIT
access on a Question
object, we need a custom voter. And... to help us out, we can generate this.
Find your terminal and run:
symfony console make:voter
Let's call it QuestionVoter
. I often have one voter class per object in my system that I need to check access for. And... done!
Let's go check it out: src/Security/Voter/QuestionVoter.php
:
... lines 1 - 2 | |
namespace App\Security\Voter; | |
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |
use Symfony\Component\Security\Core\Authorization\Voter\Voter; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
class QuestionVoter extends Voter | |
{ | |
protected function supports(string $attribute, $subject): bool | |
{ | |
// replace with your own logic | |
// https://symfony.com/doc/current/security/voters.html | |
return in_array($attribute, ['POST_EDIT', 'POST_VIEW']) | |
&& $subject instanceof \App\Entity\Question; | |
} | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
$user = $token->getUser(); | |
// if the user is anonymous, do not grant access | |
if (!$user instanceof UserInterface) { | |
return false; | |
} | |
// ... (check conditions and return true to grant permission) ... | |
switch ($attribute) { | |
case 'POST_EDIT': | |
// logic to determine if the user can EDIT | |
// return true or false | |
break; | |
case 'POST_VIEW': | |
// logic to determine if the user can VIEW | |
// return true or false | |
break; | |
} | |
return false; | |
} | |
} |
As usual, the location of this class makes no difference. The important thing is that our voter implements VoterInterface
. Well, not directly... but if you open the core class we extend, you can see that it implements VoterInterface
. The point is: as soon as we create a class that implements VoterInterface
, each time that the authorization system is called, Symfony will now call our supports()
method and basically ask:
Hey! Do you understand how to vote on this
$attribute
and this$subject
?
For us, I'm going to say if in_array($attribute, ['EDIT'])
. So basically, if the attribute that is passed is equal to EDIT
:
... lines 1 - 10 | |
class QuestionVoter extends Voter | |
{ | |
protected function supports(string $attribute, $subject): bool | |
{ | |
// https://symfony.com/doc/current/security/voters.html | |
return in_array($attribute, ['EDIT']) | |
... line 17 | |
} | |
... lines 19 - 40 | |
} |
I'm just using an array in case we support other attributes in this voter later - like DELETE
.
Anyways, if the $attribute
is EDIT
and the $subject
is an instance of Question
, then yes, we know how to vote on this:
... lines 1 - 10 | |
class QuestionVoter extends Voter | |
{ | |
protected function supports(string $attribute, $subject): bool | |
{ | |
// https://symfony.com/doc/current/security/voters.html | |
return in_array($attribute, ['EDIT']) | |
&& $subject instanceof \App\Entity\Question; | |
} | |
... lines 19 - 40 | |
} |
If we return false
, it means that our voter will "abstain" from voting. But if we return true, then Symfony calls voteOnAttribute()
:
... lines 1 - 10 | |
class QuestionVoter extends Voter | |
{ | |
... lines 13 - 19 | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
... lines 22 - 39 | |
} | |
} |
Very simply, we need to take the attribute - in our case EDIT
- and the $subject
- in our case a Question
object - and determine whether or not the user should have access by returning true
or false
.
I'm going to start by adding a few things that will help my editor. First, to get the current User
object, you use this $token
and call $token->getUser()
:
... lines 1 - 10 | |
class QuestionVoter extends Voter | |
{ | |
... lines 13 - 19 | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
... line 22 | |
$user = $token->getUser(); | |
... lines 24 - 39 | |
} | |
} |
The only problem is that my editor doesn't know that this is an instance of my specific User
class: it only knows that it's some UserInterface
. To help, I'll add @var User $user
above this:
... lines 1 - 5 | |
use App\Entity\User; | |
... lines 7 - 10 | |
class QuestionVoter extends Voter | |
{ | |
... lines 13 - 19 | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
/** @var User $user */ | |
$user = $token->getUser(); | |
... lines 24 - 39 | |
} | |
} |
Even better, you could add an if statement to check if $user
is not an instance of User
and throw an exception:
... lines 1 - 8 | |
use Symfony\Component\Security\Core\User\UserInterface; | |
class QuestionVoter extends Voter | |
{ | |
... lines 13 - 19 | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
/** @var User $user */ | |
$user = $token->getUser(); | |
// if the user is anonymous, do not grant access | |
if (!$user instanceof UserInterface) { | |
return false; | |
} | |
... lines 28 - 39 | |
} | |
} |
I'll actually do that down here. We know that $subject
will be an instance of our Question
class. To help our editor know that, say if not $subject
is an instanceof
Question
, then throw a new Exception
and just say "wrong type somehow passed":
... lines 1 - 4 | |
use App\Entity\Question; | |
... lines 6 - 10 | |
class QuestionVoter extends Voter | |
{ | |
... lines 13 - 19 | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
/** @var User $user */ | |
$user = $token->getUser(); | |
// if the user is anonymous, do not grant access | |
if (!$user instanceof UserInterface) { | |
return false; | |
} | |
if (!$subject instanceof Question) { | |
throw new \Exception('Wrong type somehow passed'); | |
} | |
... lines 32 - 39 | |
} | |
} |
That should never happen, but we're coding defensively. And more importantly, my editor - or static analysis tools like PHPStan - will now know what type the $subject
variable is.
Finally, down here, the generated code has a switch-case to handle multiple attributes. I'll remove the second case... and make the first case EDIT
. And... I don't even need the break
because I'm going to return true if $user
is equal to $subject->getOwner()
:
... lines 1 - 10 | |
class QuestionVoter extends Voter | |
{ | |
... lines 13 - 19 | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
/** @var User $user */ | |
$user = $token->getUser(); | |
// if the user is anonymous, do not grant access | |
if (!$user instanceof UserInterface) { | |
return false; | |
} | |
if (!$subject instanceof Question) { | |
throw new \Exception('Wrong type somehow passed'); | |
} | |
// ... (check conditions and return true to grant permission) ... | |
switch ($attribute) { | |
case 'EDIT': | |
return $user === $subject->getOwner(); | |
} | |
return false; | |
} | |
} |
Let's try it! Back at the browser, I'm not logged in. So if we go back... to a question page... and click "edit"... access is still denied. Log in with our normal user. And then... access is still denied... which makes sense. We're an admin user... but we are not the owner of this question.
So let's log in as the owner! Go back to the homepage and click into a question. To make it more obvious which user owns this, temporarily, open templates/question/show.html.twig
and... down here, after the display name, just to help debug, print question.owner.email
:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
... lines 13 - 33 | |
<div class="col"> | |
... lines 35 - 41 | |
<div class="q-display p-3"> | |
... lines 43 - 44 | |
<p class="pt-4"><strong>--{{ question.owner.displayName }} ({{ question.owner.email }})</strong></p> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 53 - 65 | |
</div> | |
{% endblock %} |
And... cool. Copy the email and let's use impersonation! At the end of the URL, add ?_switch_user=
, paste that email and... boom! Access is granted thanks to our voter! We can prove it. Jump into the profiler and scroll down. Here it is: access granted for EDIT
of this Question
object. I love that.
Now that we have this cool voter system, we can intelligently hide and show the edit button. Back in show.html.twig
, wrap the anchor tag with if is_granted()
passing the string EDIT
and the question object:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
... lines 13 - 33 | |
<div class="col"> | |
<div class="d-flex justify-content-between"> | |
... lines 36 - 37 | |
{% if is_granted('EDIT', question) %} | |
<a href="{{ path('app_question_edit', { | |
slug: question.slug | |
}) }}" class="btn btn-secondary btn-sm mb-2">Edit</a> | |
{% endif %} | |
</div> | |
... lines 44 - 48 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 55 - 67 | |
</div> | |
{% endblock %} |
How cool is that? We should still have access, and... when we refresh, it's still there. But if I exit impersonation... and then click back to the question, it's gone!
But I have one more challenge. Could we make it so that you can edit a question if you are the owner or if you have ROLE_ADMIN
. Sure! To do that, in the voter, we just need to check for that role. To do that, we need a new service.
Add a constructor and autowire the Security
service from the Symfony component. I'll hit Alt
+Enter
and go to "Initialize properties" to set things up:
... lines 1 - 8 | |
use Symfony\Component\Security\Core\Security; | |
... lines 10 - 11 | |
class QuestionVoter extends Voter | |
{ | |
private Security $security; | |
public function __construct(Security $security) | |
{ | |
$this->security = $security; | |
} | |
... lines 20 - 52 | |
} |
We talked about this service earlier: we used it to get the currently-authenticated User object from inside a service. It can also be used to check security from within a service.
Even before the switch case, let's add: if $this->security->isGranted('ROLE_ADMIN')
then always return true
:
... lines 1 - 8 | |
use Symfony\Component\Security\Core\Security; | |
... lines 10 - 11 | |
class QuestionVoter extends Voter | |
{ | |
... lines 14 - 27 | |
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | |
{ | |
... lines 30 - 40 | |
if ($this->security->isGranted('ROLE_ADMIN')) { | |
return true; | |
} | |
// ... (check conditions and return true to grant permission) ... | |
switch ($attribute) { | |
... lines 47 - 48 | |
} | |
... lines 50 - 51 | |
} | |
} |
So admin users can do anything. Oh, but whooops, I didn't mean to add that exclamation point!
Since we are currently logged in as an admin user.... as soon as we refresh, we have the edit button... and it works. So cool!
Next: Let's add an email confirmation system to our registration form.
Hey Hanane K.
Before explaining why the first thing you need to know is you should never check for permissions by looking if a role exists in the roles property of a User. Why? Well, there're a few reasons
- The roles hierarchy system won't do its thing. What I mean is if you are looking specifically for the role "ROLE_ADMIN", and the user only has the role "ROLE_SUPER_ADMIN", your check will fail
- Your application may have custom voters to decide whether or not a user has permission to access a resource
- Other security concerns
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.3", // v3.3.0
"composer/package-versions-deprecated": "^1.11", // 1.11.99.4
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^2.1", // 2.6.3
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
"doctrine/orm": "^2.7", // 2.10.1
"knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
"knplabs/knp-time-bundle": "^1.11", // v1.16.1
"pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
"pagerfanta/twig": "^3.3", // v3.3.0
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"scheb/2fa-bundle": "^5.12", // v5.12.1
"scheb/2fa-qr-code": "^5.12", // v5.12.1
"scheb/2fa-totp": "^5.12", // v5.12.1
"sensio/framework-extra-bundle": "^6.0", // v6.2.0
"stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
"symfony/asset": "5.3.*", // v5.3.4
"symfony/console": "5.3.*", // v5.3.7
"symfony/dotenv": "5.3.*", // v5.3.8
"symfony/flex": "^1.3.1", // v1.17.5
"symfony/form": "5.3.*", // v5.3.8
"symfony/framework-bundle": "5.3.*", // v5.3.8
"symfony/monolog-bundle": "^3.0", // v3.7.0
"symfony/property-access": "5.3.*", // v5.3.8
"symfony/property-info": "5.3.*", // v5.3.8
"symfony/rate-limiter": "5.3.*", // v5.3.4
"symfony/runtime": "5.3.*", // v5.3.4
"symfony/security-bundle": "5.3.*", // v5.3.8
"symfony/serializer": "5.3.*", // v5.3.8
"symfony/stopwatch": "5.3.*", // v5.3.4
"symfony/twig-bundle": "5.3.*", // v5.3.4
"symfony/ux-chartjs": "^1.3", // v1.3.0
"symfony/validator": "5.3.*", // v5.3.8
"symfony/webpack-encore-bundle": "^1.7", // v1.12.0
"symfony/yaml": "5.3.*", // v5.3.6
"symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
"twig/extra-bundle": "^2.12|^3.0", // v3.3.3
"twig/string-extra": "^3.3", // v3.3.3
"twig/twig": "^2.12|^3.0" // v3.3.3
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
"symfony/debug-bundle": "5.3.*", // v5.3.4
"symfony/maker-bundle": "^1.15", // v1.34.0
"symfony/var-dumper": "5.3.*", // v5.3.8
"symfony/web-profiler-bundle": "5.3.*", // v5.3.8
"zenstruck/foundry": "^1.1" // v1.13.3
}
}
Hello,
I have a question plz, is there a difference between checking if user has a role in this way
if (in_array('ROLE_ADMIN',$user->getRoles())) return true;
and using the "Security" service the way u did in this video.Thanks in advance