Buy
Buy

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 [email protected] 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!

  • 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,