Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Autenticador de token de acceso

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.

Para autenticarse con un token, un cliente de la API enviará una cabecera Authorization con la palabra Bearer y, a continuación, la cadena del token... que no es más que una práctica estándar:

$client->request('GET', '/api/treasures', [
    'headers' => [
        'Authorization' => 'Bearer TOKEN',
    ],
]);

Entonces algo en nuestra aplicación leerá esa cabecera, se asegurará de que el token es válido, autenticará al usuario y montará una gran fiesta para celebrarlo.

Activar access_token

Afortunadamente, ¡Symfony tiene el sistema perfecto para esto! Gira y abreconfig/packages/security.yaml. En cualquier lugar bajo tu cortafuegos añade access_token:

security:
... lines 2 - 11
firewalls:
... lines 13 - 15
main:
... lines 17 - 24
access_token:
... lines 26 - 52

Esto activa una escucha que observará cada petición para ver si tiene una cabeceraAuthorization. Si lo tiene, lo leerá e intentará autenticar al usuario.

Sin embargo, requiere una clase ayudante... porque aunque sabe dónde encontrar el token en la petición... ¡no tiene ni idea de qué hacer con él! No sabe si se trata de un JWT que debe descodificar... o, en nuestro caso, que puede consultar la base de datos en busca del registro coincidente. Así que, para ayudarle, añade una opción token_handler establecida en el id de un servicio que crearemos: App\Security\ApiTokenHandler:

security:
... lines 2 - 11
firewalls:
... lines 13 - 15
main:
... lines 17 - 24
access_token:
token_handler: App\Security\ApiTokenHandler
... lines 27 - 52

Cortafuegos sin estado

Por cierto, si tu sistema de seguridad sólo permite la autenticación mediante un token de API, entonces no necesitas almacenamiento de sesión. En ese caso, puedes establecer una bandera stateless: true que indique al sistema de seguridad que, cuando un usuario se autentique, no se moleste en almacenar la información del usuario en la sesión. Voy a eliminar eso, porque tenemos una forma de iniciar sesión que depende de la sesión.

La clase Token Handler

Bien, vamos a crear esa clase manejadora. En el directorio src/ crea un nuevo subdirectorio llamado Security/ y dentro de él una nueva clase PHP llamadaApiTokenHandler. Esta es una clase muy sencilla. Haz que implementeAccessTokenHandlerInterface y luego ve a "Código"->"Generar" o Command+N en un Mac y selecciona "Implementar Métodos" para generar el que necesitamos:getUserBadgeFrom():

... lines 1 - 2
namespace App\Security;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class ApiTokenHandler implements AccessTokenHandlerInterface
{
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
// TODO: Implement getUserBadgeFrom() method.
}
}

El sistema access_token sabe cómo encontrar el token: sabe que vivirá en una cabecera Authorization con la palabra Bearer delante. Así que coge esa cadena, llama a getUserBadgeFrom() y nos la pasa. Por cierto, este atributo#[\SensitiveParameter] es una nueva característica de PHP. Está bien, pero no es importante: sólo asegura que si se lanza una excepción, este valor no se mostrará en el stacktrace.

Nuestro trabajo aquí es consultar la base de datos utilizando el $accessToken y luego devolver a qué usuario se refiere. Para ello, ¡necesitamos el ApiTokenRepository! Añade un método construct con un argumento private ApiTokenRepository $apiTokenRepository:

... lines 1 - 4
use App\Repository\ApiTokenRepository;
... lines 6 - 9
class ApiTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private ApiTokenRepository $apiTokenRepository)
{
}
... lines 15 - 25
}

Abajo, digamos $token = $this->apiTokenRepository y luego llama a findOneBy()pasándole un array, para que consulte donde el campo token es igual a $accessToken:

... lines 1 - 9
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 12 - 15
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]);
... lines 19 - 24
}
}

Si la autenticación falla por cualquier motivo, necesitamos lanzar un tipo de excepción de seguridad. Por ejemplo, si el token no existe, lanzar una nuevaBadCredentialsException: la de los componentes Symfony:

... lines 1 - 5
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
... lines 7 - 9
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 12 - 15
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]);
if (!$token) {
throw new BadCredentialsException();
}
... lines 23 - 24
}
}

Esto hará que falle la autenticación... pero no necesitamos pasar un mensaje. Esto devolverá un mensaje "Credenciales incorrectas." al usuario.

Llegados a este punto, hemos encontrado la entidad ApiToken. Pero, en última instancia, nuestro sistema de seguridad quiere autenticar a un usuario... no un "Token API". Lo hacemos devolviendo un UserBadge que, en cierto modo, envuelve al objeto User. Observa: devuelve un new UserBadge(). El primer argumento es el "identificador de usuario". Pasa $token->getOwnedBy() para obtener elUser y luego ->getUserIdentifier():

... lines 1 - 7
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 12 - 15
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
... lines 18 - 23
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
}
}

Cómo se carga el objeto usuario

Observa que en realidad no estamos devolviendo el objeto User. Esto se debe principalmente a que... ¡no lo necesitamos! Deja que te lo explique. Mantén pulsado Command o Ctrl y haz clic engetUserIdentifier(). Lo que esto devuelve realmente es el email del usuario . Así que estamos devolviendo un UserBadge con el email del usuario dentro. Lo que ocurre a continuación es lo mismo que ocurre cuando enviamos un email al punto final de autenticación json_login. El sistema de seguridad de Symfony toma ese correo electrónico y, como tenemos este proveedor de usuario, sabe que debe consultar la base de datos en busca de un User con ese email.

