If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWhenever something goes wrong in our API, we have a great setup: we always get back a descriptive JSON structure with keys that describe what went wrong:
... lines 1 - 6 | |
class ProgrammerControllerTest extends ApiTestCase | |
{ | |
... lines 9 - 255 | |
public function test404Exception() | |
{ | |
... lines 258 - 261 | |
$this->assertEquals(404, $response->getStatusCode()); | |
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]); | |
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank'); | |
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found'); | |
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'No programmer found with nickname "fake"'); | |
} | |
... lines 268 - 276 | |
} |
I want to do the exact same thing when something goes wrong with authentication.
Open up the TokenControllerTest
:
... 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()); | |
} | |
} |
Here, we purposefully send an invalid username and password combination. This actually hits TokenController
, we throw this new BadCredentialsException
and that kicks us out:
... lines 1 - 12 | |
class TokenController extends BaseController | |
{ | |
... lines 15 - 18 | |
public function newTokenAction(Request $request) | |
{ | |
... lines 21 - 31 | |
if (!$isValid) { | |
throw new BadCredentialsException(); | |
} | |
... lines 35 - 39 | |
} | |
} |
It turns out that doing this this also triggers the entry point. And if you think about it, that makes sense: any time an anonymous user is able to get into your application:
... lines 1 - 17 | |
class JwtTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
... lines 20 - 79 | |
public function start(Request $request, AuthenticationException $authException = null) | |
{ | |
// called when authentication info is missing from a | |
// request that requires it | |
return new JsonResponse([ | |
'error' => 'auth required' | |
], 401); | |
} | |
} |
And then you throw an exception to deny access, that will trigger the entry point. And our entry point is not yet returning the nice API problem structure.
Copy the last four lines from one of the tests in ProgrammerControllerTest
:
... lines 1 - 6 | |
class ProgrammerControllerTest extends ApiTestCase | |
{ | |
... lines 9 - 255 | |
public function test404Exception() | |
{ | |
... lines 258 - 262 | |
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]); | |
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank'); | |
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found'); | |
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'No programmer found with nickname "fake"'); | |
} | |
... lines 268 - 276 | |
} |
And add that to testPostTokenInvalidCredentials()
:
... lines 1 - 6 | |
class TokenControllerTest extends ApiTestCase | |
{ | |
... lines 9 - 22 | |
public function testPOSTTokenInvalidCredentials() | |
{ | |
... lines 25 - 29 | |
$this->assertEquals(401, $response->getStatusCode()); | |
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]); | |
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank'); | |
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Unauthorized'); | |
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'Invalid credentials.'); | |
} | |
} |
The header should be application/problem+json
. The type should be about:blank
: that's what you should use when the status code - 401 here - already fully describes what went wrong. For the title
use Unauthorized
- that's the standard text that always goes with a 401 status code. The ApiProblem
class will actually set that for us: when we pass a null
type, it sets type
to about:blank
and looks up the correct title
.
Finally, for detail
- which is an optional field for an API problem response - use Invalid Credentials.
with a period. I'll show you why we're expecting that in a second.
Head to the JwtTokenAuthenticator
. In start()
, create a new $apiProblem = new ApiProblem()
. Pass it a 401 status code with no type
:
... lines 1 - 18 | |
class JwtTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
... lines 21 - 80 | |
public function start(Request $request, AuthenticationException $authException = null) | |
{ | |
// called when authentication info is missing from a | |
// request that requires it | |
$apiProblem = new ApiProblem(401); | |
... lines 87 - 91 | |
} | |
} |
The detail
key should tell the API client any other information about what went wrong. And check this out: when the start()
method is called, it has an optional $authException
argument. Most of the time, when Symfony calls start()
its because an AuthenticationException
has been thrown. And this class gives us some information about what caused this situation.
And in fact, in TokenController
, we're throwing a BadCredentialsException
, which is a sub-class of AuthenticationException
. Hold command
to look inside the class:
... lines 1 - 19 | |
class BadCredentialsException extends AuthenticationException | |
{ | |
... lines 22 - 24 | |
public function getMessageKey() | |
{ | |
return 'Invalid credentials.'; | |
} | |
} |
It has a getMessageKey()
method set to Invalid Credentials.
: make sure you test matches this string exactly:
... lines 1 - 6 | |
class TokenControllerTest extends ApiTestCase | |
{ | |
... lines 9 - 22 | |
public function testPOSTTokenInvalidCredentials() | |
{ | |
... lines 25 - 33 | |
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'Invalid credentials.'); | |
} | |
} |
The AuthenticationException
- and its sub-classes - are special: each has a getMessageKey()
method that you can safely return to the user to help hint as to what went wrong.
Add $message = $authException ?
$authException->getMessageKey() : 'Missing Credentials';:
... lines 1 - 18 | |
class JwtTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
... lines 21 - 80 | |
public function start(Request $request, AuthenticationException $authException = null) | |
{ | |
// called when authentication info is missing from a | |
// request that requires it | |
$apiProblem = new ApiProblem(401); | |
// you could translate this | |
$message = $authException ? $authException->getMessageKey() : 'Missing credentials'; | |
... lines 89 - 91 | |
} | |
} |
If no $authException
is passed, this is the best message we can return to the client. Finish this with $apiProblem->set('details', $message)
.:
... lines 1 - 82 | |
// called when authentication info is missing from a | |
// request that requires it | |
$apiProblem = new ApiProblem(401); | |
// you could translate this | |
$message = $authException ? $authException->getMessageKey() : 'Missing credentials'; | |
$apiProblem->set('detail', $message); | |
... lines 90 - 94 |
Finally, return a new JsonResponse
with $apiProblem->toArray()
and then a 401:
... lines 1 - 18 | |
class JwtTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
... lines 21 - 80 | |
public function start(Request $request, AuthenticationException $authException = null) | |
{ | |
// called when authentication info is missing from a | |
// request that requires it | |
$apiProblem = new ApiProblem(401); | |
// you could translate this | |
$message = $authException ? $authException->getMessageKey() : 'Missing credentials'; | |
$apiProblem->set('detail', $message); | |
return new JsonResponse($apiProblem->toArray(), 401); | |
} | |
} |
Perfect! Well, not actually perfect, but it's getting close.
Copy the invalid credentials test method and run:
./vendor/bin/phpunit --filter testPOSTTokenInvalidCredentials
It's close! The response looks right, but the Content-Type
header is application/json
instead of the more descriptive application/problem+json
.
Well that's no problem! We just need to set the header inside of the start()
method. But wait! Don't do that! Because we've done all of this work before.
Hey Vladimir Z.!
Haha, I'm aware of this issue now that you've mentioned it! And since you posted, a PR has been opened (I'm actually chatting right now with the author of that PR about it). So, in short, we will hopefully fix the issue soon. But, you should be able to fix it yourself by giving your ApiExceptionSubscriber
a priority of -1 (that will make a bit more sense if you read the issue you linked to). You can see how to add a priority in the phpdoc for the EventSubscriberInterface: https://github.com/symfony/symfony/blob/master/src/Symfony/Component/EventDispatcher/EventSubscriberInterface.php
Cheers!
Hi guys, sorry if I missed something, but I couldn't find.
With JwtTokenAuthenticator working in every end point, what would we do in the end point api/tokens. How could we define a public end point as we need to ask the token first to be used in the others ones ;)
Yo Gisele!
It's a good question :). I'll say two things:
1) JwtTokenAuthenticator works on every endpoint. But, in getCredentials(), if we don't see the Authorization header, then we return null. When this happens, the authenticator doesn't do anything: the request is allowed to continue to your controller anonymously. So, even though the authenticator works on every endpoint, this doesn't mean that every endpoint *requires* a JWT: it simply means that the authenticator is ready to authenticate the user *if* there is a token. If there is no token, the request continues anonymously. Then, it's up to your controller - or access_control in security.yml - to determine whether or not each endpoint does in fact require authentication. If you don't check for a role in either of these places, then that endpoint is public.
2) So, in the case of /api/tokens (where we obviously don't have a JWT yet), this is a public endpoint, and in the controller, we manually check for a username+password combination on the request. If that's there, then we send back the JWT.
Does that help?
Cheers!
weaverryan
Thanks for your explanation, makes total sense. You've been doing a great job.. thanks again ;)
// 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
}
}
Well, unfortunately, with Symfony 3.3 I ran into the issue well-described here: https://github.com/symfony/symfony/issues/23253
<strong>BadCredentialsException</strong> does not trigger the entry point. It simply goes directly to <strong>ApiExceptionSubscriber</strong>, bypassing the <strong>start()</strong> method. Also, instead of an <strong>AuthenticationException</strong> a <strong>DenyAccessException</strong> is thrown, which is neither an <strong>AuthenticationException</strong> nor an <strong>HttpException</strong>.
At this moment, I have added the following code into the <strong>onKernelException</strong> method of <strong>ApiExceptionSubscriber</strong>:
`
# Let Symfony handle all other exceptions
if (!$exception instanceof HttpExceptionInterface) {
}
`
Are you aware of this issue? Any possible solution to it?