Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Votante 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

Para que el sistema de seguridad entienda lo que significa cuando comprobamos el acceso a EDITen un objeto Question, necesitamos un votante personalizado. Y... para ayudarnos, podemos generarlo.

Busca tu terminal y ejecuta:

symfony console make:voter

Llamémoslo QuestionVoter. A menudo tengo una clase de votante por objeto en mi sistema del que necesito comprobar el acceso. Y... ¡listo!

Añadir la lógica del votante

Vamos a comprobarlo: src/Security/Voter/QuestionVoter.php:

... lines 1 - 2
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class QuestionVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['POST_EDIT', 'POST_VIEW'])
&& $subject instanceof \App\Entity\Question;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'POST_EDIT':
// logic to determine if the user can EDIT
// return true or false
break;
case 'POST_VIEW':
// logic to determine if the user can VIEW
// return true or false
break;
}
return false;
}
}

Como siempre, la ubicación de esta clase no supone ninguna diferencia. Lo importante es que nuestro votante implementa VoterInterface. Bueno, no directamente... pero si abres la clase del núcleo que extendemos, puedes ver que implementa VoterInterface. La cuestión es: en cuanto creemos una clase que implemente VoterInterface, cada vez que se llame al sistema de autorización, Symfony llamará ahora a nuestro métodosupports() y básicamente preguntará:

¡Eh! ¿Entiendes cómo se vota en este $attribute y en este $subject?

Para nosotros, voy a decir si in_array($attribute, ['EDIT']). Así que, básicamente, si el atributo que se pasa es igual a EDIT:

... lines 1 - 10
class QuestionVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['EDIT'])
... line 17
}
... lines 19 - 40
}

Sólo estoy utilizando una matriz por si más adelante admitimos otros atributos en este votante, como DELETE.

De todos modos, si el $attribute es EDIT y el $subject es una instancia deQuestion, entonces sí, sabemos cómo votar esto:

... lines 1 - 10
class QuestionVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['EDIT'])
&& $subject instanceof \App\Entity\Question;
}
... lines 19 - 40
}

Si devolvemos false, significa que nuestro votante se "abstendrá" de votar. Pero si devolvemos true, entonces Symfony llama a voteOnAttribute():

... lines 1 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 22 - 39
}
}

Muy sencillo, tenemos que tomar el atributo -en nuestro caso EDIT - y el$subject -en nuestro caso un objeto Question - y determinar si el usuario debe o no tener acceso devolviendo true o false.

Voy a empezar añadiendo algunas cosas que ayudarán a mi editor. En primer lugar, para obtener el objeto User actual, utiliza este $token y llama a $token->getUser():

... lines 1 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... line 22
$user = $token->getUser();
... lines 24 - 39
}
}

El único problema es que mi editor no sabe que se trata de una instancia de mi clase específica User: sólo sabe que se trata de algún UserInterface. Para ayudar, añadiré @var User $user por encima de esto:

... lines 1 - 5
use App\Entity\User;
... lines 7 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
... lines 24 - 39
}
}

Incluso mejor, podría añadir una sentencia if para comprobar si $user no es una instancia de User y lanzar una excepción:

... lines 1 - 8
use Symfony\Component\Security\Core\User\UserInterface;
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
... lines 28 - 39
}
}

De hecho, lo haré aquí abajo. Sabemos que $subject será una instancia de nuestra claseQuestion. Para ayudar a nuestro editor a saber eso, digamos que si no $subject es uninstanceof Question , entonces lanzamos un nuevo Exception y simplemente decimos "se ha pasado un tipo equivocado":

... lines 1 - 4
use App\Entity\Question;
... lines 6 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
if (!$subject instanceof Question) {
throw new \Exception('Wrong type somehow passed');
}
... lines 32 - 39
}
}

Eso no debería ocurrir nunca, pero estamos codificando a la defensiva. Y lo que es más importante, mi editor -o herramientas de análisis estático como PHPStan- sabrá ahora de qué tipo es la variable$subject.

Por último, aquí abajo, el código generado tiene un caso de conmutación para manejar múltiples atributos. Eliminaré el segundo caso... y haré que el primero sea EDIT. Y... ni siquiera necesito el break porque voy a devolver true si $user es igual a $subject->getOwner():

