If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
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.
Head back to TokenController
. Here's the process:
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.
// 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
}
}