Authentication and authorization with the security component
Until now, the routes we created were available without access restrictions. Indeed, if our api was deployed, anyone could have requested it, which can be a problem especially for sensitive requests such as creation, update or deletion.
To correct this problem, we will implement an authentication system via api tokens. A user who is already authenticated will only have to fill in his personal token to use the api without restriction. To do this, we will use the security-bundle
of Symfony which integrates a lot of tools to manage the connection, authentication and security in general.
Overview of the security component
First, like any other package, we will have to install it.
composer require symfony/security-bundle
In addition to installing the various libraries needed, this command also creates a new security.yaml
configuration file in the /config/packages
folder.
As the name suggests, this file is used to configure the security of our api:
- the way passwords are hashed
- firewalls
- the access controls
It is this file that we will modify later to create our own security system.
This security system is based on a particular class: User
which does not exist yet. This class will allow a user to connect, to access protected resources, ...
It is precisely this class that we will create.
Creating the User
class
You might be tempted to use the make:entity
command to create the User
class. However, as we mentioned, the User
class is a bit special and requires a different command. Instead, we will use the following command.
php bin/console make:user
This command allows us to create the User
class but also to execute all the related tasks.
You will be asked a series of questions afterwards. You will have to answer this:
This command should have created a new entity: User
in which the fields id
, username
, roles
and password
are located. To make this entity fit our needs, we will have to modify it.
To do this, we will use the following command.
php bin/console make:entity User
Next, we will add the new token
field. This field will be of type string
, will contain 36
characters and will not be nullable
.
You should have something like this.
The new token
field should have been added to our entity. Now we just need to create this new table in the database. As for the previous migrations, we will use the same command.
php bin/console make:migration
Then we will execute it.
php bin/console doctrine:migration:migrate
Or simply
php bin/console d:m:m
Creating the Factory
As with the Todo
entity, we will create a Factory
in order to facilitate the creation of fake data.
To do this, we will use the following command.
php bin/console make:factory
After filling in the User
entity number, a new factory should appear in the src/Factory
folder.
In the getDefaults
method, we will fill in the following content.
// src/Factory/UserFactory.php
protected function getDefaults(): array
{
return [
'password' => "password",
'token' => bin2hex(random_bytes(18)),
'username' => self::faker()->userName(),
];
}
The password we enter here is not the password that will be stored in the database. Indeed, we will have to hash it. To do this, we will use the initialize
method. In this method, we will fill in the following content.
// src/Factory/UserFactory.php
protected function initialize(): self
{
return $this
->afterInstantiate(function (User $user) {
$user->setPassword($this->passwordHasher->hashPassword($user, $user->getPassword()));
})
;
}
However, in order for this to work, we will have to inject the service allowing us to hash passwords. So we will create a constructor for this class and fill in the following content.
// src/Factory/UserFactory.php
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
public function __construct(private UserPasswordHasherInterface $passwordHasher)
{
parent::__construct();
}
To make things easier, we use the PHP 8 syntax here.
In this way, for each user we create, the User
object will first be created with a temporary password password
which we then secure.
Creating the Fixture
Now we just have to create a fixture
to dynamically create dummy users. To do this, we will first create a fixture
with the following command.
php bin/console make:fixture UserFixtures
Then, in the src/Fixtures/UserFixtures.php
file that has just been created, we will modify the load
method.
// src/DataFixtures/UserFixtures.php
use App\Factory\UserFactory;
public function load(ObjectManager $manager): void
{
UserFactory::createOne();
}
For this example, we will create only one user. This will be enough to test our api.
Finally, we just have to launch our new fixture with the following command.
php bin/console doctrine:fixtures:load --group=UserFixtures --append
Or simply
php bin/console d:f:l --group=UserFixtures --append
This command allows to launch only the UserFixtures
fixture thanks to the --group
parameter. The --append
parameter allows not to reset the database before launching the fixture. Indeed, by default, as soon as the doctrine:fixtures:load
command is executed, the database is purged.
If we take a look at our database, we should see our user.
Perfect.
Creating an authenticator
An authenticator
with Symfony is a class that will be called to authenticate the user in a certain context. This class will check the user's credentials and, if necessary, authenticate him.
There are already several authenticator
preconfigured by Symfony but in our case, we will have to develop it ourselves.
Let's start by creating a new file called TokenAuthenticator.php
in the src/Security
directory (which doesn't exist yet).
In this file, we will add the following content.
// src/Security/TokenAuthenticator.php
namespace App\Security;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
class TokenAuthenticator extends AbstractAuthenticator
{
// ...
}
If you use a modern code editor, you should see the line class TokenAuthenticator extends AbstractAuthenticator
underlined in red like this.
As stated in the error description, this error is simply because some methods are not yet implemented.
'App\Security\TokenAuthenticator' does not implement methods 'supports', 'authenticate', 'onAuthenticationSuccess', 'onAuthenticationFailure'
More precisely the :
supports
which is used to fill in the conditions that must be met for the request to be accepted by theauthenticator
.authenticate
which is used to authenticate the user.onAuthenticationSuccess
which is used to fill in the steps to follow after successful authentication.onAuthenticationFailure
which is used to fill in the steps to follow after authentication failure.
We will have to add each of these methods to make our authenticator
work.
Let's start with the supports
method. As mentioned, this method must return a boolean to indicate whether or not the request can be handled by our authenticator
.
In our case, we just need the request to contain the X-AUTH-TOKEN
header.
We use here the
X-AUTH-TOKEN
header instead of anAuthorization
header with abearer
token because depending on the system, the latter is not transmitted to the PHP program. This is notably the case of Apache which does not relay this header to the PHP program. As a precaution, we will use here theX-AUTH-TOKEN
header which will work in all cases.
// src/Security/TokenAuthenticator.php
use Symfony\Component\HttpFoundation\Request;
public function supports(Request $request): ?bool
{
return $request->headers->has("X-AUTH-TOKEN");
}
Concerning the authenticate
method, we will use the following code.
// src/Security/TokenAuthenticator.php
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
public function authenticate(Request $request): Passport
{
$token = $request->headers->get('X-AUTH-TOKEN');
if (null === $token) {
throw new CustomUserMessageAuthenticationException('No API token provided');
}
return new SelfValidatingPassport(new UserBadge($token));
}
We will first retrieve the authentication token from the request in order to use it in the auto-validated passport.
We use here the
SelfValidatingPassport
class because no password is needed. As soon as the authentication token is filled in, the process is valid.
Then, for the onAuthenticationSuccess
method, we will simply return null
because no additional process is needed.
// src/Security/TokenAuthenticator.php
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\HttpFoundation\Response;
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
Finally, for the onAuthenticationFailure
method, we will follow the same logic. We will simply return null
.
// src/Security/TokenAuthenticator.php
use Symfony\Component\Security\Core\Exception\AuthenticationException;
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
throw new AuthenticationException($exception->getMessage());
}
That's all we need to do for authentication. Well, almost. Indeed, we will have to modify the /config/packages/security.yaml
file in order to take into account our authenticator
.
First, we will modify the property
key of the app_user_provider
. We will put the value token
in it. This value corresponds to the token
field of the User
entity we created earlier. This modification allows us to indicate that this token
field must be used to authenticate a user.
Finally, we will modify the firewalls
part in order to use our authenticator
.
To do this, we will modify the main
key and add the following lines:
# config/packages/security.yaml
main:
stateless: true
provider: app_user_provider
custom_authenticators:
- App\Security\TokenAuthenticator
The stateless
field allows to say that each request must be managed independently from the others. The provider
field allows you to specify the provider
you want to use, which will then be injected into our authenticator
. Finally, the custom_authenticators
field allows to list the authenticators
to use. In our case, we will only put our custom authenticator
.
That's it.
Unfortunately, our system at the moment will not be able to restrict access to certain routes on its own. Indeed, we will also have to specify the routes concerned by this restriction. As we mentioned, we will restrict access to routes that concern creation, modification and deletion.
Restricting access to routes
As for a classic Symfony application, we will use the IsGranded
annotation which allows us to restrict access to a route according to the user's role. In our case, we use the IS_AUTHENTICATED
role which only allows us to check that the user is authenticated.
So, to restrict the routes concerned, we will add the following annotation before each method associated to a route. As a reminder, we will restrict access to the routes DELETE
, POST
, PATCH
and PUT
.
// src/Controller/TodoController.php
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted("IS_AUTHENTICATED")]
This way, if a user tries to use a protected route without filling in the X-AUTH-TOKEN
header, a 401 Unauthorized
error is returned.
Now, if we try to use the api/todos/1
route with the DELETE
method without specifying a token, we should get a 401 error.
On the other hand, if we try this same route with the authentication header, the request should work.
The value we need to fill in in the X-AUTH-TOKEN
header must match the token
column of one of the users in the user
table in the database.
It's great.
We have now succeeded in making some roads safe. Of course, this is only an example, we could go much further. If you are interested in this topic, I suggest you to have a look at the Symfony Voters documentation. This component allows you to bring more details about route restrictions.
In summary
In this lesson, we have seen some very important notions to secure a Symfony api. We have seen what an authenticator
is, how to create one and how to use it. Also, we discovered how to apply restricted access to some routes.
Finally, we saw how to create the most important class in a Symfony application: the User
class. Indeed, this class can't be created like the other entities.
In the next lesson, we'll discover how to test an api thanks to functional and unit tests that can be done with PHPUnit and Symfony.