Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Validador personalizado

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Si necesitas controlar cómo se establece un campo como isPublished en función de quién está conectado, tienes dos situaciones diferentes.

Proteger un campo frente a proteger sus datos

En primer lugar, si necesitas evitar por completo que determinados usuarios escriban en este campo, para eso está la seguridad. La opción más sencilla es utilizar la opción #[ApiProperty(security: ...)]que hemos utilizado antes sobre la propiedad. O podrías ponerte más elegante y añadir un grupo dinámico admin:write mediante un constructor de contexto. De cualquier forma, impediremos que este campo se escriba por completo.

La segunda situación es cuando un usuario puede escribir en un campo... pero los datos válidos que puede establecer dependen de quién sea. Por ejemplo, un usuario puede poner isPublished en false... pero no puede ponerlo en true a menos que sea un administrador.

Te daré un ejemplo diferente. Ahora mismo, cuando creas un DragonTreasure, obligamos al cliente a pasar un owner. Podemos verlo entestPostToCreateTreasure(). Vamos a arreglar esto en unos minutos para que podamos dejar este campo desactivado... y entonces se establecerá automáticamente para quien esté autentificado.

Pero ahora mismo, el campo owner está permitido y es obligatorio. Pero a quién se permite asignar como owner depende de quién esté conectado. Para los usuarios normales, sólo se les debería permitir asignarse a sí mismos como usuario. Pero para los administradores, deberían poder asignar a cualquiera como owner. Heck, quizá en el futuro nos volvamos más locos y haya clanes de dragones... y puedas crear tesoros y asignarlos a cualquiera de tu clan La cuestión es: la pregunta no es si podemos establecer este campo, sino a qué datos se nos permite establecerlo. Y eso depende de quiénes seamos.

¿Solución con seguridad o validación?

Vale, en realidad, este problema ya lo hemos resuelto antes para la operación Patch(). Déjame que te lo muestre. Busca testPatchToUpdateTreasure(). Entonces... ejecutemos sólo esa prueba:

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

Y... pasa. Esta prueba comprueba 3 cosas. En primer lugar, nos conectamos como el usuario propietario de DragonTreasure y realizamos una actualización. ¡Ese es el caso feliz!

A continuación, entramos como un usuario diferente e intentamos editar elDragonTreasure del primer usuario. Eso no está permitido. Y ése es un uso correcto de security: no somos propietarios de este DragonTreasure, por lo que no se nos permite en absoluto editarlo. Eso es lo que protege la línea security.

Para la última parte, nos registramos de nuevo como propietarios de este DragonTreasure. Pero luego intentamos cambiar el propietario por otra persona. Eso tampoco está permitido y ésta es la situación de la que estamos hablando. Actualmente se gestiona consecurityPostDenormalize(). Pero en su lugar quiero gestionarlo con la validación ¿Por qué? Porque la pregunta a la que estamos respondiendo es la siguiente:

¿Son válidos los datos de owner que se envían?

Y... validar los datos es... ¡el trabajo de la validación!

Elimina el securityPostDenormalize():

... lines 1 - 28
#[ApiResource(
... lines 30 - 31
operations: [
... lines 33 - 41
new Patch(
... line 43
securityPostDenormalize: 'is_granted("EDIT", object)',
),
... lines 46 - 48
],
... lines 50 - 66
)]
... lines 68 - 88
class DragonTreasure
{
... lines 91 - 249
}

Y para demostrar que esto era importante, vuelve a ejecutar la prueba:

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

¡Sí! Falló en la línea 132... que es ésta de aquí abajo. Vamos a reescribir esto con un validador personalizado, que en realidad es mucho más bonito.

Crear la validación personalizada

Ah, pero como esto fallará por validación cuando acabemos, cambia aassertStatus(422):

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 97
public function testPatchToUpdateTreasure()
{
... lines 100 - 126
$this->browser()
... lines 128 - 134
->assertStatus(422)
;
}
... lines 138 - 179
}

La idea es que se nos permite PATCH este usuario, pero enviamos datos no válidos: no podemos establecer este propietario a alguien que no seamos nosotros mismos.

Vale, dirígete a la línea de comandos y ejecuta:

php ./bin/console make:validator

Dale un nombre chulo como IsValidOwnerValidator. En Symfony, los validadores son dos clases diferentes. Abre primero src/Validator/IsValidOwner.php:

... lines 1 - 2
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class IsValidOwner extends Constraint
{
/*
* Any public properties become valid options for the annotation.
* Then, use these in your validator class.
*/
public $message = 'The value "{{ value }}" is not valid.';
}

Esta clase ligera se utilizará como atributo... y sólo contiene opciones que podemos configurar, como $message, que es suficiente. Cambiemos el mensaje por defecto por algo un poco más útil:

... lines 1 - 12
class IsValidOwner extends Constraint
{
... lines 15 - 18
public string $message = 'You are not allowed to set the owner to this value.';
}

La segunda clase es la que se ejecutará para manejar la lógica:

... lines 1 - 2
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
/* @var App\Validator\IsValidOwner $constraint */
if (null === $value || '' === $value) {
return;
}
// TODO: implement the validation here
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}

