how to build your own security system for your Symphony application

updated at 21.06.2019

It has never been easier! Even though authentication and authorization are big topics which can get quite complex, Symfony provides us with all the tools we need to set up an awesome security system. We are going to take a look at both topics and will create an example which stores the user data in a MySQL database (Doctrine) and provides a HTML form for authentication, step by step. Let's have some fun!

This article assumes that you created your Symfony application with the website skeleton:

$ composer create-project symfony/website-skeleton projectfolder

And you have The Symfony MakerBundle installed. If you haven't installed it already, you can download and install the bundle with one simple command in your console.

$ composer require symfony/maker-bundle --dev

Part 1: Authentication

Summarizing our steps to allow a user the authentication in our application: create a User entity first, then create and run the migration files, fill the database with dummy users and finaly create our own authenticator.

create the user entity

We have chose to store our users in a MySQL database, and it's pretty common in Symfony to use Doctrine to communicate with the database. This means we need an user entity. But instead of creating the entity with the MakerBundle command make:entity, we use the make:user command. make:user is going to create a user class depending on the parameters we pass to the command. Let's roll:

$ ./bin/console make:user
 The name of the security user class (e.g. User) [User]:
 > User

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > yes

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > username

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > yes

The newer Argon2i password hasher requires PHP 7.2, libsodium or paragonie/sodium_compat. Your system DOES support this algorithm.
You should use Argon2i unless your production system will not support it.

 Use Argon2i as your password hasher (bcrypt will be used otherwise)? (yes/no) [yes]:
 > no

After we have passed the parameters to MakerBundle as shown above, we will get following output inside the console:

 created: src/Entity/User.php
 created: src/Repository/UserRepository.php
 updated: src/Entity/User.php
 updated: config/packages/security.yaml


  Success!


 Next Steps:
   - Review your new App\Entity\User class.
   - Use make:entity to add more fields to your User entity and then run make:migration.
   - Create a way to authenticate! See https://symfony.com/doc/current/security.html

The output tells us that MakerBundle created a user entity aswell as the related repository and updated our security.yaml. Because we chose "User" for the class name and Doctrine to store the user data, the bundle created an entity instead of a common user class. Let's take a look at the user entity:

#/src/Entity/User.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 */
class User implements UserInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=180, unique=true)
     */
    private $username;

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

    /**
     * @var string The hashed password
     * @ORM\Column(type="string")
     */
    private $password;

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * A visual identifier that represents this user.
     *
     * @see UserInterface
     */
    public function getUsername(): string
    {
        return (string) $this->username;
    }

    public function setUsername(string $username): self
    {
        $this->username = $username;

        return $this;
    }

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

        return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    /**
     * @see UserInterface
     */
    public function getPassword(): string
    {
        return (string) $this->password;
    }

    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

    /**
     * @see UserInterface
     */
    public function getSalt()
    {
        // not needed when using the "bcrypt" algorithm in security.yaml
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials()
    {
        // If you store any temporary, sensitive data on the user, clear it here
        // $this->plainPassword = null;
    }
}

The user entity contains four private properties: $id, $username, $roles, $password. $username was created because we chose username as our display name when we used the MakerBundle to create the entity. $roles is an array that will hold the users roles we need for authorization after the authentication process. $password will hold the encoded password of the user.

In addition to these properties, MakerBundle also created all the getter and setter methods for them plus two extra methods, getSalt() and eraseCredentials(). These methods have to be in our entity because the class implements Symfony\Component\Security\Core\User\UserInterface. When the MakerBundle asked us if we want to use the newer Argon2i to create the password hashs, we chose No. So instead of Argon2i we use bcrypt, and this means we dont have to care about getSalt(). eraseCredentials() will not be touched in our example ether.

The getter method for $roles differ from the rest. This getter ensures at least one role for the user, even if there are no roles set for that user.

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

        return array_unique($roles);
    }

Now we are going to take a look at the updated security.yaml:

security:
    encoders:
        App\Entity\User:
            algorithm: bcrypt

    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: username
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true

            # activate different ways to authenticate

            # http_basic: true
            # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate

            # form_login: true
            # https://symfony.com/doc/current/security/form_login_setup.html

    # 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 }

The MakerBundle added an encoders key under the security key:

security:
	encoders:
        App\Entity\User:
            algorithm: bcrypt