Así que volverá a consultar la base de datos en busca del User a través del correo electrónico... lo cual es un poco innecesario, pero está bien. Si quieres evitarlo, podrías pasar un callable al segundo argumento y devolver $token->getOwnedBy(). Pero esto funcionará bien tal como está.

Ah, ¡y probablemente sea buena idea comprobar y asegurarnos de que el token es válido! Si no lo es$token->isValid(), entonces podríamos lanzar otro BadCredentialsException. Pero si quieres personalizar el mensaje, también puedes lanzar un nuevoCustomUserMessageAuthenticationException con "Token caducado" para devolver ese mensaje al usuario:

... lines 1 - 6
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
... lines 8 - 10
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 13 - 16
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
... lines 19 - 24
if (!$token->isValid()) {
throw new CustomUserMessageAuthenticationException('Token expired');
}
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
}
}

¿Usar el Token en Swagger?

Y... ¡listo! Entonces... ¿cómo probamos esto? Bueno, lo ideal sería probarlo en nuestros documentos Swagger. Voy a abrir una nueva pestaña... y luego cerraré la sesión. Pero mantendré abierta mi pestaña original... ¡así podré robar estos tokens válidos!

Dirígete a los documentos de la API. ¿Cómo podemos decirle a esta interfaz que envíe un token de API cuando haga las peticiones? Bueno, habrás notado que hay un botón "Autorizar". Pero cuando lo pulsamos... ¡está vacío! Eso es porque todavía no le hemos dicho a Open API cómo pueden autenticarse los usuarios. Afortunadamente, podemos hacerlo a través de API Platform.

Abre config/packages/api_platform.yaml. Y una nueva clave llamada swagger, aunque en realidad estamos configurando los documentos de OpenAPI. Para añadir una nueva forma de autenticación, configura api_keys para activar ese tipo, luego access_token... que puede ser lo que quieras. Debajo de esto, dale un nombre a este mecanismo de autenticación... y type: header porque queremos pasar el token como cabecera:

api_platform:
... lines 2 - 7
swagger:
api_keys:
access_token:
name: Authorization
type: header
... lines 13 - 18

Esto le dirá a Swagger -a través de nuestros documentos OpenAPI- que podemos enviar tokens de API a través de la cabecera Authorization. Ahora, cuando pulsemos el botón "Autorizar"... ¡sí! Dice "Nombre: Autorización", "En cabecera".

Para usar esto, tenemos que empezar con la palabra Bearer y luego un espacio... porque no lo rellena por nosotros. Hablaremos de ello más adelante. Probemos primero con un token no válido. Pulsa "Autorizar". En realidad, aún no se ha realizado ninguna petición: sólo se ha almacenado el código en JavaScript.

Probemos con la ruta get treasure collection. Cuando ejecutamos... ¡impresionante! ¡A 401! No necesitamos autenticarnos para utilizar este punto final, pero como pasamos una cabecera Authorization con Bearer y luego un token, el nuevo sistema access_tokenlo captó, pasó la cadena a nuestro manejador... pero luego no pudimos encontrar un token coincidente en la base de datos, así que lanzamos el error BadCredentialsException

Puedes verlo aquí abajo: la API devolvió una respuesta vacía, pero con una cabecera que contenía invalid_token y error_description: "Credenciales no válidas".

Comprobación de que la autenticación por token funciona

Así que el caso malo funciona. ¡Probemos el caso feliz! En la otra pestaña, copia uno de los tokens válidos. Vuelve a deslizarte hacia arriba, pulsa "Autorizar" y luego "Cerrar sesión". Cerrar sesión sólo significa que "olvida" el token de la API que hemos establecido hace un minuto. Vuelve a escribir Bearer , pega, pulsa "Autorizar", cierra... y bajemos a probar de nuevo esta ruta. Y... ¡woohoo! ¡A 200!

Así que parece que ha funcionado... ¿pero cómo podemos saberlo? Pues bien, abajo, en la barra de herramientas de depuración web, haz clic para abrir el perfilador de esa petición. En la pestaña Seguridad... ¡sí! Hemos iniciado sesión como Bernie. ¡Éxito!

Lo único que no me gusta es tener que escribir esa cadena Bearer en el cuadro de autorización. No es muy fácil de usar. Así que, a continuación, vamos a solucionarlo aprendiendo cómo podemos personalizar el documento de especificaciones OpenAPI que utiliza Swagger.

Leave a comment!

2
Login or Register to join the conversation
Ange-B Avatar

Hi !

This example shows how to get User when JWT TOKENs are stored in database.

I'm using LexikJWTAuthenticationBundle. Users are not stored in database.
I want to retrieve Connected User when making request with the generated TOKEN.
How can i do this with class ApiTokenHandler ?

Please help me ! Thanks You

Reply

Hi @Ange-B,

Can you give more context? Where exactly do you want to get User info?

IIRC if you are using LexikJWTAuthenticationBundle it already stores the user info in symfony security system, so it can be easily accessed from anywhere, all you need is love autowire Security class from Security bundle and use $this->security->getUser() method to get current authenticated user

PS maybe I'm missing something so waiting for your feedback

Cheers!

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