Home > front end >  Api-platform, JWT token and endpoints sending back data owned by the identified user
Api-platform, JWT token and endpoints sending back data owned by the identified user

Time:10-24

I'm using PHP symfony with API-platform with JWT token (through LexikJWTAuthenticationBundle), latest version as of today.

I've read quite a lot of things and I know how to do the basic stuff:

  • Create an API exposing my entities,
  • Protect certain endpoints with JWT
  • Protecting certain endpoints with user_roles

What I'm trying to do now is to have the API only sends back data that belongs to a user instead of simply sending back everything contained in the database and represented by an entity. I've based my work on this but this does not take into account the JWT token and I don't know how to use the token in the UserFilter class : https://api-platform.com/docs/core/filters/#using-doctrine-orm-filters

Here is my Book entity :

<?php
// api/src/Entity/Book.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use App\Entity\User;
use App\Attribute\UserAware;


/** A book. */
#[ORM\Entity]
#[ApiResource(operations: [
    new Get(),
    new GetCollection(),
    new Post(),
    new Put(),
    new Patch(),
    new Delete()
])]

#[UserAware(userFieldName: "id")]
class Book
{
    /** The id of this book. */
    #[ORM\Id, ORM\Column, ORM\GeneratedValue]
    private ?int $id = null;

    /** The ISBN of this book (or null if doesn't have one). */
    #[ORM\Column(nullable: true)]
    #[Assert\Isbn]
    public ?string $isbn = null;

    /** The title of this book. */
    #[ORM\Column]
    #[Assert\NotBlank]
    public string $title = '';

    /** The description of this book. */
    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    public string $description = '';

    /** The author of this book. */
    #[ORM\Column]
    #[Assert\NotBlank]
    public string $author = '';

    /** The publication date of this book. */
    #[ORM\Column(type: 'datetime')]
    #[Assert\NotNull]
    public ?\DateTime $publicationDate = null;

    /** @var Review[] Available reviews for this book. */
    #[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'book', cascade: ['persist', 'remove'])]
    public iterable $reviews;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $publisher = null;

    /** The book this user is about. */
    #[ORM\ManyToOne(inversedBy: 'books')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id')]
    #[Assert\NotNull]
    public ?User $user = null;

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

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getPublisher(): ?string
    {
        return $this->publisher;
    }

    public function setPublisher(?string $publisher): self
    {
        $this->publisher = $publisher;

        return $this;
    }
}

Here is my UserFilter class :

<?php
// api/src/Filter/UserFilter.php

namespace App\Filter;

use App\Attribute\UserAware;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use App\Entity\User;

final class UserFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
    {
        // The Doctrine filter is called for any query on any entity
        // Check if the current entity is "user aware" (marked with an attribute)
        $userAware = $targetEntity->getReflectionClass()->getAttributes(UserAware::class)[0] ?? null;

        $fieldName = $userAware?->getArguments()['userFieldName'] ?? null;
        if ($fieldName === '' || is_null($fieldName)) {
            return '';
        }

        try {
            $userId = $this->getParameter('id');
            // Don't worry, getParameter automatically escapes parameters
        } catch (\InvalidArgumentException $e) {
            // No user id has been defined
            return '';
        }

        if (empty($fieldName) || empty($userId)) {
            return '';
        }

        return sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId);
    }
}

Here is my UserAware class :

<?php
// api/Annotation/UserAware.php

namespace App\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final class UserAware
{
    public $userFieldName;
}

I added this to my config/packages/api_platform.yaml file:

doctrine:
    orm:
        filters:
            user_filter:
                class: App\Filter\UserFilter
                enabled: true

It obviously does not work, since I'm not making the bridge between the JWT token and the filter, but I have no idea how to do it. What am I missing? The current results I have is that the GET /api/books sends back all the books stored in the database instead of sending only the ones belonging to the JWT authenticated user.

CodePudding user response:

Instead of Doctrine Filter, you could use Doctrine Extension as described here. In your case it would need:

  1. Create the doctrine extension:
<?php
// api/src/Doctrine/CurrentUserExtension.php

namespace App\Doctrine;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Book;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;

final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    private $security;

    public function __construct(Security $security)
    {
        $this->security = $security;
    }

    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void;
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
    {
        if (Book::class !== $resourceClass || $this->security->isGranted('ROLE_ADMIN') || null === $user = $this->security->getUser()) {
            return;
        }

        $rootAlias = $queryBuilder->getRootAliases()[0];
        $queryBuilder->andWhere(sprintf('%s.user = :current_user', $rootAlias));
        $queryBuilder->setParameter('current_user', $user->getId());
    }
}

The main logic is in the addWhere() method:

  • applies only if you are dealing with Book entity (but you could extend the idea to a list of entities here)
  • check if the user is granted admin (if so here it skips the extension, allowing admin to fetch all books)
  • skip if the user isn't authenticated (you should prevent this access with firewall or security arribute in your endpoints)

Then it adds a where condition to the SQL query to filter by userId (or any other condition you'll need)

  1. Don't forget to eanble your filter:
# api/config/services.yaml
services:

    # ...

    'App\Doctrine\CurrentUserExtension':
        tags:
            - { name: api_platform.doctrine.orm.query_extension.collection }
            - { name: api_platform.doctrine.orm.query_extension.item }
  • Related