Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

En Autenticación correcta

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Si actualizas la página y compruebas la barra de herramientas de depuración web, verás que no hemos iniciado sesión. Probemos a utilizar un correo electrónico y una contraseña reales. Podemos hacer trampas haciendo clic en los enlaces de correo electrónico y contraseña: este usuario existe en nuestro AppFixtures, así que debería funcionar. Y... vale... ¡las casillas desaparecen! Pero no ocurre nada más. Lo mejoraremos en un minuto.

¡Gracias Sesión!

Pero por ahora, actualiza la página y vuelve a mirar la barra de herramientas de depuración web. ¡Estamos autentificados! ¡Sí! Sólo con hacer una petición AJAX correcta a esa ruta de inicio de sesión, ha bastado para crear la sesión y mantenernos conectados. Mejor aún, si empezáramos a hacer peticiones a nuestra API desde JavaScript, esas peticiones también se autenticarían. Así es No necesitamos un lujoso sistema de tokens de API en el que adjuntemos un token a cada petición. Sólo tenemos que hacer una petición y, gracias a la magia de las cookies, esa petición se autenticará.

REST y ¿qué datos devolver desde nuestra ruta de autenticación?

Así que el inicio de sesión ha funcionado... pero no ha pasado nada en la página. ¿Qué debemos hacer después de la autenticación? Una vez más, en realidad no importa. Si estás escribiendo tu sistema de autenticación para tu propio JavaScript, deberías hacer lo que sea útil para tu frontend. Actualmente devolvemos el id de user. Pero podríamos, si quisiéramos, devolver todo el objeto user como JSON.

Pero hay un pequeño problema con eso. No es super RESTful. Es una de esas cosas de "pureza REST". Cada URL de tu API, a nivel técnico, representa un recurso diferente. Esto representa el recurso de la colección, y esta URL representa un único recurso User. Y si tienes una URL diferente, se entiende que es un recurso diferente. La cuestión es que, en un mundo perfecto, sólo devolverías un recurso User desde una única URL en lugar de tener cinco rutas diferentes para buscar un usuario.

Si devolvemos el JSON de User desde esta ruta, "técnicamente" estamos creando un nuevo recurso API. De hecho, cualquier cosa que devolvamos desde esta ruta, desde un punto de vista REST, se convierte en un nuevo recurso de nuestra API. Para ser honesto, todo esto es semántica técnica y deberías sentirte libre de hacer lo que quieras. Pero tengo una sugerencia divertida.

Devolver la IRI

Para intentar ser útil a nuestro frontend y algo RESTful, tengo otra idea. ¿Y si no devolvemos nada de la ruta .... pero colamos el IRI del usuario en la cabecera Location de la respuesta? Entonces, nuestro frontend podría utilizarlo para saber quién acaba de iniciar sesión.

Te lo mostraré. En primer lugar, en lugar de devolver el ID de usuario, vamos a devolver el IRI, que será algo parecido a '/api/users/'.$user->getId(). Pero no quiero codificarlo porque podríamos cambiar la URL en el futuro. Prefiero que la API Platform lo genere por mí.

Y, afortunadamente, API Platform nos ofrece un servicio autoinstalable para hacerlo Antes del argumento opcional, añade un nuevo argumento de tipo IriConverterInterfacey llámalo $iriConverter:

... lines 1 - 4
use ApiPlatform\Api\IriConverterInterface;
... lines 6 - 10
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
{
... lines 16 - 24
}
}

Luego, aquí abajo, return new Response() (el de HttpFoundation) sin contenido y con un código de estado 204:

... lines 1 - 10
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
{
... lines 16 - 21
return new Response(null, 204, [
... line 23
]);
}
}

El 204 significa que ha tenido "éxito... pero no hay contenido que devolver". También pasaremos una cabecera Location establecida en $iriConverter->getIriFromResource():

... lines 1 - 10
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
{
... lines 16 - 21
return new Response(null, 204, [
'Location' => $iriConverter->getIriFromResource($user),
]);
}
}

Para que puedas obtener el recurso de un IRI o la cadena IRI del recurso, siendo el recurso tu objeto. Pasa este $user.

Utilizar el IRI en JavaScript

¿Qué te parece? Ahora que estamos devolviendo esto, ¿cómo podemos utilizarlo en JavaScript? Lo ideal sería que, después de iniciar sesión, mostráramos automáticamente algo de información sobre el usuario a la derecha. Esta zona está construida por otro archivo Vue llamado TreasureConnectApp.vue:

<template>
<div class="purple flex flex-col min-h-screen">
... lines 3 - 5
<div class="flex-auto flex flex-col sm:flex-row justify-center px-8">
<LoginForm
v-on:user-authenticated="onUserAuthenticated"></LoginForm>
<div
class="book shadow-md rounded sm:ml-3 px-8 pt-8 pb-8 mb-4 sm:w-1/2 md:w-1/3 text-center">
<div v-if="user">
Authenticated as: <strong>{{ user.username }}</strong>
| <a href="/logout" class="underline">Log out</a>
</div>
<div v-else>Not authenticated</div>
... lines 17 - 20
</div>
</div>
... line 23
</div>
</template>
<script setup>
import { ref } from 'vue';
import LoginForm from '../LoginForm';
import coinLogoPath from '../../images/coinLogo.png';
import goldPilePath from '../../images/GoldPile.png';
defineProps(['entrypoint']);
const user = ref(null);
const onUserAuthenticated = async (userUri) => {
const response = await fetch(userUri);
user.value = await response.json();
}
</script>

