What is correct way of injecting a nullable entity parameter in symfony5? - symfony

Whenever I want to use an entity as a parameter (in this case SupportType) this is the quickest way:
/**
* #Route("/new/{type}", name="app_support_case_new", methods={"GET", "POST"})
*/
public function new(Request $request, SupportCaseRepository $supportCaseRepository, SupportType $type = null): Response
{
$user = $this->getUser();
$now = new \DateTime();
$supportCase = new SupportCase();
$supportCase->setEmail($user->getEmail());
$supportCase->setDateCreated($now);
$supportCase->setMinutesSpent(0);
$supportCase->setPublic(0);
if($type != null) {
$supportCase->setSupportType($type);
}
however there is a security issue here, because whenever no parameter is passed, symfony just picks the first random instance of the SupportType (as if it did a ->findAll() on the repository and picked the first one).
What is going on here?
It will never turn up as null even when I expect it to, i e when my route stops at /new and there is definately no parameter passed.
Instead I end up having to go through this hassle below whenever I want a parameter to actually be null if nothing is passed:
/**
* #Route("/new/{type}", name="app_support_case_new", methods={"GET", "POST"})
*/
public function new(Request $request, SupportCaseRepository $supportCaseRepository, SupportTypeRepository $supportTypeRepository, $type = null): Response
{
....................................
if($type != null) {
$supportType = $supportTypeRepository->findOneBy(array('id'=>$type));
if (is_null($supportType)) {
die('No type with that ID exist. Hey, are you trying to hack me?');
} else {
$supportCase->setSupportType($supportType);
}
}

Related

ApiPlatform - implement authorization based on apiplatform filters

I'm using ApiPlatform and Symfony5
I placed a filter on the User entity to sort them by a boolean value of the class named $expose
Use case:
For the /users?expose=true route ROLE_USER can get list of every user with filter $expose set to true
For the /users/ route ROLE_ADMIN can get list of every user no matter what
Here is my User class:
/**
* #ApiResource(
* attributes={
* "normalization_context"={"groups"={"user:read", "user:list"}},
* "order"={"somefield.value": "ASC"}
* },
* collectionOperations={
* "get"={
* "mehtod"="GET",
* "security"="is_granted('LIST', object)",
* "normalization_context"={"groups"={"user:list"}},
* }
* }
* )
* #ApiFilter(ExistsFilter::class, properties={"expose"})
* #ApiFilter(SearchFilter::class, properties={
* "somefield.name": "exact"
* })
* #ORM\Entity(repositoryClass=UserRepository::class)
*/
I implement my authorization rules through UserVoter:
protected function supports($attribute, $subject): bool
{
return parent::supports($attribute, $subject) &&
($subject instanceof User ||
$this->arrayOf($subject, User::class) ||
(is_a($subject, Paginator::class) &&
$this->arrayOf($subject->getQuery()->getResult(), User::class))
);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** #var User $user */
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($this->accessDecisionManager->decide($token, [GenericRoles::ROLE_ADMIN])) {
return true;
}
switch ($attribute) {
case Actions::LIST:
break;
}
return false;
}
To recover the list of User I recover the paginator object passed through the LIST attribute and make sure the object inside the request result are of type User.
This part have been tested and work properly.
Now my issue come from the fact that both those route are essentialy the same to my voter, so my authorization rules implemented through it apply to them both.
What I would like to do would be to tell my voter that both request are different (which I thought I could do as I recover a Paginator object but doesn't seem possible) so I can treat them separately in the same switch case.
So far I havn't found a way to implement it
Is there a way to implement this kind of rules ?
Or is there another way to implement this kind of authorization ?
Thank you!
If you can live with ordinary users and admin users using the same request /users/ but getting different results,
this docs page describes a way to make the result of GET collection operations depend on the user that is logged in. I adapted it for your question:
<?php
// api/src/Doctrine/CurrentUserExtension.php
namespace App\Doctrine;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Offer;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;
final class CurrentUserExtension implements QueryCollectionExtensionInterface
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
{
if (User::class !== $resourceClass || $this->security->isGranted('ROLE_ADMIN')) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere("$rootAlias.expose = true");
}
}
BTW, any users that do not have ROLE_ADMIN will get the filtered result, ROLE_USER is not required.
If you choose to stick with your use case that requires users with ROLE_USER to use /users?expose=true you can make a custom CollectionDataProvider that throws a FilterValidationException:
<?php
namespace App\DataProvider;
use Symfony\Component\Security\Core\Security;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use ApiPlatform\Core\Exception\FilterValidationException;
use App\Entity\User;
class UserCollectionDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
/** #var CollectionDataProviderInterface */
private $dataProvider;
private $security;
/**
* #param CollectionDataProviderInterface $dataProvider The built-in orm CollectionDataProvider of API Platform
*/
public function __construct(CollectionDataProviderInterface $dataProvider, Security $security)
{
$this->dataProvider = $dataProvider;
$this->security = $security;
}
/**
* {#inheritdoc}
*/
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return User::class === $resourceClass;
}
/** throws FilterValidationException */
private function validateFilters($context)
{
if ($this->security->isGranted('ROLE_ADMIN')) {
// Allow any filters, including no filters
return;
}
if (!$this->security->isGranted('ROLE_USER')) {
throw new \LogicException('No use case has been defined for this situation');
}
$errorList = [];
if (!isset($context["filters"]["expose"]) ||
$context["filters"]["expose"] !== "true" && $context["filters"]["expose"] !== '1'
) {
$errorList[] = 'expose=true filter is required.'
throw new FilterValidationException($errorList);
}
}
/**
* {#inheritdoc}
* #throws FilterValidationException;
*/
public function getCollection(string $resourceClass, string $operationName = null, array $context = []): array
{
$this->validateFilters($context);
return $this->dataProvider->getCollection($resourceClass, $operationName, $context);
}
You do need to add the following to api/config/services.yaml:
'App\DataProvider\UserCollectionDataProvider':
arguments:
$dataProvider: '#api_platform.doctrine.orm.default.collection_data_provider'
BTW, to filter by a boolean one usually uses a BooleanFilter:
* #ApiFilter(BooleanFilter::class, properties={"expose"})
This is relevant because users with ROLE_ADMIN may try to filter by expose=false. BTW, If $expose is nullable you need to test what happens with Users that have $expose set to null
WARNING: Be aware that your security will fail silently, allowing all users access to all User entities, if the property $expose is no longer mapped or if the name of the property $expose is changed but in the UserCollectionDataProvider it is not or the Filter spec it is not!

Login with custom authentication

I want a login with a custom field to authenticate users into the platform.
The point is to check a field 'pw_expires_at' to \DateTime('now'), to log the user.
Here's what I did so far:
In the controller:
$user->setPassword(
$passwordEncoder->encodePassword(
$user,
$mdp)
);
$user->setPwExpiresAt(new \DateTime("now + 1 minute"));
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($user);
$entityManager->flush();
In the Authenticator:
public function checkCredentials($credentials, UserInterface $user)
{
$valid = false;
$validDate = $this->checkDate($credentials, $user);
$validPassword = $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
if($validDate && $validPassword) {
$valid = true;
}
return $valid;
}
/**
* #return bool
*/
public function checkDate($credentials, UserInterface $user){
$now = new \DateTime('now');
$pwdate = new \DateTime();
$pwdate = $this->entityManager->getRepository(Users::class)->findOneBy([
'email' => $credentials['email']
]);
if ($pwdate > $now) {
return false;
}
else {
return true;
}
}
I also added the new function checkDate() in the AuthenticatorInterface.php.
The problem is : I can log in at anytime.
You are comparing (>) a user object repository->findBy(...) which returns a Users::class with a DateTime object $now = new \DateTime();.
Also the $user object entityManager reponse is most likely the same object returned by your getUsername function (the one you pass as an argument in this function) and thus can be skipped? If it is a DTO that does not contain this expired value then add it back in.
Also you are not using the credentials for anything anymore then so removed it as well.
I would change this to something like:
public function checkDate(UserInterface $user) {
$now = new \DateTime();
$pwdate = $user->getPwExpiresAt();
// we dont need the if/else as this ($pwdate > $now)
// is an expression and will already return true/false;
return $pwdate > $now;
}
Some more suggestions:
You might want to reconsider renaming the function to something more expressive like $this->hasAuthenticationExpired($user) this should give a clear indication of what the function is doing other than "checking a date (for what?!)" without reading through the function.
You can move this function to the user object like
public function hasExpired() { return $this->getPwExpiresAt() && new \DateTime() > $this->getPwExpiresAt(); }
and just call if (!$user->hasExpired()) { which is actually a preferred way for many people as this can be easily reused and accessed whenever handling the user object anywhere.

Symfony onKernetRequest detect which table

I have the following Listener in my code:
class BeforeRequestListener
{
/**
* #var EntityManager
*/
private $em;
/**
* #var SessionInterface
*/
private $session;
/**
* BeforeRequestListener constructor.
* #param EntityManager $em
* #param SessionInterface $session
*/
public function __construct(EntityManager $em,SessionInterface $session)
{
$this->em = $em;
$this->session =$session;
}
/**
* #param GetResponseEvent $event
*/
public function onKernelRequest(GetResponseEvent $event){
//if() HERE
$filter = $this->em->getFilters()
->enable(Utils::CLIENT_FILTER_NAME);
$filter->setParameter(Utils::CLIENT_ID_NAME, $this->session->get(Utils::CLIENT_ID_NAME));
}
}
And this listener is made to enable some filter. i need to detect on which table/tables this is request id done so i can disable it when there is no column called client_id which is btw the CLIENT_ID_NAME const.
the reason is mentioned here : Doctrine and symfony filter, debug the filter
i need to apply the filter only after a user logs in and some tables doesnt have the client_id field so i want to disable that check on these tables.
Thanks!
If your request contains some information about tables, you can retrieve it like this:
$tables = $event->getRequest()->get('tables');
or you can assume affected entity/table by requested route:
$request = $event->getRequest();
// Matched route
$_route = $request->attributes->get('_route');
// Matched controller
$_controller = $request->attributes->get('_controller');
By default Request object doesn't contain any information about table because it just passes some parameters to some action attached to some route, and the action interacts with some data model which presents some database table.
I made it (a bit dirty) not the way i wanted it but it works on the current db scale i have.
just added the following code to the addFilterConstraint
if(
$targetEntity->getTableName() == 'table' ||
$targetEntity->getTableName() == 'table2' ||
$targetEntity->getTableName() == 'table3' ||
$targetEntity->getTableName() == 'table4' ||
$targetEntity->getTableName() == 'table5'
){
return true;
}
return $targetTableAlias.'.'.Utils::CLIENT_ID_NAME.' = '. $this->getParameter(Utils::CLIENT_ID_NAME);
p.s in case someone got into this and noticed some strange behavior when using return ""; just replace it with return true; sometimes it happens sometimes not i am still not sure why.

Symfony2 data transformer string to entity (reverseTransform)

I am pretty new to Symfony and hope someone can help me. I have an entity called Material and an associated entity called MaterialKeyword, which are basically tags. I am displaying the keywords comma delimited as a string in a text field on a form. I created a data transformer to do that. Pulling the keywords from the database and displaying them is no problem, but I have a problem with the reversTransform function when I want to submit existing or new keywords to the database.
Material class (MaterialKeyword):
/**
* #Assert\Type(type="AppBundle\Entity\MaterialKeyword")
* #Assert\Valid()
* #ORM\ManyToMany(targetEntity="MaterialKeyword", inversedBy="material")
* #ORM\JoinTable(name="materials_keyword_map",
* joinColumns={#ORM\JoinColumn(name="materialID", referencedColumnName="materialID", nullable=false)},
* inverseJoinColumns={#ORM\JoinColumn(name="keywordID", referencedColumnName="id", nullable=false)})
*/
public $materialkeyword;
/**
* Constructor
*/
public function __construct()
{
$this->MaterialKeyword = new ArrayCollection();
}
/**
* Set materialkeyword
*
* #param array $materialkeyword
*
*/
public function setMaterialkeyword(MaterialKeyword $materialkeyword=null)
{
$this->materialkeyword = $materialkeyword;
}
/**
* Get materialkeyword
*
* #Assert\Type("\array")
* #return array
*/
public function getMaterialkeyword()
{
return $this->materialkeyword;
}
Here is my code from the data transformer:
This part is working:
class MaterialKeywordTransformer implements DataTransformerInterface
{
/**
* #var EntityManagerInterface
*/
private $manager;
public function __construct(ObjectManager $manager)
{
$this->manager = $manager;
}
/**
* Transforms an object (materialkeyword) to a string.
*
* #param MaterialKeyword|null $materialkeyword
* #return string
*/
public function transform($material)
{
$result = array();
if (null === $material) {
return '';
}
foreach ($material as $materialkeyword) {
$result[] = $materialkeyword->getKeyword();
}
return implode(", ", $result);
}
This part is not working:
/**
* Transforms a string (keyword) to an object (materialkeyword).
*
* #param string $materialkeyword
* #return MaterialKeyword|null
* #throws TransformationFailedException if object (materialkeyword) is not found.
*/
public function reverseTransform($keywords)
{
// no keyword? It's optional, so that's ok
if (!$keywords) {
return;
}
$repository = $this->manager
->getRepository('AppBundle:MaterialKeyword');
$keyword_array = explode(", ", $keywords);
foreach($keyword_array as $keyword){
$materialkeyword = new MaterialKeyword();
$keyword_entry = $repository->findBy(array('keyword' => $keyword));
if(array_key_exists(0, $keyword_entry)){
$keyword_entry_first = $keyword_entry[0];
}else{
$keyword_entry_first = $keyword_entry;
}
if (null === $keyword_entry_first) {
throw new TransformationFailedException(sprintf('There is no "%s" exists',
$keywords
));
}
$materialkeyword->setKeyword($keyword_entry_first);
}
return $materialkeyword;
}
There will be several keywords, so how do I store them. I tried Arrays and ArrayCollections (new ArrayCollection()) without any success.
The error that I am getting currently with the code above:
Catchable Fatal Error: Argument 1 passed to Doctrine\Common\Collections\ArrayCollection::__construct() must be of the type array, object given, called in /.../vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php on line 605 and defined
TL;DR;
Your reverseTransform function should return an array containing zero or n MaterialKeyword.
It should not return a single MaterialKeyword object because the reverse transformation of MaterialKeyord[] --> string is not string --> MaterialKeyword, it is string --> MaterialKeyword[].
Thinking about this, the doctrine ArrayCollection exception you have make sense as it is trying to do new ArrayCollection(/** Single MaterialKeyword object */) instead of new ArrayCollection(/** Array of MaterialKeyword objects */).
From what you're telling I assume that Material and MaterialKeyword are connected by a ManyToMany association, in which case each Material has an array of MaterialKeyword objects associated to it.
Which means, that your Data Transformer should work with arrays as well, but you're only working with single objects.
Specifically, reverseTransform should return an array of MaterialKeyword objects, whereas you're only returning one (the last one handled in the loop.)
Another issue is that your method created new objects every time, even though $repository->findBy(...) would already return a MaterialKeyword instance. Creating a new object would cause that entry to be copied instead of simply used.
So the correct method might look like this:
public function reverseTransform($keywords)
{
// no keyword? It's optional, so that's ok
if (!$keywords) {
return array();
}
$repository = $this->manager
->getRepository('AppBundle:MaterialKeyword');
$keyword_array = explode(", ", $keywords);
$result_list = array(); // This will contain the MaterialKeyword objects
foreach($keyword_array as $keyword){
$keyword_entry = $repository->findOneBy(array('keyword' => $keyword));
if (null === $keyword_entry) {
throw new TransformationFailedException(sprintf('There is no "%s" exists',
$keyword
));
}
$result_list[] = $keyword_entry;
}
return $result_list;
}
#Hanzi put me on the correct track. It has to be an array of MaterialKeywords objects.
Here is my final working code in class MaterialKeywordTransformer:
/**
* Transforms a string (keyword) to an object (materialkeyword).
*
* #param string $materialkeyword
* #return MaterialKeyword|null
* #throws TransformationFailedException if object (materialkeyword) is not found.
*/
public function reverseTransform($keywords)
{
// keyword are optional, so that's ok
if (!$keywords) {
return;
}
$repository = $this->manager
->getRepository('AppBundle:MaterialKeyword');
$repository_m = $this->manager
->getRepository('AppBundle:Material');
$keyword_array = explode(", ", $keywords);
foreach($keyword_array as $keyword){
$materialkeyword = new MaterialKeyword();
$materialkeyword->setKeyword($keyword);
if($this->opt["data"]->getMaterialID() !== null) {
$materialkeyword->setMaterialID($this->opt["data"]->getMaterialID());
} else {
$material = $repository_m->findOne();
$materialID = $material[0]->getMaterialID();
$materialkeyword->setMaterialID($materialID);
}
$materialkeywords[] = $materialkeyword;
if (null === $keywords) {
throw new TransformationFailedException(sprintf('There is no "%s" exists',
$keywords
));
}
}
return $materialkeywords;
}

Symfony2 and ParamConverter(s)

Accessing my route /message/new i'm going to show a form for sending a new message to one or more customers. Form model has (among others) a collection of Customer entities:
class MyFormModel
{
/**
* #var ArrayCollection
*/
public $customers;
}
I'd like to implement automatic customers selection using customers GET parameters, like this:
message/new?customers=2,55,543
This is working now by simply splitting on , and do a query for getting customers:
public function newAction(Request $request)
{
$formModel = new MyFormModel();
// GET "customers" parameter
$customersIds = explode($request->get('customers'), ',');
// If something was found in "customers" parameter then get entities
if(!empty($customersIds)) :
$repo = $this->getDoctrine()->getRepository('AcmeHelloBundle:Customer');
$found = $repo->findAllByIdsArray($customersIds);
// Assign found Customer entities
$formModel->customers = $found;
endif;
// Go on showing the form
}
How can i do the same using Symfony 2 converters? Like:
public function newAction(Request $request, $selectedCustomers)
{
}
Answer to my self: there is not such thing to make you life easy. I've coded a quick and dirty (and possibly buggy) solution i'd like to share, waiting for a best one.
EDIT WARNING: this is not going to work with two parameter converters with the same class.
Url example
/mesages/new?customers=2543,3321,445
Annotations:
/**
* #Route("/new")
* #Method("GET|POST")
* #ParamConverter("customers",
* class="Doctrine\Common\Collections\ArrayCollection", options={
* "finder" = "getFindAllWithMobileByUserQueryBuilder",
* "entity" = "Acme\HelloBundle\Entity\Customer",
* "field" = "id",
* "delimiter" = ",",
* }
* )
*/
public function newAction(Request $request, ArrayCollection $customers = null)
{
}
Option delimiter is used to split GET parameter while id is used for adding a WHERE id IN... clause. There are both optional.
Option class is only used as a "signature" to tell that converter should support it. entity has to be a FQCN of a Doctrine entity while finder is a repository method to be invoked and should return a query builder (default one provided).
Converter
class ArrayCollectionConverter implements ParamConverterInterface
{
/**
* #var \Symfony\Component\DependencyInjection\ContainerInterface
*/
protected $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
function apply(Request $request, ConfigurationInterface $configuration)
{
$name = $configuration->getName();
$options = $this->getOptions($configuration);
// Se request attribute to an empty collection (as default)
$request->attributes->set($name, new ArrayCollection());
// If request parameter is missing or empty then return
if(is_null($val = $request->get($name)) || strlen(trim($val)) === 0)
return;
// If splitted values is an empty array then return
if(!($items = preg_split('/\s*'.$options['delimiter'].'\s*/', $val,
0, PREG_SPLIT_NO_EMPTY))) return;
// Get the repository and logged user
$repo = $this->getEntityManager()->getRepository($options['entity']);
$user = $this->getSecurityContext->getToken()->getUser();
if(!$finder = $options['finder']) :
// Create a new default query builder with WHERE user_id clause
$builder = $repo->createQueryBuilder('e');
$builder->andWhere($builder->expr()->eq("e.user", $user->getId()));
else :
// Call finder method on repository
$builder = $repo->$finder($user);
endif;
// Edit the builder and add WHERE IN $items clause
$alias = $builder->getRootAlias() . "." . $options['field'];
$wherein = $builder->expr()->in($alias, $items);
$result = $builder->andwhere($wherein)->getQuery()->getResult();
// Set request attribute and we're done
$request->attributes->set($name, new ArrayCollection($result));
}
public function supports(ConfigurationInterface $configuration)
{
$class = $configuration->getClass();
// Check if class is ArrayCollection from Doctrine
if('Doctrine\Common\Collections\ArrayCollection' !== $class)
return false;
$options = $this->getOptions($configuration);
$manager = $this->getEntityManager();
// Check if $options['entity'] is actually a Dcontrine one
try
{
$manager->getClassMetadata($options['entity']);
return true;
}
catch(\Doctrine\ORM\Mapping\MappingException $e)
{
return false;
}
}
protected function getOptions(ConfigurationInterface $configuration)
{
return array_replace(
array(
'entity' => null,
'finder' => null,
'field' => 'id',
'delimiter' => ','
),
$configuration->getOptions()
);
}
/**
* #return \Doctrine\ORM\EntityManager
*/
protected function getEntityManager()
{
return $this->container->get('doctrine.orm.default_entity_manager');
}
/**
* #return \Symfony\Component\Security\Core\SecurityContext
*/
protected function getSecurityContext()
{
return $this->container->get('security.context');
}
}
Service definition
arraycollection_converter:
class: Acme\HelloBundle\Request\ArrayCollectionConverter
arguments: ['#service_container']
tags:
- { name: request.param_converter}
It's late, but according to latest documentation about #ParamConverter, you can achieve it follow way:
* #ParamConverter("users", class="AcmeBlogBundle:User", options={
* "repository_method" = "findUsersByIds"
* })
you just need make sure that repository method can handle comma (,) separated values

Resources