Des champs de formulaire Symfony sécurisés par vos données avec Symfony
Dans le chapitre précédent, nous avons vu comment ajouter
de la sécurité sur un champs de formulaire en fonction des rôles de
l’utilisateur. Cependant la gestion de la sécurité peut être plus complexe. En
effet, il est commun d’avoir besoin de l’objet (Article
dans notre exemple)
pour prendre une décision.
Dans ce chapitre, nous verrons comment autoriser l’édition du titre de l’article en fonction des rôles de l’utilisateur, mais aussi en fonction de la catégorie de l’article.
La règle de gestion est très simple : un utilisateur aura le droit d’éditer le
titre d’un article de la catégorie PHP
si il a le rôle
ROLE_ARTICLE_CATEGORY_PHP
. Nous pouvons formuler cette règle de manière plus générique :
Un utilisateur a le droit d’éditer le titre d’un article de la catégorie
XXX
si il a lui-même le rôleROLE_ARTICLE_CATEGORY_XXX
.
Section intitulée mise-a-jour-du-modeleMise à jour du modèle
On reprend les bases du chapitre un et on commence par ajouter la colonne category
à l’entité Article
. Dans un soucis de simplicité, nous allons hard-coder les différentes valeurs
possible :
class Article
{
const CATEGORIES = [
'Default',
'PHP',
'Golang',
'Ops',
];
/**
* @ORM\Column(type="string", length=255)
*/
private $category;
// ...
}
L’entité Admin
ayant déjà une propriété roles
, nous avons juste à mettre à
jour les fixtures:
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
// ...
$admin = new Admin();
$admin->setName('alice');
- $admin->setRoles(['ROLE_EDITOR']);
+ $admin->setRoles(['ROLE_EDITOR', 'ROLE_ARTICLE_CATEGORY_PHP']);
$manager->persist($admin);
// ...
}
}
Section intitulée creation-d-un-voterCréation d’un voter
Les voters sont très utiles pour vérifier les permissions d’un utilisateur.
Je ne sais pas pourquoi, mais j’ai pu remarquer au travers des formations ou des missions d’expertise chez des clients que les voters sont souvent méconnus des développeurs. Pourtant très simple à mettre en place et à tester, ils sont une alternative très puissante aux ACLs. Pour ma part, je n’ai jamais eu besoin d’avoir recours aux ACLs, et tant mieux car ce composant a été supprimé dans la version 4 de Symfony.
Nous allons implémenter un ArticleVoter
qui aura la responsabilité de donner
accès ou non à l’édition d’un article :
namespace App\Security\Voter;
use App\Entity\Article;
use App\Security\RoleMapping;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class ArticleVoter extends Voter
{
protected function supports($attribute, $subject)
{
return \in_array($attribute, ['ARTICLE_EDIT'], true)
&& $subject instanceof Article;
}
protected function voteOnAttribute($attribute, $article, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
if ('ARTICLE_EDIT' === $attribute) {
// ADMIN can do anything
if (\in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return true;
}
$roleNeed = RoleMapping::ARTICLE[$article->getCategory()] ?? false;
if (\in_array($roleNeed, $user->getRoles(), true)) {
return true;
}
return false;
}
return false;
}
}
Ici, nous utilisons la classe RoleMapping
pour avoir une association entre la
catégorie d’un article et le rôle nécessaire pour l’éditer :
namespace App\Security;
class RoleMapping
{
const ARTICLE = [
'Default' => 'ROLE_ARTICLE_CATEGORY_DEFAULT',
'PHP' => 'ROLE_ARTICLE_CATEGORY_PHP',
'Golang' => 'ROLE_ARTICLE_CATEGORY_GOLANG',
'Ops' => 'ROLE_ARTICLE_CATEGORY_OPS',
];
}
Et voici le test unitaire de la classe Voter
:
namespace App\Tests\Security\Voter;
use App\Entity\Admin;
use App\Entity\Article;
use App\Security\Voter\ArticleVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class ArticleVoterTest extends TestCase
{
private $voter;
protected function setUp()
{
$this->voter = new ArticleVoter();
}
public function testVoteOnSometingElse()
{
$token = $this->prophesize(TokenInterface::class);
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $this->voter->vote($token->reveal(), null, ['FOOBAR']));
}
public function testVoteWhenNotConnected()
{
$article = new Article(new Admin());
$token = $this->prophesize(TokenInterface::class);
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote($token->reveal(), $article, ['ARTICLE_EDIT']));
}
public function provideVoteTests()
{
$admin = new Admin();
$admin->setRoles([]);
$article = new Article(new Admin());
$article->setCategory('PHP');
yield 'admin without role can not edit' => [VoterInterface::ACCESS_DENIED, $admin, $article];
$admin = new Admin();
$admin->setRoles(['ROLE_ADMIN']);
$article = new Article(new Admin());
$article->setCategory('PHP');
yield 'ROLE_ADMIN can edit everything' => [VoterInterface::ACCESS_GRANTED, $admin, $article];
$admin = new Admin();
$admin->setRoles(['ROLE_ARTICLE_CATEGORY_PHP']);
$article = new Article(new Admin());
$article->setCategory('PHP');
yield 'ROLE_ARTICLE_CATEGORY_PHP can edit PHP article' => [VoterInterface::ACCESS_GRANTED, $admin, $article];
$admin = new Admin();
$admin->setRoles(['ROLE_ARTICLE_CATEGORY_PHP']);
$article = new Article(new Admin());
$article->setCategory('Golang');
yield 'ROLE_ARTICLE_CATEGORY_PHP can not edit Golang article' => [VoterInterface::ACCESS_DENIED, $admin, $article];
}
/** @dataProvider provideVoteTests */
public function testVote(int $expected, Admin $admin, Article $article)
{
$token = new UsernamePasswordToken($admin, 'password', 'provider_key', $admin->getRoles());
$this->assertSame($expected, $this->voter->vote($token, $article, ['ARTICLE_EDIT']));
}
}
C’est un test unitaire assez classique, mais je pense qu’il est intéressant car il permet de mettre en avant différents points :
- L’utilisation de la méthode
setUp
, qui permet de regrouper dans une méthode la création du SUT ; - L’annotation
@dataProvider
qui permet de fournir des jeux de tests ; - L’utilisation du mot clé
yield
, qui est plus lisible (et plus hype 😎) qu’un tableau ; - L’utilisation d’une clé (devant
yield
) qui permet de nommer les tests dudataprovider
; - L’utilisation de la librairie Prophecy pour la gestion des mocks, qui est plus lisible, simple (et plus hype) que celle de PHPUnit ;
- La simplicité de mise en place d’un test sur les voters.
Section intitulée mise-a-jour-de-l-extension-de-formulaireMise à jour de l’extension de formulaire
L’AuthorizationChecker
s’utilise de la façon suivante :
$authorizationChecker->isGranted($attribute, $subject);
Où $attribute
est une string
(ici ARTICLE_EDIT
) et $subject
peut être
n’importe quoi (ici une instance de Article
).
C’est la SecurityExtension
qui va devoir récupérer l’article pour le passer à
lAuthorizationChecker
. Le composant Form utilise un composant nommé PropertyAccess
pour
accéder aux propriété de vos objets (entité, DTO, …). C’est naturellement ce
composant que nous allons utiliser pour naviguer dans le graph d’objet pour
récupérer l’article.
Le PropertyAccessor
a besoin d’un propertyPath
qui correspond au nom de la
propriété (ou à une succession de propriétés : $foo->bar->baz
). L’extension
doit alors définir une nouvelle option is_granted_subject_path
:
class SecurityExtension extends AbstractTypeExtension
{
private $authorizationChecker;
private $propertyAccessor;
public function __construct(AuthorizationCheckerInterface $authorizationChecker, PropertyAccessorInterface $propertyAccessor)
{
$this->authorizationChecker = $authorizationChecker;
$this->propertyAccessor = $propertyAccessor;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'is_granted_attribute' => null,
'is_granted_subject_path' => null,
'is_granted_hide' => false,
'is_granted_disabled' => false,
]);
}
}
Il suffit de mettre à jour la méthode isGranted
de l’extension pour utiliser
cette nouvelle option :
class SecurityExtension extends AbstractTypeExtension
{
private function isGranted(array $options, FormInterface $form)
{
if (!$options['is_granted_attribute']) {
return true;
}
$subject = null;
if ($options['is_granted_subject_path']) {
$subject = $this->propertyAccessor->getValue($form, $options['is_granted_subject_path']);
}
if ($this->authorizationChecker->isGranted($options['is_granted_attribute'], $subject)) {
return true;
}
return false;
}
}
Nous pouvons voir que la valeur de départ du PropertyAccessor
est $form
. C’est
une instance de FormInterface
qui correspond au type
qui a l’option
is_granted_attribute
.
Cela implique qu’il faut connaître l’API public de FormInterface
pour naviguer
dedans et retrouver article
. Cependant, dans la majorité des cas il suffira de
« remonter » à la racine du form pour ensuite récupérer la donnée :
parent[.parent].data
dump($form);die;
fera très bien l’affaire en dev pour retrouver ses petits.
Section intitulée utilisationUtilisation
Pour utiliser cette nouvelle option, il faut mettre à jour la classe
ArticleType
:
class ArticleType extends AbstractType
{
private $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TextType::class, [
'is_granted_disabled' => $options['is_granted_disabled'],
'is_granted_attribute' => 'ARTICLE_EDIT',
'is_granted_subject_path' => 'parent.data',
])
// ..
;
}
}
Et Voilà !
Bonus : ça marche même avec EasyAdmin :
easy_admin:
entities:
Article:
form:
fields:
- property: title
type_options:
is_granted_attribute: ARTICLE_EDIT
is_granted_subject_path: parent.data
Section intitulée conclusionConclusion
Encore une fois le composant de formulaire, grâce notamment aux FormExtension
s, permet de changer facilement le comportement d’un formulaire. Il est primordial de bien connaître ce point d’extension de Symfony dès que l’on veut réaliser des formulaires avec des règles métiers complexes.
Vous pouvez aussi retrouver tout le code de ce deuxième chapitre sur Github.
Commentaires et discussions
Comprendre et éviter les attaques CSRF grâce à Symfony
CSRF veut dire Cross-Site Request Forgery en anglais, une traduction française pourrait être « Falsification de requêtes inter-sites ». Dans cet article, nous allons faire un rappel de ce qu’est une attaque CSRF et comment Symfony nous en protège. Puis au travers d’un exemple…
Lire la suite de l’article Comprendre et éviter les attaques CSRF grâce à Symfony
Nos articles sur le même sujet
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !
Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
L’équipe de Finarta a fait appel à JoliCode pour le développement de leur plateforme Web. Basée sur le framework Symfony 2, l’application est un réseau privé de galerie et se veut être une place de communication et de vente d’oeuvres d’art entre ses membres. Pour cela, de nombreuses règles de droits ont été mises en places et une administration poussée…
Refonte complète de la plateforme d’annonces immobilières de Cushman & Wakefield France. Connecté aux outils historiques, cette nouvelle vitrine permet une bien meilleure visibilité SEO et permet la mise en avant d’actifs qui ne pouvaient pas l’être auparavant.
Après avoir monté une nouvelle équipe de développement, nous avons procédé à la migration de toute l’infrastructure technique sur une nouvelle architecture fortement dynamique à base de Symfony2, RabbitMQ, Elasticsearch et Chef. Les gains en performance, en stabilité et en capacité de développement permettent à l’entreprise d’engager de nouveaux marchés…