And also added an app_user_provider key under the security.providers key:

security:
	providers:
        	# used to reload user from session & other features (e.g. switch_user)
        	app_user_provider:
            	entity:
                	class: App\Entity\User
                	property: username

These settings tell Symfony to use bcrypt as encoder for our passwords and add the user entity as a provider. So thanks to the MakerBundle, already a lot of things happend with a single command. Of course we want to store more data inside our user entity. We also want to store the firstname, lastname and email of the user. Lazy as we are, we use the MakerBundle again:

$ ./bin/console make:entity

 Class name of the entity to create or update (e.g. TinyPizza):
 > User

 Your entity already exists! So let's add some new fields!

 New property name (press <return> to stop adding fields):
 > firstname

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 > 100

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/User.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > lastname

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 100

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/User.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > email

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 150

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/User.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >



  Success!


 Next: When you're ready, create a migration with make:migration

Nice, within a few seconds we updated our user entity and can migrate already.

migrate the user entity

For now we only have our user entity but no actual database. Let's create our database and migrate our user entity into the database. But first we have to edit our /.env, alternatively we can edit /.env.local while working on the local development machine. So update the following line with your database settings:

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name

Our project will use the following settings:

DATABASE_URL=mysql://sam:supersecretpassword@127.0.0.1:3306/securityexample

We can use the command line to create the database in our system:

$ ./bin/console doctrine:database:create
Created database `securityexample` for connection named default

The database is now ready and we can migrate the entity. To do that we need to create a migration file which contains the queries we need to send to our database to create the table for the entity. And again, the freaking awesome MakerBundle is going to help us:

$ ./bin/console make:migration


  Success!


 Next: Review the new migration "src/Migrations/Version20190511110628.php"
 Then: Run the migration with php bin/console doctrine:migrations:migrate
 See https://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html

The output inside the console tells us where the migration file lives and even tells us the next command we are going to use. The console command doctrine:migrations:migrate will check if there are any new migration files available. If so and if you choose to execute them, the script will update the migration_versions table in your database with the current migration version, so the executed migration file doesn't get executed again next time you use the command. Alright, let's migrate!

$ ./bin/console doctrine:migrations:migrate

                    Application Migrations


WARNING! You are about to execute a database migration that could result in schema changes and data loss. Are you sure you wish to continue? (y/n)y
Migrating up to 20190511110628 from 0

  ++ migrating 20190511110628

     -> CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, firstname VARCHAR(100) NOT NULL, lastname VARCHAR(100) NOT NULL, email VARCHAR(150) NOT NULL, UNIQUE INDEX UNIQ_8D93D649F85E0677 (username), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB

  ++ migrated (took 60.4ms, used 14M memory)

  ------------------------

  ++ finished in 62.5ms
  ++ used 14M memory
  ++ 1 migrations executed
  ++ 1 sql queries

Done, our database with the user table related to our entity is good to go. Let's insert some dummy users for our tests next.

insert dummy users

I highly recommend to install the DoctrineFixturesBundle for handling your dummy data in your projects. However we will not use any fixtures in this example and just insert two users via command line. To do so just copy the following command into your console:

$ ./bin/console doctrine:query:sql "INSERT INTO user (username, roles, password, firstname, lastname, email) VALUES
('UserName', '[]', '$2y$13$5RmnElSyd1KapKAbf9VgbOuOZsZCKYe3fN4VN1GhhwT.hrcGmCpti', 'Vincent', 'Lynch', 'user@mail.com'),
('AdminName', '[\"ROLE_ADMIN\"]', '$2y$13$3lfylNZsVPyMNhwNUzLE2.RzMYTORjjZoenU98IpDWRCjqdGefrDK', 'Gregg', 'Stoltenberg', 'admin@mail.com');"

This will create two users. One user with the username "UserName" and no roles set and a second one with the username "AdminName" with the role ROLE_ADMIN. Both user use "test" as password. That's it for the database right now. Our next step will be to create our own authenticator.

If you want to create a user inside a controller you have to use a password encoder to set the password. Here is an example how to use it:

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Doctrine\Common\Persistence\ObjectManager;
use App\Entity\User;

class BaseController extends AbstractController {