... lines 1 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
if (!$subject instanceof Question) {
throw new \Exception('Wrong type somehow passed');
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'EDIT':
return $user === $subject->getOwner();
}
return false;
}
}

¡Vamos a probarlo! De vuelta al navegador, no estoy conectado. Así que si volvemos... a una página de preguntas... y hacemos clic en "editar"... el acceso sigue estando denegado. Iniciamos la sesión con nuestro usuario normal. Y entonces... el acceso sigue siendo denegado... lo que tiene sentido. Somos un usuario administrador... pero no somos el propietario de esta pregunta.

¡Así que vamos a entrar como el propietario! Vuelve a la página de inicio y haz clic en una pregunta. Para que sea más obvio qué usuario es el propietario, temporalmente, abretemplates/question/show.html.twig y... aquí abajo, después del nombre de visualización, sólo para ayudar a la depuración, imprime question.owner.email:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
... lines 35 - 41
<div class="q-display p-3">
... lines 43 - 44
<p class="pt-4"><strong>--{{ question.owner.displayName }} ({{ question.owner.email }})</strong></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
... lines 53 - 65
</div>
{% endblock %}

Y... genial. Copia el correo electrónico y ¡utilicemos la suplantación! Al final de la URL, añade ?_switch_user=, pega ese correo electrónico y... ¡boom! ¡El acceso está garantizado gracias a nuestro votante! Podemos probarlo. Entra en el perfilador y desplázate hacia abajo. Aquí está: acceso concedido para EDIT de este objeto Question. Me encanta esto.

Uso del votante en Twig

Ahora que tenemos este genial sistema de votantes, podemos ocultar y mostrar inteligentemente el botón de edición. De vuelta a show.html.twig, envuelve la etiqueta de anclaje con ifis_granted() pasando la cadena EDIT y el objeto pregunta:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
<div class="d-flex justify-content-between">
... lines 36 - 37
{% if is_granted('EDIT', question) %}
<a href="{{ path('app_question_edit', {
slug: question.slug
}) }}" class="btn btn-secondary btn-sm mb-2">Edit</a>
{% endif %}
</div>
... lines 44 - 48
</div>
</div>
</div>
</div>
</div>
</div>
... lines 55 - 67
</div>
{% endblock %}

¿No es genial? Deberíamos seguir teniendo acceso, y... cuando refrescamos, sigue ahí. Pero si salgo de la suplantación... y vuelvo a hacer clic en la pregunta, ¡ha desaparecido!

Permitir también que los usuarios administradores editen

Pero tengo un reto más. ¿Podríamos hacer que se pueda editar una pregunta si eres el propietario o si tienes ROLE_ADMIN. Claro que sí Para ello, en el votante, sólo tenemos que buscar ese rol. Para ello, necesitamos un nuevo servicio.

Añade un constructor y autocablea el servicio Security desde el componente Symfony. Voy a pulsar Alt+Enter y a ir a "Inicializar propiedades" para configurar las cosas:

... lines 1 - 8
use Symfony\Component\Security\Core\Security;
... lines 10 - 11
class QuestionVoter extends Voter
{
private Security $security;
public function __construct(Security $security)
{
$this->security = $security;
}
... lines 20 - 52
}

Ya hemos hablado antes de este servicio: lo utilizamos para obtener el objeto Usuario actualmente autenticado desde dentro de un servicio. También se puede utilizar para comprobar la seguridad desde dentro de un servicio.

Incluso antes del caso del interruptor, añadamos: si $this->security->isGranted('ROLE_ADMIN')entonces siempre devuelve true:

... lines 1 - 8
use Symfony\Component\Security\Core\Security;
... lines 10 - 11
class QuestionVoter extends Voter
{
... lines 14 - 27
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 30 - 40
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
... lines 47 - 48
}
... lines 50 - 51
}
}

Para que los usuarios administradores puedan hacer cualquier cosa. Oh, pero, ¡no quería añadir ese signo de exclamación!

Como estamos conectados como usuarios administradores...., en cuanto refresquemos, tendremos el botón de editar... y funciona. ¡Qué bien!

Lo siguiente: Vamos a añadir un sistema de confirmación por correo electrónico a nuestro formulario de registro.

Leave a comment!

¡Este tutorial también funciona muy bien para Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}