If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeNueva búsqueda de seguridad: Quiero permitir que sólo el propietario de un tesoro pueda editarlo. Ahora mismo, puedes editar un tesoro siempre que tengas este rol. Pero eso significa que puedes editar el tesoro de cualquiera. Alguien sigue cambiando elcoolFactor
de mi cuadro Velvis a 0. Eso no mola nada.
Escribamos una prueba para esto. En la parte inferior dipublic function testPatchToUpdateTreasure()
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 97 | |
public function testPatchToUpdateTreasure() | |
{ | |
... lines 100 - 112 | |
} | |
} |
Y empezaremos como siempre: $user = UserFactory::createOne()
luego$this->browser->actingAs($user)
.
Como vamos a editar un tesoro, vamos a ->patch()
a /api/treasures/
... ¡y luego necesitamos un tesoro para editar! Crea uno encima:$treasure = DragonTreasureFactory::createOne()
. Y para esta prueba, queremos asegurarnos de que el owner
es definitivamente este $user
. Termina la URL con$treasure->getId()
.
Para los datos, envía algo de json
para actualizar sólo el campo value
a 12345
, luego assertStatus(200)
y assertJsonMatches('value', 12345)
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 97 | |
public function testPatchToUpdateTreasure() | |
{ | |
$user = UserFactory::createOne(); | |
$treasure = DragonTreasureFactory::createOne(['owner' => $user]); | |
$this->browser() | |
->actingAs($user) | |
->patch('/api/treasures/'.$treasure->getId(), [ | |
'json' => [ | |
'value' => 12345, | |
], | |
]) | |
->assertStatus(200) | |
->assertJsonMatches('value', 12345) | |
; | |
} | |
} |
¡Excelente! Esto debería estar permitido porque somos el owner
. Copia el nombre del método, luego busca tu terminal y ejecútalo:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
No te sorprendas, pasa.
Ahora probemos el otro caso: iniciemos sesión como otra persona e intentemos actualizar este tesoro.
Copia toda la sección $browser
. Podríamos crear otro método de prueba, pero esto funcionará bien todo en uno. Antes de esto, añade$user2 = UserFactory::createOne()
- y luego inicia sesión como ese usuario. Esta vez, cambia el value
por 6789
y, como esto no debería estar permitido, afirma que el código de estado es 403:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 97 | |
public function testPatchToUpdateTreasure() | |
{ | |
... lines 100 - 113 | |
$user2 = UserFactory::createOne(); | |
$this->browser() | |
->actingAs($user2) | |
->patch('/api/treasures/'.$treasure->getId(), [ | |
'json' => [ | |
'value' => 6789, | |
], | |
]) | |
->assertStatus(403) | |
; | |
} | |
} |
Cuando intentemos la prueba ahora
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
¡Falla! Esto está permitido: ¡la API devuelve un 200!
Entonces, ¿cómo podemos hacer que sólo el propietario de un tesoro pueda editarlo? Bueno, en DragonTreasure
, la respuesta está en la opción security
:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Put( | |
security: 'is_granted("ROLE_TREASURE_EDIT")', | |
), | |
new Patch( | |
security: 'is_granted("ROLE_TREASURE_EDIT")', | |
), | |
... lines 47 - 49 | |
], | |
... lines 51 - 67 | |
) | |
... lines 69 - 89 | |
class DragonTreasure | |
{ | |
... lines 92 - 249 | |
} |
Una cosa que resulta complicada con Put()
y Patch()
es que ambos se utilizan para editar usuarios. Así que si vas a tener ambos, necesitas mantener sus opciones security
sincronizadas. De hecho, voy a eliminar Put()
para que podamos centrarnos en Patch()
.
La cadena dentro de security
es una expresión... y podemos ponernos un poco elegantes. Podemos conceder acceso si tienes ROLE_TREASURE_EDIT
y si object.owner == user
:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Patch( | |
security: 'is_granted("ROLE_TREASURE_EDIT") and object.owner == user', | |
), | |
... lines 44 - 46 | |
], | |
... lines 48 - 64 | |
) | |
... lines 66 - 86 | |
class DragonTreasure | |
{ | |
... lines 89 - 246 | |
} |
Dentro de la expresión de seguridad, Symfony nos da unas cuantas variables. Una es user
, que es el objeto actual User
. Otra es object
, que será el objeto actual para esta operación. Así que el objeto DragonTreasure
. Así que estamos diciendo que se debe permitir el acceso si el DragonTreasure
s owner
es igual aluser
autenticado actualmente. Eso es... ¡exactamente lo que queremos!
Así que, ¡vuelve a intentar la prueba!
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
Y... ¡oh! ¡Bajamos a un error 500! Aquí es donde resulta útil ese archivo de registro guardado. Haré clic para abrirlo. Si esto es difícil de leer, mira la fuente de la página. Mucho mejor. Dice
No se puede acceder a la propiedad privada
DragonTreasure::$owner
.
Y viene de ExpressionLanguage
de Symfony . Ah, ya sé lo que pasa. El lenguaje de expresión es como Twig... pero no exactamente igual. No podemos hacer cosas extravagantes como .owner
cuando owner
es una propiedad privada. Tenemos que llamar al método público:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Patch( | |
security: 'is_granted("ROLE_TREASURE_EDIT") and object.getOwner() == user', | |
), | |
... lines 44 - 46 | |
], | |
... lines 48 - 64 | |
) | |
... lines 66 - 86 | |
class DragonTreasure | |
{ | |
... lines 89 - 246 | |
} |
Redoble de tambores, por favor:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
¡Pasa con éxito!
Pero ya me conoces, tengo que hacerlo más difícil. Copia parte de la prueba. Esta vez, iniciar sesión como propietario y editar nuestro propio tesoro. Hasta aquí, todo bien. Pero ahora intenta cambiar el owner
por otro: $user2->getId()
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 97 | |
public function testPatchToUpdateTreasure() | |
{ | |
... lines 100 - 126 | |
$this->browser() | |
... line 128 | |
->patch('/api/treasures/'.$treasure->getId(), [ | |
'json' => [ | |
// change the owner to someone else | |
'owner' => '/api/users/'.$user2->getId(), | |
], | |
]) | |
... line 135 | |
; | |
} | |
} |
Ahora puede que esto sea algo que quieras permitir. Tal vez digas
Si puedes editar un
DragonTreasure
, entonces eres libre de asignarle un > propietario diferente propietario.
Pero supongamos que queremos impedirlo. Entonces assertStatus(403)
. ¿Crees que la prueba pasará? Inténtalo:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
¡Falla! ¡Nos ha permitido cambiar el owner
! Vuelve a DragonTreasure
. La expresión security
se ejecuta antes de que los nuevos datos se deserialicen en el objeto. En otras palabras, object
será el DragonTreasure
de la base de datos, antes de que se le aplique nada del nuevo JSON. Esto significa que se está comprobando que el owner
actual es igual al usuario conectado en ese momento, que es el caso principal que queremos proteger.
Pero a veces quieres ejecutar la seguridad después de que los nuevos datos se hayan introducido en el objeto. En ese caso, utiliza una opción llamada securityPostDenormalize
. Recuerda que desnormalizar es el proceso de tomar los datos y ponerlos en el objeto. Así quesecurity
seguirá ejecutándose primero... y se asegurará de que somos el propietario original. Ahora también podemos decir object.getOwner() == user
:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Patch( | |
security: 'is_granted("ROLE_TREASURE_EDIT") and object.getOwner() == user', | |
securityPostDenormalize: 'object.getOwner() == user', | |
), | |
... lines 45 - 47 | |
], | |
... lines 49 - 65 | |
) | |
... lines 67 - 87 | |
class DragonTreasure | |
{ | |
... lines 90 - 247 | |
} |
Esto parece idéntico... pero esta vez object
será el DragonTreasure
con los nuevos datos. Así que estamos comprobando que el nuevo propietario también es igual al usuario actualmente conectado.
Por cierto, en securityPostDenormalize
, también tienes una variable previous_object
, que es igual al objeto antes de la desnormalización. Por tanto, es idéntica a object
en la opción security
. Pero, no necesitamos eso.
Haz la prueba ahora:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
¡Lo hemos conseguido!
Este último ejemplo pone de manifiesto dos tipos diferentes de comprobaciones de seguridad. La primera comprobación determina si el usuario puede o no realizar esta operación. Por ejemplo: ¿puede el usuario actual hacer una petición a este tesoro a PATCH
? Eso depende del usuario actual y del DragonTreasure actual en la base de datos.
Pero la segunda comprobación dice
Vale, ahora que sé que se me permite hacer una petición a
PATCH
, ¿se me permite cambiar los datos de esta forma exacta?
Esto depende del usuario conectado en ese momento y de los datos que se estén enviando.
Traigo a colación esta diferencia porque, para mí, el primer caso -en el que intentas averiguar si una operación está permitida en absoluto, independientemente de los datos que se envíen- es tarea de la seguridad. Y así es exactamente como yo lo implementaría.
Sin embargo, en el segundo caso, en el que intentas averiguar si el usuario está autorizado a enviar esos datos exactos -por ejemplo, si puede cambiar la direcciónowner
o no-, creo que es mejor que se encargue de ello la capa de validación.
Por ahora voy a mantener esto en la capa de seguridad. Pero más adelante, cuando hablemos de la validación personalizada, lo trasladaremos a ella.
Próximamente: ¿podemos flexibilizar la opción security
lo suficiente como para permitir también a los usuarios administradores editar el tesoro de cualquiera? ¡Permanece atento!
"Houston: no signs of life"
Start the conversation!
// 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
}
}