access_control Authorization & Roles

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Everything that we've done so far has been about authentication: how your user logs in. But now, our space-traveling users can log in! We're loading users from the database, checking their password and even protecting ourselves from the Borg Collective... with CSRF tokens.

So let's start to look at the second part of security: authorization. Authorization is all about deciding whether or not a user should have access to something. This is where, for example, you can require a user to log in before they see some page - or restrict some sections to admin users only.

There are two main ways to handle authorization: first, access_control and second, denying access in your controller. We'll see both, but I want to talk about access_control first, it's pretty cool.

access_control in security.yaml

At the bottom of your security.yaml file, you'll find a key called, well, access_control:

security:
... lines 2 - 38
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }

Uncomment the first access control:

security:
... lines 2 - 40
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
... lines 43 - 44

The path is a regular expression. So, this access control says that any URL that starts with /admin should require a role called ROLE_ADMIN. We'll talk about roles in a minute.

Go to your terminal and run

php bin/console debug:router

Ah, yes, we do already have a few URLs that start with /admin, like /admin/comment. Well... let's see what happens when we try to go there!

Access denied! Cool! We get kicked out!

Roles!

Let's talk about how roles work in Symfony: it's simple and it's beautiful. Down on the web debug toolbar, click on the user icon. Cool: we're logged in as spacebar1@example.com and we have one role: ROLE_USER. Here's the idea: when a user logs in, you give them whatever "roles" you want - like ROLE_USER. Then, you run around your code and make different URLs require different roles. Because our user does not have ROLE_ADMIN, we are denied access.

But... why does our user have ROLE_USER? I don't remember doing anything with roles during the login code. Open the User class. When we ran the make:user command, one of the methods that it generated was getRoles():

... lines 1 - 10
class User implements UserInterface
{
... lines 13 - 66
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
... lines 78 - 128
}

Look at it carefully: it reads a roles property, which is an array that's stored in the database:

... lines 1 - 10
class User implements UserInterface
{
... lines 13 - 24
/**
* @ORM\Column(type="json")
*/
private $roles = [];
... lines 29 - 128
}

Right now, this property is empty for every user in the database: we have not set this to any value in the fixtures.

But, inside getRoles(), there's a little extra logic that guarantees that every user at least has this one role: ROLE_USER:

... lines 1 - 10
class User implements UserInterface
{
... lines 13 - 69
public function getRoles(): array
{
... line 72
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
... lines 75 - 76
}
... lines 78 - 128
}

This is nice because we now know that, if you are logged in, you definitely have this one role. Also... you need to make sure that getRoles() always returns at least one role... otherwise weird stuff happens: the user becomes an undead zombie that is "sort of" logged in.

To prove that this roles system works like we expect, change ROLE_ADMIN to ROLE_USER in the access control:

security:
    # ...
    access_control:
        - { path: ^/admin, roles: ROLE_USER }

Then, click back to the admin page and... access granted!

Change that back to ROLE_ADMIN.

Only One access_control Matches per Page

As you can see in the examples down here, you're allowed to have as many access_control lines as you want: each has their own regular expression path. But, there is one super important thing to understand. Access controls work like routes: Symfony checks them one-by-one from top to bottom. And as soon as it finds one access control that matches the URL, it uses that and stops. Yep, a maximum of one access control is used on each page load.

Actually... this fact allows you to do some cool things if you want most of your pages to require login. We'll talk about that later.

Now that we can deny access... something interesting happens if you try to access a protected page as an anonymous user. Let's see that next.