Lo veremos en un momento... pero antes utilicemos la nueva restricción. Sobre DragonTreasure, abajo en la propiedad owner... ahí vamos... añade el nuevo atributo: IsValidOwner:

... lines 1 - 19
use App\Validator\IsValidOwner;
... lines 21 - 88
class DragonTreasure
{
... lines 91 - 135
#[IsValidOwner]
... line 137
private ?User $owner = null;
... lines 139 - 250
}

Rellenar la lógica del validador

Ahora que tenemos esto, cuando se valide nuestro objeto, Symfony llamará aIsValidOwnerValidator y nos pasará el $value -que será el objeto User- y la restricción, que será IsValidOwner.

Hagamos un poco de limpieza. Elimina el var y sustitúyelo porassert($constraint instanceof IsValidOwner):

... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
assert($constraint instanceof IsValidOwner);
if (null === $value || '' === $value) {
return;
}
... lines 18 - 23
}
}

Eso es sólo para ayudar a mi editor: sabemos que Symfony siempre nos pasará eso. A continuación, fíjate en que está comprobando si el $value es nulo o está vacío. Y si lo es, no hace nada. Si la propiedad $owner está vacía, eso sí que debería gestionarlo una restricción diferente.

De vuelta en DragonTreasure, añade #[Assert\NotNull]:

... lines 1 - 88
class DragonTreasure
{
... lines 91 - 136
#[Assert\NotNull]
... line 138
private ?User $owner = null;
... lines 140 - 251
}

Así, si se olvidan de enviar owner, esto se encargará de ese error de validación. De vuelta dentro de nuestro validador, si nos encontramos en esa situación, podemos simplemente devolver:

... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
... lines 13 - 14
if (null === $value || '' === $value) {
return;
}
... lines 18 - 23
}
}

Debajo de esto, añade un assert() más que $value es un instanceof User.

Realmente, Symfony nos pasará cualquier valor que se adjunte a esta propiedad... pero sabemos que siempre será un User:

... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
... lines 13 - 14
if (null === $value || '' === $value) {
return;
}
// constraint is only meant to be used above a User property
assert($value instanceof User);
... lines 21 - 23
}
}

Por último, elimina setParameter() -que no es necesario en nuestro caso- y$constraint->message lee la propiedad $message:

... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
assert($constraint instanceof IsValidOwner);
if (null === $value || '' === $value) {
return;
}
// constraint is only meant to be used above a User property
assert($value instanceof User);
$this->context->buildViolation($constraint->message)
->addViolation();
}
}

Llegados a este punto, ¡tenemos un validador funcional! Excepto que... va a fallar en todas las situaciones. Ah, al menos asegurémonos de que está siendo llamado. Ejecuta nuestra prueba:

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

¡Bonito fallo! Un 422 procedente de la línea 110 de DragonTreasureResourceTest... porque nuestra restricción nunca se satisface.

Comprobación de la propiedad en el validador

Por último, podemos añadir nuestra lógica de negocio. Para hacer la comprobación de propietario, necesitamos saber quién está conectado. Añade un método __construct(), autocablea nuestra clase favorita Security... y pondré private delante, para que se convierta en una propiedad:

... lines 1 - 5
use Symfony\Bundle\SecurityBundle\Security;
... lines 7 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
public function __construct(private Security $security)
{
}
... lines 15 - 34
}

Abajo, pon $user = $this->security->getUser(). Y si no hay ningún usuario por alguna razón, lanza un LogicException para que las cosas exploten:

... lines 1 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
... lines 12 - 15
public function validate($value, Constraint $constraint)
{
... lines 18 - 23
// constraint is only meant to be used above a User property
assert($value instanceof User);
$user = $this->security->getUser();
if (!$user) {
throw new \LogicException('IsOwnerValidator should only be used when a user is logged in.');
}
... lines 31 - 33
}
}

¿Por qué no lanzar un error de validación? Podríamos... pero en nuestra aplicación, si un usuario anónimo está cambiando de alguna manera con éxito un DragonTreasure... tenemos algún tipo de error de configuración.

Por último, si $value no es igual a $user -por tanto, si owner no esUser -, añade ese fallo de validación:

... lines 1 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
... lines 12 - 15
public function validate($value, Constraint $constraint)
{
... lines 18 - 31
if ($value !== $user) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}

¡Ya está! ¡Vamos a probar esto!

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

Y... ¡bingo! Tanto si estamos creando como editando un DragonTreasure, no se nos permite establecer como propietario a alguien que no seamos nosotros.

Y podemos añadir cualquier otra fantasía que queramos. Por ejemplo, si el usuario es un administrador, volver para que los usuarios administradores puedan asignar el owner a cualquiera:

... lines 1 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
... lines 12 - 15
public function validate($value, Constraint $constraint)
{
... lines 18 - 26
$user = $this->security->getUser();
if (!$user) {
throw new \LogicException('IsOwnerValidator should only be used when a user is logged in.');
}
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
if ($value !== $user) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}

Esto me encanta. Pero... sigue habiendo un gran agujero de seguridad: ¡un agujero que permitirá a un usuario robar los tesoros de otra persona! ¡No mola! Averigüemos cuál es a continuación y aplastémoslo.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice