Creating a one api endpoint with PHP and Symfony (Part 2)

Nacho Colomina
6 min readDec 11, 2022

--

In the last article (https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-d5f03d3141c0) we saw how to create a one endpoint api and how to define operations.

In this second part, we will add security to our api in two ways:

  • We will authenticate through a token included in request http headers.
  • We will protect operations access using authorization checkings

Introduction

As in the previous article, a basic knowledge of Symfony it’s required to follow this one.

Before starting, we need to install symfony security component by using composer:

 composer require symfony/security-bundle

After having security component installed, we are ready to start

The Authenticator

As said, we’re going to use a symfony authenticator which will look into the request headers for the auth token. To do it, we need to create a class which extends from Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator

For more information about how to create this kind of authenticator, you can check symfony docs: https://symfony.com/doc/current/security/custom_authenticator.html

Let’s see how our authenticator looks like:

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class ApiTokenAuthenticator extends AbstractAuthenticator
{

public function supports(Request $request): ?bool
{
return $request->headers->has('X-AUTH-TOKEN') || preg_match('#\/api\/v1\/operation#', $request->getUri());
}

public function authenticate(Request $request): Passport
{
$apiToken = $request->headers->get('X-AUTH-TOKEN');
if (null === $apiToken) {
throw new CustomUserMessageAuthenticationException('No API token provided');
}

return new SelfValidatingPassport(new UserBadge($apiToken));
}

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
{
$data = [
'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
];

return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
}

The authenticator is pretty simple, it looks into the request headers and gets the token. If there is no token an exception will be thrown. Otherwise, authenticate method returns a SelfValidatingPassport. A UserBadge is passed to the Passport on which the token received is passed as a user identifier. Internally, the user badge will match the token against our users using the user provider defined in our security.yaml

The user provider is defined using an “in memory users” provider which will contains one user to work with:

security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
users_in_memory: {
memory: {
users: [
{ identifier: "fYDg7nMhlvxAtL7KfBnS", roles: ["ROLE_USER"] }
]
}
}

Finally, we need to create the firewall in the security.yaml firewalls section so our authenticator can do its work when then endpoint being called. Let’s see how the firewall looks like:

security:
..........
firewalls:
api:
pattern: '^/api/v1/operation'
provider: users_in_memory
custom_authenticators:
- App\Security\ApiTokenAuthenticator

When an operation is called, custom authenticator App\Security\ApiTokenAuthenticator will be executed and it will use users_in_memory provider to find the user which identifier matches the token sent in the http headers.

Checking the authentication

As we did in the previous article, we’re using postman to check our authenticator is working. In the image, we can see that the X-AUTH-TOKEN sent is not the same as our user identifier. When the request is sent an HTTP 401 Unauthorized code is returned.

HTTP 401 Unauthorized when sending an invalid X-AUTH-TOKEN

When writting the right token, authenticator lets us pass and the operation is executed:

Http 200 OK when sending the right X-AUTH-TOKEN

Operation authorization

So far, we have a firewall which protects our endpoint by validating the auth token contained into the http headers but, what about we want to restrict an operation to certain roles ?. In order to achieve this, we will lean on symfony voters.

For more information about how symfony voters work, you can check docs here: https://symfony.com/doc/current/security/voters.html

Let’s see how our voter looks like:

use App\Api\Operation\ListCountriesOperation;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class ListCountriesOperationVoter extends Voter
{

protected function supports(string $attribute, mixed $subject): bool
{
return $subject === ListCountriesOperation::class;
}

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if(!in_array('ROLE_SUPERUSER', $user->getRoles())){
return false;
}

return true;
}
}

Voter checks whether user is a SUPERUSER. Thanks to support method, this voter will only be executed when calling ListCountriesOperation.

Now, we have to go to ApiOperationHandler (which we created in the previous article) and, in the performOperation method, check whether user have permissions to execute the operation. We will need to use symfony Security service to achieve this. Let’s see it:

