Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Create a Shiny JSON Web 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 $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Create a new TokenController in the Api directory:

... lines 1 - 2
namespace AppBundle\Controller\Api;
use AppBundle\Controller\BaseController;
... lines 6 - 9
class TokenController extends BaseController
{
... lines 12 - 19
}

Make this extend the same BaseController from our project and let's get to work!

First create a public function newTokenAction(). Add the @Route above and let it autocomplete so that the use statement is added for the annotation. Set the URL to /api/tokens. Heck, let's get crazy and also add @Method: we only want this route to match for POST requests:

... lines 1 - 5
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
... lines 8 - 9
class TokenController extends BaseController
{
/**
* @Route("/api/tokens")
* @Method("POST")
*/
public function newTokenAction()
{
... line 18
}
}

To start, don't get too fancy: just return a new Response from HttpFoundation with TOKEN!:

... lines 1 - 7
use Symfony\Component\HttpFoundation\Response;
class TokenController extends BaseController
{
... lines 12 - 15
public function newTokenAction()
{
return new Response('TOKEN!');
}
}

Got it! That won't make our test pass, but it is an improvement. Re-run it:

./vendor/bin/phpunit --filter testPOSTCreateToken

Still failing - but now it has the 200 status code.

Checking the Username and Password

Head back to TokenController. Here's the process:

  1. Check that the username and password are correct.
  2. Generate a JSON web token.
  3. Send it back to the client.
  4. High-five everyone at your office. I can't wait to get to that step.

Type-hint a new argument with Request to get the request object:

... lines 1 - 8
use Symfony\Component\HttpFoundation\Request;
... lines 10 - 12
class TokenController extends BaseController
{
... lines 15 - 18
public function newTokenAction(Request $request)
{
... lines 21 - 39
}
}

Next, query for a User object with the normal $user = $this->getDoctrine()->getRepository('AppBundle:User') and findOneBy(['username' => '']). Get the HTTP Basic username string with $request->getUser():

... lines 1 - 12
class TokenController extends BaseController
{
... lines 15 - 18
public function newTokenAction(Request $request)
{
$user = $this->getDoctrine()
->getRepository('AppBundle:User')
->findOneBy(['username' => $request->getUser()]);
... lines 24 - 39
}
}

And what if we can't find a user? Throw a $this->createNotFoundException():

... lines 1 - 20
$user = $this->getDoctrine()
->getRepository('AppBundle:User')
->findOneBy(['username' => $request->getUser()]);
if (!$user) {
throw $this->createNotFoundException();
}
... lines 28 - 41

If you wanted to hide the fact that the username was wrong, you can throw a BadCredentialsException instead - you'll see me do that in a second.

Checking the password is easy: $isValid = $this->get('security.password_encoder') ->isPasswordValid(). Pass it the $user object and the raw HTTP Basic password string: $request->getPassword():

... lines 1 - 10
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
class TokenController extends BaseController
{
... lines 15 - 18
public function newTokenAction(Request $request)
{
$user = $this->getDoctrine()
->getRepository('AppBundle:User')
->findOneBy(['username' => $request->getUser()]);
if (!$user) {
throw $this->createNotFoundException();
}
$isValid = $this->get('security.password_encoder')
->isPasswordValid($user, $request->getPassword());
if (!$isValid) {
throw new BadCredentialsException();
}
... lines 35 - 39
}
}

If this is not valid, throw a new BadCredentialsException. We're going to talk a lot more later about properly handling errors so that we can control the exact JSON returned. But for now, this will at least kick the user out.

Ok, ready to finally generate that JSON web token? Create a $token variable and set it to $this->get('lexik_jwt_authentication.encoder')->encode() and pass that any array of information you want to store in the token. Let's store ['username' => $user->getUsername()] so we know who this token belongs to:

... lines 1 - 18
public function newTokenAction(Request $request)
{
$user = $this->getDoctrine()
->getRepository('AppBundle:User')
->findOneBy(['username' => $request->getUser()]);
... lines 25 - 35
$token = $this->get('lexik_jwt_authentication.encoder')
->encode([
'username' => $user->getUsername(),
'exp' => time() + 3600 // 1 hour expiration
]);
... lines 41 - 42
}

Tip

Don't forget to pass an exp key to the token, otherwise the token will never expire! We forgot to do this in the video!

But you can store anything here, like roles, user information, some poetry - whatever!

And that's it! This is a string, so return a new JsonResponse with a token field set to $token:

