Hey salut, bienvenue dans ce tutoriel sur Symfony et API Platform. Nous allons dans ce tutoriel parler de l'autorisation. L'autorisation comme son nom l'indique c'est dire si un visiteur de notre site a accès ou non à certaines parties de notre application. Dans la partie précédente, nous avons fait en sorte que nos visiteurs puissent s'inscrire et s'authentifier sur notre API. Mais disons que notre API, c'est comme celui de Kaherecode par exemple, plusieurs utilisateurs peuvent s'inscrire et écrire des articles, les modifier et supprimer comme ils veulent. Actuellement, la seule sécurité c'est que personne ne peut créer, modifier ou supprimer un article sans être authentifié, mais une fois authentifié, l'utilisateur peut modifier n'importe quel article, supprimer l'article qu'il souhaite et celà n'est pas envisageable, il faut donc rajouter une autre couche de sécurité, où nous allons dire qu'une personne authentifiée ne peut modifier que les articles qu'elle a créé, et que les utilisateurs avec un rôle administrateur, peuvent modifier ou supprimer tous les articles sur la plateforme.

Nous allons d'abord commencer par faire le lien entre les entités Article et User, c'est une relation ManyToOne, c'est à dire qu'un utilisateurs peut écrire plusieurs articles, mais qu'un article appartient à un seul utilisateur. Pour te rafraichir un peu la mémoire sur les relations entre entités, va suivre ce tutoriel qui en parle bien en détail.

Nous allons définir la relation dans la classe Article avec la commande make:entity:

$ php bin/console make:entity

Je crée un nouvel attribut author dans la classe Article, cet attribut est de type User et représente donc le propriétaire de l'article.

Il faut ensuite mettre à jour la base de données, mais avant, si tu avais déjà créé un article auparavant, il faut le supprimer.

$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate

Et après ajouter les groupes sur les différents attributs des 2 classes, je veux à chaque fois que je lis un article, avoir l'id et le username de son auteur, de la même manière quand je lis un utilisateur, avoir l'id et le title de ses différents articles.

src/Entity/Article.php

<?php
// src/Entity/Article.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\Collection;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiSubresource;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @ApiResource(
 *     normalizationContext={"groups"={"article:read"}},
 *     denormalizationContext={"groups"={"article:write"}}
 * )
 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
 */
class Article
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     *
     * @Groups({"article:read", "user:read"})
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     *
     * @Groups({"article:read", "article:write", "user:read"})
     */
    private $title;

    // ...

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="articles")
     * @ORM\JoinColumn(nullable=false)
     *
     * @Groups("article:read")
     */
    private $author;

    // ...

    public function getAuthor(): ?User
    {
        return $this->author;
    }

    public function setAuthor(?User $author): self
    {
        $this->author = $author;

        return $this;
    }
}

src/Entity/User.php

<?php
// src/Entity/User.php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\SerializedName;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 *
 * @ApiResource(
 *     normalizationContext={"groups"={"user:read"}},
 *     denormalizationContext={"groups"={"user:write"}}
 * )
 */
class User implements UserInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     *
     * @Groups({"user:read", "article:read"})
     */
    private $id;

    // ...

    /**
     * @ORM\Column(type="string", length=255)
     *
     * @Groups({"user:read", "user:write", "article:read"})
     */
    private $username;

    // ...

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\Article", mappedBy="author")
     *
     * @Groups("user:read")
     */
    private $articles;

    public function __construct()
    {
        $this->articles = new ArrayCollection();
    }

    // ...

    /**
     * @return Collection|Article[]
     */
    public function getArticles(): Collection
    {
        return $this->articles;
    }

    public function addArticle(Article $article): self
    {
        if (!$this->articles->contains($article)) {
            $this->articles[] = $article;
            $article->setAuthor($this);
        }

        return $this;
    }

    public function removeArticle(Article $article): self
    {
        if ($this->articles->contains($article)) {
            $this->articles->removeElement($article);
            // set the owning side to null (unless already changed)
            if ($article->getAuthor() === $this) {
                $article->setAuthor(null);
            }
        }

        return $this;
    }
}

Maintenant que nous avons bien défini la relation entre nos entités, nous allons définir un contrôle d'accès pour spécifier qu'il faut être authentifié pour créer un article. Pour cela, nous allons utiliser les opérations avec API Platform. Tu te rappelles dans la première partie de cette série, on parlait des itemOperations et collectionOperations, eh bien les revoilà.

Vu que nous n'allons pas interdire l'accès à toutes les méthodes pour l'endpoint /api/articles, nous allons juste interdire l'accès aux méthodes POST, PUT et DELETE. Pour l'instant je vais juste m'intéresser a la méthode POST, comme on l'a dit il faut être authentifié pour créer un article, nous allons donc utiliser la couche de sécurité de API Platform qui est fait à partir du composant de sécurité de Symfony. Tu peux lire ce tutoriel sur la sécurité avec Symfony.