class ApiOperationHandler
{
......

private Security $security;

public function performOperation(ApiInput $apiInput): ApiOutput
{
if(!isset($this->operations[$apiInput->getOperation()])){
throw new ApiOperationException([], sprintf('Operation %s is not defined', $apiInput->getOperation()));
}

$operationHandler = $this->operations[$apiInput->getOperation()];
if(!$this->security->isGranted('EXECUTE_OPERATION', get_class($operationHandler))){
throw new AccessDeniedException('Not allowed to perform operation');
}

return $operationHandler->perform($apiInput);
}
}

As operation class name matches voter supports method, voteOnAttribute will be executed and, if user is not a superuser, an AccessDeniedException will be thrown

Let’s use postman to see what happens when we try to list countries:

Since our user is not a superuser, an HTTP 403 Access denied is returned.

In order to get Access denied as json response, I‘ve been used a symfony access denied handler. You can see how to do it here: https://symfony.com/doc/current/security/access_denied_handler.html#customize-the-forbidden-response

Checking authorization for multiple operations

What happens if we have some operations which share the same authorization criteria ?. In this case we could mark those operations with the same flag and then create a voter which supports operations marked with that flag.

Let’s start by adding a new method to our interface ApiOperationInterface

use App\Api\Model\ApiInput;
use App\Api\Model\ApiOutput;

interface ApiOperationInterface
{
public function perform(ApiInput $apiInput): ApiOutput;
public function getName(): string;
public function getInput(): string;

public function getGroup(): ?string;
}

Method getGroup will return which group operation belongs to. If no group returned operation does not belong to any group. Now let’s add ListCountriesOperation to COUNTRY group:

class ListCountriesOperation implements ApiOperationInterface
{
.....

public function getGroup(): ?string
{
return 'COUNTRY';
}


}

And now, let’s modify our voter to allow it to check for operation group.

class ListCountriesOperationVoter extends Voter
{

protected function supports(string $attribute, mixed $subject): bool
{
if(!$subject instanceof ApiOperationSubject){
return false;
}

return $subject->getGroup() === 'COUNTRY';
}

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if(!in_array('ROLE_SUPERUSER', $user->getRoles())){
return false;
}

return true;
}
}

In this case, an object of class ApiOperationSubject is passed to voter as a subject. Let’s see how it looks:

class ApiOperationSubject
{
private string $operation;
private ?string $group = null;

public function __construct(string $operation, ?string $group)
{
$this->operation = $operation;
$this->group = $group;
}

public function getOperation(): string
{
return $this->operation;
}

public function getGroup(): ?string
{
return $this->group;
}

}

ApiOperationSubject is really simple, it holds two properties (operation and group). With this, voters are able to check authorization based on operation name (class name) and / or operation group.

Finally, let’s change ApiOperationHandler so it pass an ApiOperationSubject:

class ApiOperationHandler
{
.......

public function performOperation(ApiInput $apiInput): ApiOutput
{
if(!isset($this->operations[$apiInput->getOperation()])){
throw new ApiOperationException([], sprintf('Operation %s is not defined', $apiInput->getOperation()));
}

$operationHandler = $this->operations[$apiInput->getOperation()];
if(!$this->security->isGranted('EXECUTE_OPERATION', new ApiOperationSubject(get_class($operationHandler), $operationHandler->getGroup()))){
throw new AccessDeniedException('Not allowed to perform this operation');
}

return $operationHandler->perform($apiInput);
}
}

In our case, attribute value does no matter since it’s no used on voter, but its mandatory to pass a value so symfony requires it (see vendor/symfony/security-core/Authorization/Voter/Voter.php line 29)

And now, if we send the request again with postman, we will see that an Access denied response is returned

Last words

As last words, I would like to say that there can be many other ways to achieve authorization checking. I like this one because (thanks to symfony voters) it provides a lot of flexibility to define how to check operation authorization.

In the next article, we will cover how to make some operations as asynchronous, execute them in the background and returning and HTTP Accepted code to the client.

--

--