    public function __constructor(UserPasswordEncoderInterface $userPasswordEncoder, ObjectManager $manager)
    {
        $this->userPasswordEncoder = $userPasswordEncoder;
        $this->manager = $manager;
    }

    public function createUser() {
        $user = new User();
        $user->setFirstname('Henry')
                ->setLastname('Ford')
                ->setUsername('Henry')
                ->setEmail('henry@ford.com');
        $user->setPassword($this->userPasswordEncoder->encodePassword($user, 'plainpassword'));

        $this->manager->persist($user);
        $this->manager->flush();

        return $this->render('user/create.html.twig');
    }
}

create an authenticator

To create the authenticator, use the following command:

./bin/console make:auth

 What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 1

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > FormAuthenticator

 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 > SecurityController

 created: src/Security/FormAuthenticator.php
 updated: config/packages/security.yaml
 created: src/Controller/SecurityController.php
 created: templates/security/login.html.twig


  Success!


 Next:
 - Customize your new authenticator.
 - Finish the redirect "TODO" in the App\Security\FormAuthenticator::onAuthenticationSuccess() method.
 - Review & adapt the login template: templates/security/login.html.twig.

This is awesome, the MakerBundle created a authenticator class named FormAuthenticator, created a controller with the route for a login, created a template with a basic login form and updated the security.yaml configuration file, so our authenticator listens to every request inside Symfony.

Let's create a route with an empty method for the logout. Add this method to the SecurityController:

/**
 * @Route("/logout", name="app_logout")
 */
public function logout()
{
}

We can leave the method empty and just add the route inside our security.yaml:

security:
    firewalls:
        main:
            logout:
                path: app_logout

The last thing we are going to do to complete the authentication is a super small adaption in our authenticator. We have to tell our authenticator where to redirect to when the authentication has been successfull. We are going to use the route app_home, but feel free to change this route so it suits your needs. Open the authenticator file /src/Security/FormAuthenticator.php and replace line 88 and 89:

// For example : return new RedirectResponse($this->urlGenerator->generate('some_route'));
throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
with:
return new RedirectResponse($this->urlGenerator->generate('app_home'));

This change sets the URL redirection to the home page of the application. And we are done with the authentication part! The login form is already working. Go ahead an try it out! You can see your current user status in Symfony's developer bar when you are in development mode. You are able to use http://pathtomyapplication/login and http://pathtomyapplication/logout to test the authentication process.

Before we take care of the authorization, we take a look on each method of the authenticator and explain what happens there.

What happens inside supports()?

public function supports(Request $request)
{
    return 'app_login' === $request->attributes->get('_route')
        && $request->isMethod('POST');
}

Since we created our authenticator with the MakerBundle, the script added our authenticator in the security configuration file to the firewall. This means, every time Symfony performs a request, this method gets called, and if supports() returns true, the getCredentials() method of the authenticator will be called. The logic inside our generated supports() checks if the route for the request is going to the login and checks if the request method is POST. So if you call app_login in your browser via the GET method, you will see the login form. And because $request->isMethod('POST') is false, then supports() will return false as well, so our authenticator will do nothing. If you submit the form, you will request app_login with a POST method and our authenticator will call getCredentials().

What happens inside getCredentials()?

public function getCredentials(Request $request)
{
    $credentials = [
        'username' => $request->request->get('username'),
        'password' => $request->request->get('password'),
        'csrf_token' => $request->request->get('_csrf_token'),
    ];
    $request->getSession()->set(
        Security::LAST_USERNAME,
        $credentials['username']
    );

    return $credentials;
}

This method is used to fetch the user credentials from the request, create an array with the user data and pass the credentials to the next method, named getUser(). In addition to this, we store the username in a special session key, so we can fetch it with the AuthenticationUtils.

What happens inside getUser()?

public function getUser($credentials, UserProviderInterface $userProvider)
{
    $token = new CsrfToken('authenticate', $credentials['csrf_token']);
    if (!$this->csrfTokenManager->isTokenValid($token)) {
        throw new InvalidCsrfTokenException();
    }

    $user = $this->entityManager->getRepository(User::class)->findOneBy(['username' => $credentials['username']]);

    if (!$user) {
        // fail authentication with a custom error
        throw new CustomUserMessageAuthenticationException('Username could not be found.');
    }

    return $user;
}