... lines 1 - 7
use Symfony\Component\HttpFoundation\JsonResponse;
... lines 9 - 12
class TokenController extends BaseController
{
... lines 15 - 18
public function newTokenAction(Request $request)
{
$user = $this->getDoctrine()
->getRepository('AppBundle:User')
->findOneBy(['username' => $request->getUser()]);
if (!$user) {
throw $this->createNotFoundException();
}
$isValid = $this->get('security.password_encoder')
->isPasswordValid($user, $request->getPassword());
if (!$isValid) {
throw new BadCredentialsException();
}
$token = $this->get('lexik_jwt_authentication.encoder')
->encode([
'username' => $user->getUsername(),
'exp' => time() + 3600 // 1 hour expiration
]);
return new JsonResponse(['token' => $token]);
}

That's it, that's everything. Run the test!

./vendor/bin/phpunit --filter testPOSTCreateToken

It passes! Now, make sure a bad password fails. Duplicate this method:

... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
public function testPOSTCreateToken()
{
$this->createUser('weaverryan', 'I<3Pizza');
$response = $this->client->post('/api/tokens', [
'auth' => ['weaverryan', 'I<3Pizza']
]);
$this->assertEquals(200, $response->getStatusCode());
$this->asserter()->assertResponsePropertyExists(
$response,
'token'
);
}
}

and rename it to testPOSTTokenInvalidCredentials(). But now, we'll lie and pretend my password is IH8Pizza... even though we know that I<3Pizza:

... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
$this->createUser('weaverryan', 'I<3Pizza');
$response = $this->client->post('/api/tokens', [
'auth' => ['weaverryan', 'IH8Pizza']
]);
$this->assertEquals(401, $response->getStatusCode());
}
}

Check for a 401 status code. Copy the method name and go run that test:

./vendor/bin/phpunit --filter testPOSTTokenInvalidCredentials

It should pass... but it doesn't! Interesting. Look at this: it definitely doesn't return the token... it redirected us to /login. We are getting kicked out of the controller, but this is not how we want our API error responses to work. We'll fix this a bit later.

Leave a comment!

15
Login or Register to join the conversation
Default user avatar
Default user avatar Sébastien Rosset | posted 5 years ago

Hello Ryan,
For information, it seems that $request->getUser() does not work if your server is on CGI/FastCGI mode like mine is.
I tried it in many ways (with phpStorm REST Client, in my browser, etc) but I never get any value for $_SERVER['PHP_AUTH_USER'].

It seems like Symfony tried to get around this limitation by putting this in the /web/.htaccess file:
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
(http://stackoverflow.com/qu...

However that still doesn't work.
So I guess I'll have to pass the user/password without the HTTP headers...

Cheers
Sebastien

2 Reply
Default user avatar
Default user avatar Sébastien Rosset | Sébastien Rosset | posted 5 years ago

To add to this comment:
The Authorization header is also filtered out by Apache when we use the Bearer (token) authentification. The only way I found around this is to change the name of the sent header to "Token" (could have been anything else). Then I adapted the code of the AuthorizationHeaderTokenExtractor (part 7.1 of this course).

$extractor = new AuthorizationHeaderTokenExtractor(
'Bearer',
'Token' // instead of 'Authorization'
);

And it worked.

Reply
Default user avatar
Default user avatar Sébastien Rosset | Sébastien Rosset | posted 5 years ago

So I got around the problem by editing the /web/.htaccess like this:

RewriteCond %{HTTP:Authorization} .
#RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteRule .* - [E=REMOTE_USER:%{HTTP:Authorization}]

Then by adding these lines of code at the top of the app.php and app_dev.php files:

$_SERVER['HTTP_AUTHORIZATION'] = $_SERVER['REDIRECT_REMOTE_USER'];
list($authType, $authKey) = explode(' ' , $_SERVER['HTTP_AUTHORIZATION']);
if( $authType=="Basic" ) { // manually get user and password
list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':' , base64_decode($authKey));
}

Hope it can be useful to someone else.

2 Reply

Wow, thanks for sharing all of this Sébastien! And more importantly, I'm glad you got it figured out! I know that Apache can do some weird things with the Authorization header - but this is pretty wild :)

Cheers!

Reply
picks Avatar
picks Avatar picks | posted 4 years ago | edited

Hi, since v2.6 of the <a href="https://github.com/lexik/LexikJWTAuthenticationBundle/blob/master/Resources/doc/1-configuration-reference.md#encoder-configuration&quot;&gt;LexikJWTAuthenticationBundle&lt;/a&gt;, the lexik_jwt_authentication.encoder service has to be replaced by lexik_jwt_authentication.encoder.lcobucci apparently.
Could you maybe update the tutorial? :)

Reply

Yo picks!

Sorry for the slow reply! We queued this up on our list of updates to make to our tutorial... but I think that there's actually nothing to update - but you can tell me if you disagree! In version 2.6, there is still a service called lexik_jwt_authentication.encoder - it's an alias that points to whatever your actual, underlying service is (which is usually the lcobucci one you mentioned): https://github.com/lexik/LexikJWTAuthenticationBundle/blob/14262e890833e55d7218ea8c16f114582771a494/DependencyInjection/LexikJWTAuthenticationExtension.php#L76

What is deprecated I believe is the lexik_jwt_authentication.encoder.default service.