<?php
// src/Entity/Article.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\Collection;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiSubresource;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @ApiResource(
 *     normalizationContext={"groups"={"article:read"}},
 *     denormalizationContext={"groups"={"article:write"}},
 *     collectionOperations={
 *         "get",
 *         "post"={"security"="is_granted('ROLE_USER')"}
 *     },
 * )
 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
 */
class Article
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     *
     * @Groups({"article:read", "user:read"})
     */
    private $id;

    // ...
}

Nous ajoutons collectionOperations dans l'annotation @ApiResource, parce que la méthode POST et une opération de type collection, ensuite sur la clé post, on ajoute security et on lui dit is_granted('ROLE_USER'). C'est aussi simple que cela, si tu essaies de créer un article sans s'être authentifié auparavant, tu as donc une erreur 401.

Avant de passer à la création d'un article, il nous faut d'abord mettre l'utilisateur authentifié comme auteur de l'article à la création, nous allons faire cela dans ArticleDataPersister.php:

<?php
// src/DataPersister/ArticleDataPersister.php

namespace App\DataPersister;

// ...
use Symfony\Component\Security\Core\Security;

/**
 *
 */
class ArticleDataPersister implements ContextAwareDataPersisterInterface
{
    // ...

    /**
     * @param Security
     */
    private $_security;

    public function __construct(
        EntityManagerInterface $entityManager,
        SluggerInterface $slugger,
        RequestStack $request,
        Security $security
    ) {
        $this->_entityManager = $entityManager;
        $this->_slugger = $slugger;
        $this->_request = $request->getCurrentRequest();
        $this->_security = $security;
    }


    // ...

    /**
     * @param Article $data
     */
    public function persist($data, array $context = [])
    {
        // Update the slug only if the article isn't published
        if (!$data->getIsPublished()) {
            $data->setSlug(
                $this
                    ->_slugger
                    ->slug(strtolower($data->getTitle())). '-' .uniqid()
            );
        }

        // Set the author if it's a new article
        if ($this->_request->getMethod() === 'POST') {
            $data->setAuthor($this->_security->getUser());
        }

        // Set the updatedAt value if it's not a POST request
        if ($this->_request->getMethod() !== 'POST') {
            $data->setUpdatedAt(new \DateTime());
        }

        $tagRepository = $this->_entityManager->getRepository(Tag::class);
        foreach ($data->getTags() as $tag) {
            $t = $tagRepository->findOneByLabel($tag->getLabel());

            // if the tag exists, don't persist it
            if ($t !== null) {
                $data->removeTag($tag);
                $data->addTag($t);
            } else {
                $this->_entityManager->persist($tag);
            }
        }

        $this->_entityManager->persist($data);
        $this->_entityManager->flush();
    }

    // ...
}

On vérifie si c'est une méthode POST, puis on renseigne l'auteur de l'article avec $data->setAuthor($security->getUser()). Nous utilisons le composant sécurité de Symfony pour avoir accès à l'utilisateur authentifié, la méthode getUser() retourne l'utilisateur authentifié ou null si personne n'est authentifié, mais nous on s'assure déjà qu'aucun utilisateur n'atteindra ce niveau s'il n'est pas authentifié.

Maintenant que nous avons contrôler l'accès sur la création d'un article, nous allons nous pencher sur la modification et la suppression d'un article. Pour modifier ou supprimer un article, il faut soit être le propriétaire de l'article, ou avoir un rôle administrateur. Nous allons représenter le rôle administrateur avec la chaîne ROLE_ADMIN, pour définir les contrôles d'accès, nous allons cette fois-ci utilisé itemOperations:

<?php
// src/Entity/Article.php

namespace App\Entity;

// ...

/**
 * @ApiResource(
 *     normalizationContext={"groups"={"article:read"}},
 *     denormalizationContext={"groups"={"article:write"}},
 *     collectionOperations={
 *         "get",
 *         "post"={"security"="is_granted('ROLE_USER')"}
 *     },
 *     itemOperations={
 *         "get",
 *         "put"={"security"="is_granted('ROLE_ADMIN') or object.author == user"},
 *         "delete"={"security"="is_granted('ROLE_ADMIN') or object.author == user"}
 *     }
 * )
 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
 */
class Article
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     *
     * @Groups({"article:read", "user:read"})
     */
    private $id;

    // ...
}

Cette fois-ci, pour les méthodes put et delete, nous avons soit is_granted('ROLE_ADMIN') ou object.author équivaut a user. object c'est l'objet que nous traitons, donc un objet de type Article dans ce cas et user c'est l'utilisateur qui est actuellement authentifié, on compare donc l'attribut author de Article (object) à l'utilisateur qui est actuellement connecté.