Leave a comment!

  • 2020-05-28 Victor Bocharsky

    Hey AbelardoLG,

    > I couldn't block any anonymous user: how to do this when the ip is dynamically associated. 🤔

    You don't need the IP :) Well, let's take a standard login form with username and password. Usually, intruders know the username, and crack only password just literally iterating over some common words, etc. In this case, you just need to block the access by the username and that's it. If someone enter his password incorrectly 3 times for the same username - block that username. And you don't care about IP here. Well, you can also add an IP check and then no matter what username/password pair was inputted - if it was made from the same IP 3 times and failed - block the IP. Well, doing both blocks (by IP *and* by username) you solve dynamic IPs for intruders.

    Well, instead username it might be email or identifier as in your case - the strategy is the same. Username is just a general name for everything: identifier, email, username, token, etc.

    If you afraid of blocking good users - I'd recommend to make the system obvious. Like saying how much failure attempts user has, and say that current username/IP is blocked. And that they have to wait some time before trying to login again or write to the support and ask to unblock them. And yeah, you don't need to block users forever. Doing it at least for 1 hour will already be a good enough thing.

    I hope this helps!

    Cheers!

  • 2020-05-28 AbelardoLG

    Admin page route path = login page route path since the latter allows the access to the former. My web is about recipes. Any user is not intended to have credentials to access to any profile. Only the admin people have them.

    Or, you can add a more custom code and allow like only 3 invalid password failures: I'll add this step to my control panel login page. :)

    I couldn't block any anonymous user: how to do this when the ip is dinamically associated. 🤔

    Have a nice thirsty day (sorry, Thursday) Take 🍻

  • 2020-05-28 Victor Bocharsky

    Hey AbelardoLG,

    > Yes, it's obviously my webpage is not a fortress :D but a simple web easily cracked but if you show the login page to anybody, you are showing the door to your fortress (a step more and that unknown will be inside it). IMHO, the less weak points you show the entire world, the less opportunity they have to harm you.

    Agree, but once again it's not about your admin page route path, it's more about your login page route path. In this case, I agree. The less people know your login page address - the less harm they may do. But, it's "relative". I bet you are not using crazy long route path with special chars in it. And if so, it's easy to write a simple script that will find your login page... it's totally easier than write a script that will hack your credentials :)

    The most common solution to avoid bad guys cracking your credentials with login page - two factor authentication. Or, you can add a more custom code and allow like only 3 invalid password failures. If someone hit this - just block the username for some time, like an hour or so. It will increase your security a lot.

    > but I should think faster than anybody who attempts to enter to my system.

    Yes, agree :) And those are good questions to think about, really. It always depends on your project. Well, if you allow your users to login/register on the website - your login form should be public. If it's only for admins and users should not even know about it - all you said have much more sense of course. the current implementation of login process in Symfony is probably more user friendly, as it has wider use I think. Usually, if you want to make something more project specific - you would probably to add more custom code.

    Yes, interesting discussion! And good questions! :)

    Cheers!

  • 2020-05-27 AbelardoLG

    Interesting discussion here. 🤗

    You said :
    the system may not know WHO you are, and so he may not know if the admin page should be shown to you or not. That's where it redirects you to login page so you may tell the system who you are.

    Yes, it's obviously my webpage is not a fortress :D but a simple web easily cracked but if you show the login page to anybody, you are showing the door to your fortress (a step more and that unknown will be inside it). IMHO, the less weak points you show the entire world, the less oportunity they have to harm you.

    Go with some more long and secure path name.: Yes, it was made. But it's not enough since if they fail while they try to guess the admin panel, your approach will show the door to your control panel; so, double security: 1) a hard admin path and 2) when it is guessed, or it is failed, don't show the way to enter your control panel. I could fail on purpose to show your control panel login and...I'll try to break your security system. :)

    Also, I don't use an email as part of the credentials. I use an identifier (it may be a long string, a special text, the name of the owner's pet name...and so on) and a password, indeed. Again, 2 factor authentication is applied here.

    Obviously, I don't think that a robust security system exists (not at least in this world xD); but I should think faster than anybody who attempts to enter to my system.

    As forementioned, interesting discussion here. 🤗

    Best regards and take care. Cheers.

  • 2020-05-27 Victor Bocharsky

    Hey AbelardoLG,

    Yes, I see your point... but if someone may break your website knowing the admin page route - then you probably have problems with the website security :) Let's think about it... First of all, if you're not logged in - the system may not know WHO you are, and so it may not know if the admin page should be shown to you or not. That's where it redirects you to login page so you may tell the system who you are. That's just a good UX. Another point - nobody will hack your admin route, usually bad guys hack your login form :) That means that it does not really matter if users know your admin route or no, most probably they know your login page route, and it's enough for start cracking your system. And even if they cracked your credentials and logged in to your website as admin - the fact they don't know the your admin route is really a little problem, as your credentials were leaked anyway, and you definitely have problems in case case as well. And if someone could break your website admin credentials - probably the easiest he could do is to guess the admin route, I don't think it would be difficult for them to do so, even if you will make up /aLongAndSuperSecureAdminRoutePath :) So, I don't think that knowing the admin route is a security break really. Well, if you add a public link to your admin page for all users - it's not a good idea of course, but if your security system works well - it's not a security break. And if you really care about not making the admin route easy guessable - I'd recommend you to not call it as /admin that almost everyone might guess. Go with some more long and secure path name.

    So, as far as you use long password with special chars in it and your system security works well and does not have bugs - knowing your admin route is not a problem at all. But if you're looking for a more security - look into 2 factor authentication, or at least get rid of username/password standard login form in favor of social login buttons. And use 2-factor authentication there :) But of course, then you're responsive for securing your laptop and log out from your socials, if you don't want someone pick your laptop and auto sign in with your logged in Facebook account :)

    I hope it makes sense for you.

    Cheers!

  • 2020-05-26 AbelardoLG

    Yes, sure! :)

    When somebody goes to /admin entry point, you encourage us to show the login screen.🔒🔑 They could crack your system. 👹

    For me, this behaviour is riskly since you are showing the entry point of your dashboard or admin zone. 🔐

    I think a good behaviour of the application is to show a screen where you inform about an error (with no more information).
    With this approach, you:
    1) don't give them clues about how to enter into your dashboard; a security break!
    2) don't give them clues about why they were wrong.

    Best regards.

  • 2020-05-25 Victor Bocharsky

    Hey Abelardo,

    I'm sorry, I'm not sure I completely understand your problem. Could you explain it a bit more? Probably some good real example? Do you mean when someone is try to access your /admin entry point - they see 403 Forbidden access code but you would rather prefer to return 404 instead?

    Cheers!

  • 2020-05-24 Abelardo León González

    Hi there,

    IMHO, it's a risk the following behaviour:
    - when I set this url /XXXXX as my real entry point to my dashboard and somebody writes /admin, my website automatically shows the /XXXXX, my real entry point to my dashboard. The security of my website could be compromised. Bad choice of design from my viewpoint.

    When somebody writes /admin or whatever, I wouldn't show my real entry point. I'm going to know how to do this change.

    Best regards.

  • 2020-03-06 Victor Bocharsky

    Hey Christina,

    Basically, you need to use a data transformer that will convert a string value selected in the form into an array. See the docs for more info: https://symfony.com/doc/cur...

    Or, I think as an option, you can allow setting string value as an argument to the User::setRoles() method, but convert it into an array inside, because getRoles() should return an array IIRC. This trick should also work. But technically, as I understand your implementation, it's still possible to set multiple roles to one user, so probably it's not a big deal to have multiple=true for that form as it's exactly how your system works?

    I hope this helps!

    Cheers!

  • 2020-03-05 Caeema

    Hi everybody.

    I face an issue regarding the CRUD of a user and the roles.

    In my User entity, the property roles is a type JSON:


    /**
    * @ORM\Column(type="json")
    */
    private $roles = [];

    /**
    * @see UserInterface
    */
    public function getRoles(): array
    {
    $roles = $this->roles;
    // guarantee every user at least has ROLE_USER
    $roles[] = 'ROLE_USER';

    return array_unique($roles);
    }

    /**
    * @param array $roles
    * @return $this
    */
    public function setRoles(array $roles): self
    {
    $this->roles = $roles;

    return $this;
    }

    In my database, the field is a LONGTEXT, with this content for example: ["ROLE_SUPER_ADMIN"]
    I don't have any issue with all of this.

    What I'm now trying to do, is to give the opportunity to EDIT a user, with a classic form.
    Here, the code for the choice of the role:


    ->add('roles', ChoiceType::class,
    [
    'choices' =>
    [
    'Super admin' => ["Super admin" => "ROLE_SUPER_ADMIN"],
    'Admin' => ["Admin" => "ROLE_ADMIN"],
    'User' => ["User" => "ROLE_USER"]
    ],
    'multiple' => true,
    'expanded' => true,
    'required' => true
    ]
    )

    It's working! Except that I don't want to give the chance to the user to choose multiple roles.

    But.. If I change the "multiple" to false, I have this error: Notice: Array to string conversion.

    I understand what it means, but what I don't get is how to manage this differently.

    Can somebody help with this and some explanations ? Thanks!

  • 2020-02-03 Luis Enrique Farfán Prado

    Hi!

    Oh thanks! I hadn't thought about that, you are totally right. Yes, I will fetch the sale information for the users and use voters instead of creating another entity.

    Thanks for the help!

  • 2020-02-03 weaverryan

    Hey Luis Enrique Farfán Prado!

    An example is indeed the best way! :)

    Here's my answer I think I might *not* use roles for this! Here's the important point: if you forget about security for a minute and pretend that we're not storing *any* roles *anywhere* in the database, you *already* have enough information to know which user should have access to which module. You know this because, when a user subscribes to the site and selects the modules he wants to buy, you certainly store that information somewhere in the database. What you *really* want then, is a way for Symfony's security system to look at that existing data and use *it* to determine access. If you also stored all this info as roles related to users (however you structure it), you will be effectively duplication that info in the database.

    So, how do we do this? Voters: https://symfonycasts.com/sc...

    For example, above a controller for a module (or wherever you want to put security config), you might have:


    /**
    * @IsGranted("MODULE_ACCESS", "foo_bar")
    */
    class FooBarModuleController
    {
    // ... probably several controller methods for this module
    }

    Your custom voter would handle this MODULE_ACCESS "attribute". The "subject" variable that the voter will receive will be the string module name (foo_bar in this case). You will then check the database to see if the user should have access to the foo_bar module and grant/deny access.

    Let me know how this sounds!

    Cheers!

  • 2020-01-30 Luis Enrique Farfán Prado

    Hi! Yes it makes sense. I understood your point.

    The roles in my project will be finite, but they will be related to the modules of the project mostly, and only some administrator roles that also will be finite, I think with an example it will be more understandable:

    Supose the project will have a module to have control of the assistance of students to some class and other to create plans of classes and sell them to other people. When a user subscribes to the site, he will have the option to select wich modules he want to buy, and depending on that he will or not have access to that modules. So, the assign of roles will be when the user buys a module.

    The reason I thought it will be better to have another entity with all the roles is because a role will be created when another module is created, and many roles will be assigned to many users according to what they bought, so, the roles will repeat a lot between each user.

    But I still don't know if I'm exaggerating and complicating the work needlessly. With the whole scenario, I will be very grateful if you give me your opinion haha. Please.

    In case create an entity for roles is the best for my project, if I understood you, the method getRoles() only need to return an array of strings full of roles, so the only extra thing I have to do is fetch all the roles related to the user and insert them in a string array and return that to still be able to use the access_controll and authorization system of symfony?

    Thanks a lot! Sorry for the inconvenience.

  • 2020-01-30 weaverryan

    Hey Luis Enrique Farfán Prado!

    Excellent question :).

    > The way symfony works propose to have the roles inside the User table. Is that the best option?

    It's not the *best* option, it's just the "simplest" option - and one that works for most use-cases. But what you're thinking is a completely valid way to go.

    At the end of the day, the *only* thing that Symfony cares about is that it can call $user->getRoles() on your User object and that this returns an array of the role strings. You can do that by having a simple property directly on the User like we do in this tutorial or by using some relations and looping over those relations inside of getRoles() to get an array of all of the strings.

    There *is* one point I want to challenge you on... to make sure you need the complexity before you add it ;). Personally, I would *not* create a Role entity. Why? Well, suppose you have some module and you want to only allow people with ROLE_EMPLOYEE_ADMIN to access that section. This will mean that (somewhere in your code, like access_control or in your controller), you will literally have the string ROLE_EMPLOYEE_ADMIN written. What I'm trying to say is: you will *truly* have a finite set of actual "roles" in your system. If you have a Role entity... and someone uses an admin interface to create a new role called ROLE_I_LIKE_SKIING and then assign it to some users... what will happen? Well, unless a programmer has already added some code that protects a module/section with this role... nothing will happen. Does that make sense? I don't like having a database table full of values that are *not* truly dynamic.

    But, I could be missing some important use-case for you :). In general, I think there are two main options:

    A) Simple: roles field on User.
    B) More complex: a Group entity with a roles field. And then, each User is ManyToMany to Groups.

    Option (B) is nice when you want your admins to be able to add people to "groups" (e.g. Human resources) and automatically get whatever roles are given to that group. But the roles are *still* just a property on that Group. Regardless of which option you choose, if you have a lot of roles, I'd recommend adding each of them as constants to some class (e.g. RoleManager) along with a public static getAllRoles() method that returns the whole list. You can use this in your code to, for example, create a drop-down menu with a list of all the roles.

    Let me know if this makes sense!

    Cheers!

  • 2020-01-29 Luis Enrique Farfán Prado

    The project I am developing will have a lot of Roles related to user according to the modules that they decide to acquire and some administrator roles. That's the reason I am wondering using instead another table of roles.

    Thanks!

  • 2020-01-29 Luis Enrique Farfán Prado

    Hi, I have a question about the way of manage user roles in a large project. I have seen in many projects that a common way of manage roles is creating a new table "Role" and make a many to many relation to the "User" table. The way symfony works propose to have the roles inside the User table. Is that the best option? If not, I still want to take advantage of the symfony access control and authorization system, I think is very powerful. But how do I do to adapt that with that new "Role" table?

  • 2019-03-21 Vasiliy

    Oh, its so simple and straight, that i didn`t guess by myself)) Thank you wery much!

  • 2019-03-20 Diego Aguiar

    Oh I see your problem, the autowire functionality can't just autowire your repository because of unknown dependencies but I found a possible solution. Just inject the "ManagerRegistry" into your entity's repository and add a little bit of logic as shown here: https://github.com/Atlantic...

    I hope it helps. Cheers!

  • 2019-03-20 Vasiliy

    Of course i read it, and setup entity correctly. At now time it works well as a tree entity, but with standard repository without MaterializedPathRepository's methods.

  • 2019-03-19 Diego Aguiar

    Have you checked at the docs? https://github.com/Atlantic...
    I believe you didn't set up correctly your entity configuration

  • 2019-03-17 Vasiliy

    Hi. Tried to enable Tree on User entity with materialised path behaviour. Done everything from docs and make UserRepository extended from MaterializedPathRepository to have all its methods. Got eror from UserRepository:

    Cannot autowire service "App\Repository\UserRepository": argument "$class" of method "Gedmo\Tree\Entity\Repository\AbstractTreeRepository::__construct()" references class "Doctrine\ORM\Mapping\ClassMetadata" but no such service exists.


    Can you show me the rigth direction where to dig?

  • 2019-02-26 Diego Aguiar

    Oh, I think you are storing a string instead of an array in your $roles property. I think you can use the DB type of "json, json_array, or array" and it should work (but I might be wrong). Anyway, I drop you the docs about roles so you can get a better idea of how they work: https://symfony.com/doc/cur...

    If you still have problems, let me know. Cheers!

  • 2019-02-25 Nicolo Buzzi

    Sorry, i talked too fast now i have this error "Uncaught PHP Exception Doctrine\DBAL\Types\ConversionException: "Could not convert database value to 'array' as an error was triggered by the unserialization: 'unserialize(): Error at offset 0 of 10 bytes'" do you have an idea

  • 2019-02-25 nico

    Thank you for your help, like you said i had to change from json to json_array and it worked.
    Thank you again

  • 2019-02-25 Diego Aguiar

    Hey @nico

    That's because you are storing a string instead of an array in your $user->roles property. Just change that and everything should work fine :)

    Cheers!

  • 2019-02-25 nico

    Hello,
    I have a small question, i have this error " Could not convert database value "ROLE_ADMIN" to Doctrine Type json ", i don't understand why i have this error, is it the version of MySQL?

  • 2019-02-07 Diego Aguiar

    Yep :) Since you manually created and set the token into the session (like short-circuiting), then the security component will just use it, so it doesn't matter if after that you change a role on your User object

    Cheers!

  • 2019-02-07 Jérémie Dejardin

    Exactly on point!

    Thank you again : with your guidance I now understand better the usage of the fake token. I assumed the fake token used the security configuration of the app, but it was a mistake. It only contains the information I provided to it.

  • 2019-02-05 Diego Aguiar

    Oh, I think you have to add the "ROLE_USER" role as well when creating the "fake" Token. Try it out and let me know if it works :)

  • 2019-02-05 Jérémie Dejardin

    Hello Diego!

    Thanks for the swift answer.

    I already tested this before and I believe I had a double issue. You solution indeed solves the 301... But replace it with a 403. And i failed to notice the status error that changed during my debugging.

    Additionally, if change the trailing / and adapt the the code at the top of the controller to align with my role hierarchy definition, the test works. It's just when I use the ROLE_USER that is only present in getRoles() that the test returns a 403.

    This status code makes more sense. I'll use "IS_AUTHENTICATED_REMEMBERED" for my purposes.

    I'm still curious if I missed anything else to make the "ROLE_USER" work with the tests.

  • 2019-02-05 Diego Aguiar

    Hey Jérémie Dejardin

    What's going on is that Symfony is redirecting you automatically to /requests because you are hitting /requests/ (ending with slash), just adjust your route and you won't get a 301 response anymore. And those both roles are being by your fake "login" process

    Cheers!

  • 2019-02-05 Jérémie Dejardin

    Good day,

    I have an issue regarding functional testing on an assertion regarding the security @isGranted("ROLE_USER") at the top of the controller and I am looking for help :) .

    Here is the code I have :
    In User class :


    public function getRoles(): array
    {
    ...
    $roles = $this->roles;
    /* guarantee every user at least has ROLE_USER */
    $roles[] = 'ROLE_USER';
    return array_unique($roles);
    ...
    }

    In Controller :


    /**
    * @IsGranted("ROLE_USER")
    */
    class RequestController extends BaseController
    {
    /**
    * @Route("/requests", name="api_requests", options={"expose"=true}, methods={"GET", "POST"})
    ...

    In TestController


    namespace App\Tests;

    use Symfony\Bundle\FrameworkBundle\Client;
    use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
    use Symfony\Component\BrowserKit\Cookie;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

    class PostControllerTest extends WebTestCase
    {
    /** @var $client Client*/
    private $client = null;

    public function setUp()
    {
    $this->client = static::createClient();
    }

    /**
    * @dataProvider urlProvider
    */
    public function testRequests($url)
    {
    $this->logIn();

    $this->client->request('GET', $url);

    $this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
    }

    private function logIn()
    {
    $session = $this->client->getContainer()->get('session');

    $firewallName = 'main';
    $firewallContext = 'main';

    $token = new UsernamePasswordToken('...', '...', $firewallName, ['ROLE_RECRUITER']);
    $session->set('_security_'.$firewallContext, serialize($token));
    $session->save();

    $cookie = new Cookie($session->getName(), $session->getId());
    $this->client->getCookieJar()->set($cookie);
    }

    public function urlProvider()
    {
    yield ['/requests/'];
    ...
    }
    }

    The execution of that test case results in a response with status code 301.

    If I change the assert at the top of the controller to "IS_AUTHENTICATED_REMEMBERED" or "ROLE_RECRUITER" (The role tested here.) the result is correct.

    Any idea of what I am missing?

    Regards,