Let me know if you're seeing something different - I was just checking through the bundle source code :).

Cheers!

Reply
Default user avatar
Default user avatar Alex123 | posted 5 years ago

Guzzle 6 and class ResponseAsserter it doesnt work!
Is there already a fix or workaround?

Reply

Hey Alex!

We upgraded the project to Guzzle 6 before this part (episode 4). If you download the "start" code for the project, you can get the new ApiTestCase that works with Guzzle 6 (fortunately, the ResponseAsserter did not need any changes!). I also added a gist for people - since this is a common question!

https://gist.github.com/wea...

Cheers!

Reply

Yo Josh!

Man, good question :). So, in theory, there are an infinite number of ways that a request could send username/password data on a request: it could send it as POST parameters, it could send a JSON string with username & password fields, or it could set some headers on the request that communicate the username & password. None are right/wrong, but there is an age-old standard way - "HTTP Basic Authentication", where the client sets an Authorization header with "Basic " and then a string that combines the user/pass (https://en.wikipedia.org/wi.... Since this is a standard way, that's what Symfony's Request::getUser() and Request::getPassword() look for. It's not doing anything special, it's just looking for that basic way of authentication. Behind the scenes, PHP itself actually recognizes Basic authentication and puts the user and pass on $_SERVER keys called PHP_AUTH_USER and PHP_AUTH_PW. This is actually all that Symfony needs to look for :).

I hope that helps! Cheers!

Reply
Default user avatar
Default user avatar Steve Meyer | posted 5 years ago

Do you have the source available for this one?

Reply

Hey Steve!

If you're a subscriber, we have a "Download" button on the upper right of the page. Otherwise, you can at least grab the "start" code for the whole series (but not the finish code) from our GitHub page: https://github.com/knpunive...

I hope that helps!

Reply
Default user avatar
Default user avatar leevigraham | posted 5 years ago | edited

there Great tutorial! I'm curious why you didn't recommend the standard config for the bundle which provides a 'login' endpoint and firewall? https://github.com/lexik/Le... Clearly Guard gives you more control but to get up and running quickly the standard config has worked for me.

Reply

Hey Leevi!

Honestly, this is something I've been debating with myself recently. As you mentioned, Guard clearly gives you more control, but it requires a lot more setup. While the built-in stuff is quick to setup, but you lack control. What seems to be missing is a middle-ground - something that is quick to setup, but then you have sensible extension points afterwards. The answer could be to provide more out-of-the-box ready authenticators in core or in libraries. The biggest problem I have with something like form_login isn't that error/success handling, it's (a) *how* your User is loaded from the database and (b) where you grab the request information from (in other words, getCredentials() and getUser() in Guard). If you want to have a custom query to load your User or something else, it's not obvious how to do that. Neither is it obvious how you would use if you wanted your user to send the fields in JSON instead of POST params (actually, you'd have to create your own system for this currently).

Anyways, it's a completely valid question - and I think there's *some* answer in there to either make the Guard authenticators easier to setup *or* provide better extension points on built-in systems.

Cheers!

Reply

The isPasswordValid() method returns false even if the password is correct.
Any idea please?

I'm using Symfony 4.1 + bcrypt encoder

-1 Reply

Hey ahmadmayahi

I believe I know what's going on. The Guzzle client is hitting your "dev" environment instead of the "test" one
Can you double check it? Just dump the kernel.environment parameter in your controller's action


// someController.php
public function anyAction()
{
    dd($this->container->getParameter('kernel.environment'));
}

If that's the case, you have two options
1) Use Symfony's client instead of Guzzle https://symfony.com/doc/current/components/browser_kit.html
2) Create your own "app_test.php" front controller so you can use it for testing
(I prefer option one)

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony. The concepts of API tokens & JWT are still valid, but integration in newer Symfony versions may be different.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.0.*", // v3.0.3
        "doctrine/orm": "^2.5", // v2.5.4
        "doctrine/doctrine-bundle": "^1.6", // 1.6.2
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.3.11
        "symfony/monolog-bundle": "^2.8", // v2.10.0
        "sensio/distribution-bundle": "^5.0", // v5.0.4
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.14
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.2
        "jms/serializer-bundle": "^1.1.0", // 1.1.0
        "white-october/pagerfanta-bundle": "^1.0", // v1.0.5
        "lexik/jwt-authentication-bundle": "^1.4" // v1.4.3
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.0.6
        "symfony/phpunit-bridge": "^3.0", // v3.0.3
        "behat/behat": "~3.1@dev", // dev-master
        "behat/mink-extension": "~2.2.0", // v2.2
        "behat/mink-goutte-driver": "~1.2.0", // v1.2.1
        "behat/mink-selenium2-driver": "~1.3.0", // v1.3.1
        "phpunit/phpunit": "~4.6.0", // 4.6.10
        "doctrine/doctrine-fixtures-bundle": "^2.3" // 2.3.0
    }
}
userVoice