Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Probar la autenticación por token

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

¿Qué tal una prueba como ésta... pero en la que iniciamos sesión con una clave API? Creemos un nuevo método: función pública testPostToCreateTreasureWithApiKey():

... lines 1 - 10
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 13 - 61
public function testPostToCreateTreasureWithApiKey(): void
{
... lines 64 - 70
}
}

Esto empezará más o menos igual que antes. Copiaré la parte superior de la prueba anterior, quitaré el actingAs()... y añadiré un dump() cerca de la parte inferior:

... lines 1 - 10
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 13 - 61
public function testPostToCreateTreasureWithApiKey(): void
{
$this->browser()
->post('/api/treasures', [
'json' => [],
])
->dump()
->assertStatus(422)
;
}
}

Así, como antes, estamos enviando datos no válidos y esperamos un código de estado 422.

Copia ese nombre de método, luego gira y ejecuta sólo esta prueba:

symfony php bin/phpunit --filter=testPostToCreateTreasureWithApiKey

Y... ninguna sorpresa: obtenemos un código de estado 401 porque no estamos autenticados.

Enviemos una cabecera Authorization, pero una no válida para empezar. Pasa una claveheaders configurada en una matriz con Authorization y luego la palabra Bearer y luego... foo.

Esto debería seguir fallando:

symfony php bin/phpunit --filter=testPostToCreateTreasureWithApiKey

Y... ¡lo hace! Pero con un mensaje de error diferente: invalid_token. ¡Qué bien!

Utilizar un código real

Para pasar un token real, tenemos que introducir un token real en la base de datos. Hazlo con $token = ApiTokenFactory::createOne():

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 63
public function testPostToCreateTreasureWithApiKey(): void
{
$token = ApiTokenFactory::createOne([
... line 67
]);
... lines 69 - 79
}
}

¿Necesitamos controlar algún campo de esto? En realidad sí. Abre DragonTreasure. Si nos desplazamos hacia arriba, la operación Post requiere ROLE_TREASURE_CREATE:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 37
new Post(
security: 'is_granted("ROLE_TREASURE_CREATE")',
),
... lines 41 - 49
],
... lines 51 - 64
)]
... lines 66 - 83
class DragonTreasure
{
... lines 86 - 243
}

Cuando nos autenticamos a través del formulario de acceso, gracias a role_hierarchy, siempre tenemos eso. Pero cuando utilizamos una clave API, para obtener ese rol, el token necesita el ámbito correspondiente.

Para asegurarnos de que lo tenemos, en la prueba, establece la propiedad scopes enApiToken::SCOPE_TREASURE_CREATE:

... lines 1 - 4
use App\Entity\ApiToken;
... lines 6 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 63
public function testPostToCreateTreasureWithApiKey(): void
{
$token = ApiTokenFactory::createOne([
'scopes' => [ApiToken::SCOPE_TREASURE_CREATE]
]);
... lines 69 - 79
}
}

Ahora pasa esto a la cabecera: $token->getToken(). Ah... y déjame arreglarscopes: que debería ser una matriz:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 63
public function testPostToCreateTreasureWithApiKey(): void
{
$token = ApiTokenFactory::createOne([
'scopes' => [ApiToken::SCOPE_TREASURE_CREATE]
]);
... line 69
$this->browser()
->post('/api/treasures', [
... line 72
'headers' => [
'Authorization' => 'Bearer '.$token->getToken()
]
])
... lines 77 - 78
;
}
}

¡Creo que ya estamos listos! Ejecuta la prueba:

symfony php bin/phpunit --filter=testPostToCreateTreasureWithApiKey

Y... ¡ya está! ¡Vemos los bonitos 422 errores de validación!

Probar un token con un alcance incorrecto

Hagamos una prueba para asegurarnos de que no tenemos acceso si a nuestro token le falta este ámbito. Copia todo el método de prueba... y pégalo a continuación. LlámalotestPostToCreateTreasureDeniedWithoutScope().

Esta vez, cambia scopes por otra cosa, como SCOPE_TREASURE_EDIT. A continuación, ahora esperamos un código de estado 403:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 80
public function testPostToCreateTreasureDeniedWithoutScope(): void
{
$token = ApiTokenFactory::createOne([
'scopes' => [ApiToken::SCOPE_TREASURE_EDIT]
]);
$this->browser()
->post('/api/treasures', [
'json' => [],
'headers' => [
'Authorization' => 'Bearer '.$token->getToken()
]
])
->assertStatus(403)
;
}
}

Esta vez, vamos a ejecutar todas las pruebas:

symfony php bin/phpunit

Y... ¡todo verde! Un 422 y luego un 403. Ve a eliminar los volcados de ambos puntos.

Por cierto, si utilizas mucho los tokens de la API en tus pruebas, pasar la cabecera Authorizationpuede resultar molesto. Browser tiene una forma en la que podemos crear un objeto Browser personalizado con métodos personalizados. Por ejemplo, podrías añadir un método authWithToken(), pasar un array de ámbitos, y entonces crearía ese token y lo pondría en la cabecera