This method is used to fetch the user from the database and pass it to checkCredentials(). Before the method queries the database for the user, it checks if the CSRF token generated by our login form is valid to prevent attacks from other servers. If the request contains a valid token, the method queries the database for a user with the display name (username) we got inside $credentials. If there is no user, the method will throw an error message to display, otherwise the user will be returned and checkCredentials() called.

What happens inside checkCredentials()?

public function checkCredentials($credentials, UserInterface $user)
{
    return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
}

This method grabs the password encoder and checks if the password is valid. If so, isPasswordValid() will return true and the last method in the authentication process onAuthenticationSuccess() will be called.

What happens inside onAuthenticationSuccess()?

public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
    if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
        return new RedirectResponse($targetPath);
    }

    return new RedirectResponse($this->urlGenerator->generate('app_home'));
}

This method defines what happens after a successful authentication. So what is happening in the if statement? Imagine you tried to access a secured area of your application but forgot to login first, Symfony will redirect you to the login form. If you authenticate successfully, the path of the source you originally tried to require will be available with $this->getTargetPath($request->getSession(), $providerKey). So the if statement tries to fetch a targetPath, and if there is one available, the user will be redirected to the location he came from. Otherwise, the user will be redirected to the route app_home.

Try to login with both users to test if everything works fine. You can check the current logged in users at the Symfony toolbar at the bottom of your application (if you are working inside the dev env).

We are done with the authentication process (user has to prove his identity) and have to take care of the authorization (figure out if user has access) now.

Part 2: Authorization

So far we can login with two different users with two different roles (ROLE_USER and ROLE_ADMIN) but we have no restrictions for them, let's change that. But first we are going to take a look at the roles we used.

roles

As we created the user entity we talked about the getRoles() method inside the entity and the fact that this method ensures at least one role (ROLE_USER) for the user. The second role we are using in our test application is the role ROLE_ADMIN for our admin user, and due to the fact how getRoles() inside the user entity works our admin user has both roles (ROLE_USER and ROLE_ADMIN).

access control

One way of creating restrictions for your users is called access control. Access controls are a super easy way to create restrictions for complete areas of your application and is all you need for simple projects.

Let us take a look at the security.yaml file, more precisely the last three lines:

access_control:
    # - { path: ^/admin, roles: ROLE_ADMIN }
    # - { path: ^/profile, roles: ROLE_USER }

For now the generated file provides us with two commented examples of access control. path contains a regular expression of the URI and roles contains the role that has to be granted for access.

So if we would uncomment path definitions only users with the role ROLE_ADMIN would be able to access URIs starting with /admin, and only logged in users will be able to access requests starting with /profile. An anonymous user will not be able to access any of those request and will be redirected to the login route. Easy and neat.

restrict access inside a controller

Authorization can be checked inside a controller with annotations or with the method denyAccessUnlessGranted() provided by the base controller provided by Symfony.

The denyAccessUnlessGranted() can be used like this:

/**
 * @Route("/hello-world", name="hello_world")
 */
public function helloworld() {
    $this->denyAccessUnlessGranted('ROLE_ADMIN');
    // ...
}

This route is only accessible by users with the role ROLE_ADMIN. Anonymous user will be redirected to the login page and logged in users without ROLE_ADMIN will be redirected to a error 403 page.

Annotations are another simple way to control your users. To achieve the same result as the example above you can use the @IsGranted annotation at your method:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;

// ...

/**
 * @Route("/hello-world", name="hello_world")
 * @IsGranted("ROLE_ADMIN")
 */
public function helloworld() {
    // ...
}

@IsGranted can deny access to a complete controller and all its method aswell:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
/**
 * @IsGranted("ROLE_ADMIN")
 */
class TestController extends Controller
{
    // ...
}

access control in templates

IsGranted also helps inside templates to check permissions:

{% if is_granted('ROLE_ADMIN') %}
    <strong>only shown to admins</strong>
{% endif %}

Overall security within Symfony is a breeze! More details about Symfonys security can be found here.

This article is just a quick overview and a more detailed article will be released at some point. So stay tuned! Use the comment section for any questions or improvments.

With best regards, Sam.

comment successfully committed

leave a comment:

please enter your first name above
please enter a valid email above
please enter your message before you send the form

connect

please enter your name
please enter a valid email above
please enter the subject for your request
please enter your message before you send the form
mail successfully committed