Si tu essaies maintenant de modifier ou supprimer un article, tu as une erreur qui dit impossible d'accéder à un attribut private author, il faut donc qu'il soit public. Pour l'instant nous allons mettre l'attribut author à public pour faire nos tests, connecte toi ensuite et essaie de modifier un article que tu n'as pas créer avec cet utilisateur, tu vas avoir une erreur 403, qui dit que tu n'as pas le droit de modifier cet article. On a réussi, à peu près. Nous avons failli à une règle importante la Programmation Orienté Objet, l'encapsulation, qui dit que tout les attributs d'une classe doivent être private ou protected. Et vu que nous faisons de la POO, il faut donc respecter ses règles.

Ce que nous allons faire, c'est que nous allons nous même créer notre propre voter pour vérifier ces permissions. Pour créer un voter, nous allons créer une classe ArticleVoter.php dans src/Security/ et y mettre le contenu suivant:

<?php
// src/Security/ArticleVoter.php

namespace App\Security;

use App\Entity\User;
use App\Entity\Article;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class ArticleVoter extends Voter
{
    const EDIT = 'edit';
    const DELETE = 'delete';

    /**
     * Determines if the attribute and subject are supported by this voter.
     *
     * @param string $attribute An attribute
     * @param mixed  $subject   The subject to secure, e.g. an object the user wants to access or any other PHP type
     *
     * @return bool True if the attribute and subject are supported, false otherwise
     */
    protected function supports(string $attribute, $subject)
    {
        if (!in_array($attribute, [self::EDIT, self::DELETE])) {
            return false;
        }

        if (!$subject instanceof Article) {
            return false;
        }

        return true;
    }

    /**
     * Perform a single access check operation on a given attribute, subject and token.
     * It is safe to assume that $attribute and $subject already passed the "supports()" method check.
     *
     * @param mixed $subject
     *
     * @return bool
     */
    protected function voteOnAttribute(
        string $attribute,
        $subject,
        TokenInterface $token
    ) {
        $user = $token->getUser();

        if (!$user instanceof User) {
            return false;
        }

        /**
         * @var Article
         */
        $article = $subject;

        return $user->hasRoles('ROLE_ADMIN') || $user === $article->getAuthor();
    }
}

Cette classe défini deux méthodes:

  • La méthode supports() se comporte comme la méthode supports() dans les DataPersister, elle fait donc une première vérification pour s'assurer que c'est bien le Voter dont nous avons besoin
  • La méthode voteOnAttribute() n'est appelée que si la méthode supports() retourne true, c'est dans cette méthode  que nous allons donc définir notre logique et si cette méthode retourne true, l'accès est accordé sinon une erreur 403 est renvoyée. Nous vérifions donc que l'utilisateur a le rôle ROLE_ADMIN ou qu'il est propriétaire de l'article.

J'ai ajouté la méthode hasRoles() dans la classe User.php:

<?php
// src/Entity/User.php

namespace App\Entity;

// ...

class User implements UserInterface
{
    // ...

    public function hasRoles(string $roles): bool
    {
        return in_array($roles, $this->roles);
    }
}

Et voilà, tu peux mettre l'attribut author dans la classe Article.php à private et aussi modifier les annotations:

<?php
// src/Entity/Article.php

namespace App\Entity;

// ...

/**
 * @ApiResource(
 *     normalizationContext={"groups"={"article:read"}},
 *     denormalizationContext={"groups"={"article:write"}},
 *     collectionOperations={
 *         "get",
 *         "post"={"security"="is_granted('ROLE_USER')"}
 *     },
 *     itemOperations={
 *         "get",
 *         "put"={"security"="is_granted('edit', object)"},
 *         "delete"={"security"="is_granted('delete', object)"}
 *     }
 * )
 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
 */
class Article
{
    // ...
}

Cette fois-ci, on passe en paramètre de is_granted la chaîne edit ou delete et l'objet Article.

Voilà comment est-ce qu'on sécurise nos entités, Moussa ne pourra plus modifier ou supprimer les articles de Adama, à moins que Moussa soit administrateur bien sûr.

Merci d'avoir suivi ce tutoriel jusqu'au bout, j'espère vraiment que tu as appris quelque chose tout le long. N'hésite pas à laisser un commentaire ci-dessous si tu as des questions ou à écrire directement dans le chat discord de Kaherecode où je serais beaucoup plus réactifs. Merci, à bientôt.


Partager cet article

alioukahere

Mamadou Aliou Diallo

@alioukahere

Développeur web fullstack avec une passion pour l’entrepreneuriat et les nouvelles technologies. Fondateur de Kaherecode.