Повышение безопасности и упрощение разработки в Symfony2 при помощи аннотаций и фильтров Доктрины

Как вы и сами прекрасно понимаете, пользователь должен видеть только свои заказы, свои сообщения и так далее, и ни в коем случае не чужие. Но, конечно, иногда пропустив условие выборки данных, например, забыв указать WHERE в ParamConverter, мы нарушаем это правило.

Я покажу вам как можно легко избавиться от подобного рода проблем не только на определенных запросах и таблицах, а в пределах всего Symfony приложения. И в этом нам помогут аннотации и фильтры Доктрины.

В итоге вы повысите безопасность приложения, упростите код (не придётся прописывать отдельные запросы) и сможете быстрее расширять функционал.

Предположим, у нас есть связанные сущности User и Order. Пользователь должен иметь доступ только к своим заказам.

<?php
/** @Entity **/
class User
{
  // ...
}
/** @Entity **/
class Order
{
  // ...
   /**
    * @ManyToOne(targetEntity="User")
    * @JoinColumn(name="user_id", referencedColumnName="id")
    **/
   private $user;
}

В целом, идея заключается в том, что каждый запрос на выборку заказов должен содержать в своем теле подобное условие WHERE user_id = :user_id.

Шаг 1. Создание собственной аннотации для работы с защищенными сущностями

Здесь мы создадим свою собственную новую аннотацию для сущностей Доктрины.

Смысл её должен заключаться в следующем:

  1. Она должна служить флагом для Доктрины, чтобы та добавляла дополнительное условию к выборке.
  2. Она должна предоставлять имя поля, по которому связываются сущности. Создадим класс аннотации, который позволит передать имя поля связки пользователи и заказа:
<?php
namespace Acme\DemoBundle\Annotation;
use Doctrine\Common\Annotations\Annotation;
/**
* @Annotation
* @Target("CLASS")
*/
final class UserAware
{
   public $userFieldName;
}

Шаг 2. Применение аннотации в сущности заказа

Поставим отметку о том, что сущность заказа фильтруется по полю пользователя.

<?php
namespace Acme\DemoBundle\Entity;
use Acme\DemoBundle\Annotation\UserAware;
/**
* Order entity
*
* @UserAware(userFieldName="user_id")
*/
class Order { ... } 

Тут-то все и происходит. Просто сделав такую отметку, все запросы, связанные с этой сущностью (даже при присоединении сущности в подзапросах) будут дополнятся условием WHERE.

Шаг 3. Создание класса фильтра Доктрины

Создадим и настроем новый фильтр для Доктрины. Более подробную документацию на тему вы найдете в документации к доктрине.

<?php
namespace Acme\DemoBundle\Filter;
use Doctrine\ORM\Mapping\ClassMetaData;
use Doctrine\ORM\Query\Filter\SQLFilter;
use Doctrine\Common\Annotations\Reader;
class UserFilter extends SQLFilter
{
   protected $reader;
   public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
   {
       if (empty($this->reader)) {
           return '';
       }
      // The Doctrine filter is called for any query on any entity
      // Check if the current entity is "user aware" (marked with an annotation)
       $userAware = $this->reader->getClassAnnotation(
           $targetEntity->getReflectionClass(),
           'Acme\\DemoBundle\\Annotation\\UserAware'
       );
       if (!$userAware) {
           return '';
       }
       $fieldName = $userAware->userFieldName;
       try {
          // Don't worry, getParameter automatically quotes parameters
           $userId = $this->getParameter('id');
       } catch (\InvalidArgumentException $e) {
          // No user id has been defined
           return '';
       }
       if (empty($fieldName) || empty($userId)) {
           return '';
       }
       $query = sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId);
       return $query;
   }
   public function setAnnotationReader(Reader $reader)
   {
       $this->reader = $reader;
   }
}

Шаг 4. Настроим фильтр Доктрины

Для начала включим фильтр в app/config/cofig.yml:

doctrine:
   orm:
       filters:
           user_filter:
               class:   Acme\DemoBundle\Filter\UserFilter
               enabled: true

Добавим подписчика для каждого запроса, который будет инициализировать наш фильтр с данными о текущем пользователе (AcmeDemoBundle/Resources/config/services.yml).

Реализация конфигуратора:

<?php
namespace Acme\DemoBundle\Filter;
use Symfony\Component\Security\Core\User\UserInterface;
class Configurator
{
   protected $em;
   protected $securityContext;
   protected $reader;
   public function __construct($em, $securityContext, $reader)
   {
       $this->em              = $em;
       $this->securityContext = $securityContext;
       $this->reader          = $reader;
   }
   public function onKernelRequest()
   {
       if ($user = $this->getUser()) {
           $filter = $this->em->getFilters()->enable('user_filter');
           $filter->setParameter('id', $user->getId());
           $filter->setAnnotationReader($this->reader);
       }
   }
   private function getUser()
   {
       $token = $this->securityContext->getToken();
       if (!$token) {
           return null;
       }
       $user = $token->getUser();
       if (!($user instanceof UserInterface)) {
           return null;
       }
       return $user;
   }
}

Шаг 5. Готово

Теперь каждый запрос к таблице заказов будет содержать в своем теле условие user_id = :user_id, будь то простой SELECT или INNER JOIN.

Нашу новую аннотацию можно применять к любой сущности, просто добавьте @UserAware и ко всем запросам будет добавляться условие выборки.

А вот так теперь будет выглядеть ваш контроллер:

<?php
namespace Acme\DemoBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
class OrderController
{
   /**
    * Show order action
    *
    * @param  Order  $order
    * @Template
    */
   public function showAction(Order $order)
   {
       return array('order' => $order);
   }
}

Все последующие запросы будут содержать условие WHERE (допустим id связанного пользователя равно 12345):

<?php
// SELECT * FROM order WHERE id = 1 AND user_id = 12345
$this->em->getRepository('DemoAcmeBundle:Order')->find(1);
// SELECT *
// FROM order_product op
// INNER JOIN order ON (op.order_id = o.id AND o.user_id = 12345)
// WHERE o.id = 987
$this->em->getRepository('DemoAcmeBundle:OrderProduct')
   ->createQueryBuilder('op')
   ->innerJoin('op.order', 'o')
   ->where('o.id = :order_id')
   ->setParameter('order_id', 987)
;