Create a custom attribute/annotation and understand @security annotation behind the scene


Sep 4, 2023 Symfony


As a senior developer you will comme accross symfony code that look like this :

   /**
    * @MyCustomAttribute(name="my annotation", value="test")
    */

   
public function myAction()
   {

   }

   
#[MyCustomAttribute(name:"home", value:"homepage")]
   
public function myAction(): Response
   {

   }

 

This is a custom attribute/annotation named “MyCustomAttribute”. This means that somewhere in the code base we will be using ReflectionMethod class in order to get information about our attributes properties “name” and “value”. In case of annotation we will be using the annotation reader service. We will focus on attributes in the following sections

The target of these attributes can be on a class or a property. In our example we are using it on a method. Here is the full list of targets https://www.php.net/manual/en/class.attribute.php   

Let see an example :

Having this in our controller  :

   #[MyCustomAttribute(name:"home", value:"homepage")]
   
public function myAction(): Response
   {

   }

mean that we have a PHP class that represent the attribute MyCustomAttribute

<?php

namespace App\Annotation;

#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_PROPERTY)]
class MyCustomAttribute
{
   
private string $name;
   
private string $value;

   
/**
    * @return string
    */

   
public function getName(): string
   {
       
return $this->name;
   }

   
public function setName(string $name): void
   {
       
$this->name = $name;
   }

   
public function getValue(): string
   {
       
return $this->value;
   }

   
public function setValue(string $value): void
   {
       
$this->value = $value;
   }

}

Let us say that we want to get information on our attribute from the request listener, here is what our listener looks like

    public function onKernelRequest(RequestEvent $event): void
   {
       
//we get the controller class
       $controllerClassAndMethod = ($event->getRequest()->get(
'_controller'));

       
//we get the attributes of the controller
       $reflectionMethod =
new \ReflectionMethod($controllerClassAndMethod);
       $attributes = $reflectionMethod->getAttributes(MyCustomAttribute::class);

       
foreach ($attributes as $attribute) {
           
//we get our attribute properties name and value in an instance of MyCustomAttribute
           $class =
new \ReflectionClass(MyCustomAttribute::class);
           
/** @var MyCustomAttribute $myCustomAttributes */
           $myCustomAttribute = $class->newInstanceArgs($attribute->getArguments());

           
//we can then do whatever we want with these data
       }
   }

Deep dive in #[isGranted] attributes of Symfony

We will have a look at 2 classes :

  1. vendor/symfony/security-http/Attribute/IsGranted.php

<?php

namespace Symfony\Component\Security\Http\Attribute;

use Symfony\Component\ExpressionLanguage\Expression;


#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
final class IsGranted
{
   
public function __construct(
       
/**
        * Sets the first argument that will be passed to isGranted().
        */

       public string|Expression $attribute,

       
/**
        * Sets the second argument passed to isGranted().
        *
        * @var array<string|Expression>|string|Expression|null
        */

       public array|string|Expression|null $subject = null,

       
/**
        * The message of the exception - has a nice default if not set.
        */

       public ?string $message = null,

       
/**
        * If set, will throw HttpKernel's HttpException with the given $statusCode.
        * If null, Security\Core's AccessDeniedException will be used.
        */

       public ?int $statusCode = null,

       
/**
        * If set, will add the exception code to thrown exception.
        */

       public ?int $exceptionCode = null,
   ) {
   }
}

In this class we can see the properties of IsGranted attribute. Here is an example using these attributes

#[IsGranted('ROLE_ADMIN', statusCode: 403, exceptionCode: 10010,message: 'You are not allowed to access the admin dashboard.'))]

An example using the subject with a voter for example

#[IsGranted(Voter::ACCESS, subject: new Expression('request.attributes.get("userId")'), statusCode: Response::HTTP_FORBIDDEN)]

  1. vendor/symfony/security-http/EventListener/IsGrantedAttributeListener.php

This class is a listener on kernel.controller_arguments (The CONTROLLER_ARGUMENTS event occurs once controller arguments have been resolved)

I have commented the method content so we better understand what is happening inside

public function onKernelControllerArguments(ControllerArgumentsEvent $event)
   {
       
//we retrieve the attributes of our controller named IdGranted
       
/** @var IsGranted[] $attributes */
       
if (!\is_array($attributes = $event->getAttributes()[IsGranted::class] ?? null)) {
           
return;
       }

       $request = $event->getRequest();
       
//controller arguments are needed in order to be replaced on the evaluated expression passed on the subject attribute for example
       $arguments = $event->getNamedArguments();

       
// we loop on all isGranted attributes
       
foreach ($attributes as $attribute) {
           $subject =
null;

           
// we get the subject if it exists
           
if ($subjectRef = $attribute->subject) {
               
if (\is_array($subjectRef)) {
                   
foreach ($subjectRef as $refKey => $ref) {
                       $subject[\is_string($refKey) ? $refKey : (string) $ref] =
$this->getIsGrantedSubject($ref, $request, $arguments);
                   }
               }
else {
                   $subject =
$this->getIsGrantedSubject($subjectRef, $request, $arguments);
               }
           }

           
//we check access right and throw exception
           
if (!$this->authChecker->isGranted($attribute->attribute, $subject)) {
               $message = $attribute->message ?: sprintf(
'Access Denied by #[IsGranted(%s)] on controller', $this->getIsGrantedString($attribute));

               
if ($statusCode = $attribute->statusCode) {
                   
throw new HttpException($statusCode, $message, code: $attribute->exceptionCode ?? 0);
               }

               $accessDeniedException =
new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
               $accessDeniedException->setAttributes($attribute->attribute);
               $accessDeniedException->setSubject($subject);

               
throw $accessDeniedException;
           }
       }
   }


symfony