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 :
- 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)] |
- 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; } } }
|