No entraré en detalles, pero mientras ese componente tenga datos del usuario, los imprimirá aquí. Y LoginForm.vue ya está configurado para pasar esos datos de usuario a TreasureConnectApp.vue. En la parte inferior, después de una autenticación correcta, aquí es donde borramos el estado de email y password, que vacía las casillas después de iniciar sesión. Si emitimos un evento llamadouser-authenticated y le pasamos el userIri, TreasureConnectApp.vueya está configurado para escuchar este evento. Entonces hará una petición AJAX auserIri, obtendrá el JSON de vuelta y rellenará sus propios datos.

Si no te sientes cómodo con Vue, no pasa nada. La cuestión es que todo lo que tenemos que hacer es coger la cadena IRI de la cabecera Location, emitir este evento, y todo debería funcionar.

Para leer la cabecera, di const userIri = response.headers.get('Location'). También descomentaré esto para que podamos emit:

... lines 1 - 48
<script setup>
... lines 50 - 65
const handleSubmit = async () => {
... lines 67 - 89
email.value = '';
password.value = '';
const userIri = response.headers.get('Location');
emit('user-authenticated', userIri);
}
</script>

¡Esto debería funcionar! Muévete y actualiza. Lo primero que quiero que notes es que seguimos conectados, pero nuestra aplicación Vue no sabe que estamos conectados. Vamos a arreglar eso en un minuto. Vuelve a iniciar sesión con nuestro correo electrónico y contraseña válidos. Y... ¡precioso! Hicimos la petición a POST, nos devolvió el IRI y luego nuestro JavaScript hizo una segunda petición a ese IRI para obtener los datos del usuario, que mostró aquí.

A continuación: Hablemos de lo que significa cerrar sesión en una API. A continuación, te mostraré una forma sencilla de decirle a tu JavaScript quién ha iniciado sesión al cargar la página. Porque, ahora mismo, aunque estemos conectados, en cuanto actualizo, nuestro JavaScript piensa que no lo estamos. Lamentable.

Leave a comment!

5
Login or Register to join the conversation
Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted hace 1 mes | edited

Hi Ryan, it's me again. I have a problem:

I want to add conditions in my QueryExtension, you know where Project in (:allowedProjects). So far, so easy. I create the Extension, get the user from the Token, add some joins and conditions and done.
But I find is quite cumbersome to always add some nested joins to "get back" to my Project-User Mapping to know which projects my user has access to, so I tried a different approach:

1) A subscriber that happens right after authorization (In my case 'kernel.request' => [['addProjects', 8]]). Here, I get the User from the Token, fetch all my Projects from different sources (he can be the owner or was granted access by another way) and add them to the user

public function addProjects(RequestEvent $requestEvent): void
{
    if (!$requestEvent->isMainRequest()) return;
    
    $user = $this->security->getUser();
    // fetch projects
    $user->addProjects($projects); // NOT a Doctrine relation, just a list of entities
}

2) In my QueryExtension, I get the User from the Token and can simply where project in (:projects) and setParameter('projects', $user->getProjects()) and done

My Problem: The user in my Subscriber and the user in my Query ARE NOT THE SAME, event if it's only one request. I mean, it's the same email address, but a different object (used spl_object_hash), so the User in the QueryExtension has an empty projects list

In other words: How to modify the user that is used in the QueryExtension?

Reply

Hey @Sebastian-K!

Ah, that's an interesting solution! So the problem is that, with the priority 8 on your event listener, your listener is running before the security system, most importantly before the core ContextListener, whose job it is to take the User object from the previous request (which had been serialized into the session), grab its id, then query for a fresh User (to make sure the User object has the most up to date database data). So very good digging to figure out via spl_object_hash() that these are not the same objects. Actually, I think the core system also has a priority of 8, so it's probably just bad luck that your's is getting called too late. I'm guessing you chose those on purpose, for example, to be early enough in API Platform's process.

What I would do instead (and I would probably do this even if you didn't have this problem) is create some sort of UserContext service and give IT the addProjects() method. Then, autowire this service where you need it. That should solve the problem as you won't be trying to change the User object anymore. I like this solution better anyway because (though perhaps slightly less convenient), I always feel weird setting non-persisted data onto an entity... not there there is anything SO wrong about this.

Anyway, let me know if this helps!

Cheers!

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | posted hace 2 meses | edited

Hello there!
I'm currently creating a login form just like yours and it works great, thanks. But I'm also trying to register my users through something similar, and I meet some difficulties to authenticate the new User after registration, due to the Json_login technic.
How could I do that, please?
For now, I've got this:

    #[Route('/register', name: 'app_register', methods: ['POST'])]
    public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, IriConverterInterface $iriConverter, EntityManagerInterface $entityManager, UserAuthenticatorInterface $userAuthenticator, JsonLoginAuthenticator $authenticator): Response
    {
        $user = new User();
        $temp = json_decode($request->getContent(), true); 
        $user->setRoles(["ROLE_USER"]);               
        $user->setEmail($temp['email']);
        $user->setPassword(
            $userPasswordHasher->hashPassword(
                $user,
                $temp['password']
            )
        );

        $entityManager->persist($user);
        $entityManager->flush();

        return $userAuthenticator->authenticateUser(
                $user,
                $authenticator,
                $request
            );
            

        /*return new Response(null, 204, [
            'Location' => $iriConverter->getIriFromResource($user),
        ]);*/            
    }

If I uncomment the last response (and obviously comment the previous one), it does work as long the new user doesn't refresh its page. And if I use $userAuthenticator, I have a 500 error:

Cannot autowire argument $authenticator of "App\Controller\RegistrationController::register()": it references class "Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator" but no such service exists. You should maybe alias this class to the existing "security.authenticator.json_login.main" service.

What should I change to automatically Login the new User, please?

Thank you.

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | sadikoff | posted hace 2 meses

Great, thank you!

Reply
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