$this->browser()
    ->authWithToken([ApiToken::SCOPE_TREASURE_CREATE])
    // ...
;

Esto no funciona en absoluto ahora mismo, pero consulta la documentación de Browser para aprender cómo hacerlo.

Siguiente: en la API Platform 3.1, el comportamiento de la operación PUT está cambiando. Hablemos de cómo, y de lo que tenemos que hacer en nuestro código para prepararnos para ello.

Leave a comment!

4
Login or Register to join the conversation
Chtioui Avatar

Hello symfonycasts Team !
i'm using LexikJWTAuthenticationBundle to handle token authentication everything works fine until I tried to update a user's login credentials (password & email) with the PATCH operation the update request succedeed but i've got token this error : "Invalid credentials" after finishing updating the user I want to understand something here do we need to login again the user after updating his login credentials or therre is any other solution to generate a new token after the user updates his login credentials?
Cheers!

Reply

Hey @Chtioui!

Hmm very interesting! So, you're using LexikJWTAuthenticationBundle with "stateless" authentication is that correct? What I mean is: you send the JWT with every API request, right? You do not rely on just "logging in once" and then using the session to authenticate you on future request. Let me know if that assumption is incorrect :).

Anyways, if you ARE using "stateless" authentication, then I have no idea what's happening yet :P.

But if you ARE relying on session authentication, then I still don't totally know what's happening, but I can offer some info. On each request, Symfony attempts to load the User object from the session. It then checks to see if some of the important fields on that User object (the one that was stored in the session) have since changed in the database - for example, if the password has been changed. If any of these fields have changed, it does NOT load your User (i.e. it effectively logs you out). This is a security mechanism so that if you change your password on one computer because someone hacked your account, it will log ALL devices out of your account immediately. The logic for this is here: https://github.com/symfony/symfony/blob/0eb03203c800b11bac4496a3e84c75e2966d5507/src/Symfony/Component/Security/Http/Firewall/ContextListener.php#L281-L321

However, normally, if you change your password on a request, then at the end of that request, when Symfony serializes the User into the session, it will serialize the User object that contains the NEW hashed password. And so, in the next request, everything will work fine.

So, something feels weird to me. Can you tell me a bit more about your situation - are you using stateless auth or session-based auth? When exactly do you get the "Invalid credentials" error - is that on the NEXT request? Are you sending a JWT on that? What does your JWT contain?

Cheers!

1 Reply
Chtioui Avatar

Hello @weaverryan,

-Yes i'm using stateless authentication with the LexikJWTAuthenticationBundle and i'm using the user's username and password for generating the JWT token.
-I get the "Invalid credentials" error on the next request and the user is no longer authenticated in my web app.

-I think whats happening is normal because the user is updating his login credentials (username & password in my case) that's why i'm getting the error "Invalid credentials" with the status 401 also I get this error only when update the user's login credentials but if I update other properties of the user's class everything works fine and I got no errors from the api response.

-I want to know if there is any mechanism to integrate to solve the problem or do I need to authenticate again the user after he updates one of his login credentials.

*firewalls in security.yaml:

security firewall

    firewalls:
        api:
            pattern: ^/api/
            stateless: true
            provider: app_user_provider
            jwt: ~
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            json_login:
                check_path: /authentication_token
                username_path: username
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
            refresh_jwt:
                check_path: /authentication_token/refresh # or, you may use the `api_refresh_token` route name
            logout:
                path: app_logout

Lexik bundle file configuration:

Lexic bundle configuration file

lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 1800 # 30min in seconds 1800

Cheers!

Reply

Hey @Chtioui!

Hmm. If the user changes their username, it DOES make sense that you would lose authentication since the username is what's added to the JWT... and then that username is read from the JWT to find the user. So if you change the username, that user won't be found. To fix that, you could change your JWT to use the id instead, which is probably safer anyways. I'm not sure exactly how you're supposed to do this - but the https://symfony.com/bundles/LexikJWTAuthenticationBundle/current/2-data-customization.html seems to be close.

I don't understand why changing the password is also causing problems - but you did mention that:

Yes i'm using stateless authentication with the LexikJWTAuthenticationBundle and i'm using the user's username and password for generating the JWT token

So perhaps you're adding and using the password in the token in some manual way already.

Anyways, to fix the issue, you'll need to either:

A) Re-authenticate after this (as you know is already possible)
or
B) Somehow send back a fresh JWT from the user endpoint where you update the email/password. I'm not sure how standard this is... but in theory, you could register an event listener that could add a custom response header - e.g. X-JWT to this endpoint. It's a non-trivial problem. You might, inside User::setPlainPassword() and setUsername() set some flag on your User like $this->needsNewJWT = true. Then register a ResponseListener and look for that (you can get the User object via $request->attributes->get('data').

I hope this gives you some hints :)

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