API Token Authenticator Part 2!
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWhen the request sends us a valid API token, our authenticator code is working! At least all the way to checkCredentials()
. But before we finish that, I want to see what happens if a client sends us a bad key. So let's see... the last number in the token is six. Let's add a space: that will be enough to mess things up.
Hit send again. Woh! It redirects us to /login
? I did not see that coming.
Sometimes the hardest part of security is figuring out what's happening when something unexpected occurs. So, let's figure out exactly what's going on here.
When authentication fails, this onAuthenticationFailure()
method is called:
// ... lines 1 - 12 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 15 - 54 | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception) | |
{ | |
// todo | |
} | |
// ... lines 59 - 73 | |
} |
Our job is to return a Response
that should be sent back to the client. Right now... we're doing nothing.! So, instead of sending an error back to the user, the request continues like normal to the controller. But, the request is still anonymous. So when it hits our security check in AccountController
, Symfony activates the "entry point", which redirects the user to /login
.
onAuthenticationFailure()
But... that's not what we want at all! If an API client sends a bad API token, we need to tell them! Bad API client! Let's return a new JsonResponse()
with a message
key that describes what went wrong. Earlier, I mentioned that whenever authentication fails - for any reason - it's because, internally, some sort of AuthenticationException
is thrown. That's important because this exception is passed to us as an argument:
// ... lines 1 - 12 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 15 - 54 | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception) | |
{ | |
// ... line 57 | |
} | |
// ... lines 59 - 73 | |
} |
And it has a method - getMessageKey()
- that holds a message about what went wrong. Set the status code to 401:
// ... lines 1 - 5 | |
use Symfony\Component\HttpFoundation\JsonResponse; | |
// ... lines 7 - 13 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 16 - 55 | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception) | |
{ | |
return new JsonResponse([ | |
'message' => $exception->getMessageKey() | |
], 401); | |
} | |
// ... lines 62 - 76 | |
} |
Custom Error Messages with CustomUserMessageAuthenticationException
Let's try it again! Send the request. Yes! A 401 Unauthorized response. But, oh. That message
isn't right at all!
Username could not be found?
This is because Symfony creates a different error message based on where authentication fails inside your authenticator. If you fail to return a User
from getUser()
, you get this "Username could not be found" error.
For our login form, we render this exact messageKey
field in the template. But we also pass it through the translator:
// ... lines 1 - 10 | |
{% block body %} | |
<form class="form-signin" method="post"> | |
{% if error %} | |
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div> | |
{% endif %} | |
// ... lines 16 - 34 | |
</form> | |
{% endblock %} |
That allowed us to translate that into a better message:
"Username could not be found.": "Oh no! It doesn't look like that email exists!" |
We could do the same here: inject the translator service into ApiTokenAuthenticator
and translate the message key. But... hmmm, the message still wouldn't be right - it would use the "It doesn't look like that email exists!" message from the translation file.
No problem: there is a second way to control error messages in an authenticator, and it's super flexible. At any point in your authenticator, you can throw a new CustomUserMessageAuthenticationException()
that will cause authentication to fail and accepts any custom error message you want, like, "Invalid API Token":
// ... lines 1 - 9 | |
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; | |
// ... lines 11 - 14 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 17 - 38 | |
public function getUser($credentials, UserProviderInterface $userProvider) | |
{ | |
// ... lines 41 - 44 | |
if (!$token) { | |
throw new CustomUserMessageAuthenticationException( | |
'Invalid API Token' | |
); | |
} | |
// ... lines 50 - 51 | |
} | |
// ... lines 53 - 79 | |
} |
That's it! This exception will be passed to onAuthenticationFailure()
and its getMessageKey()
method will return that message.
Go back to Postman to try it: send! We got it! So much better!
Checking Token Expiration
Oh, while we're talking about tokens failing, we should definitely check to make sure the token hasn't expired. Inside ApiToken
, we created this nice expiresAt
property:
// ... lines 1 - 9 | |
class ApiToken | |
{ | |
// ... lines 12 - 23 | |
/** | |
* @ORM\Column(type="datetime") | |
*/ | |
private $expiresAt; | |
// ... lines 28 - 60 | |
} |
Go down to the bottom of the class and add a new helper function: isExpired()
that returns a bool
. Return $this->getExpiresAt()
is less than or equal to new \DateTime()
:
// ... lines 1 - 9 | |
class ApiToken | |
{ | |
// ... lines 12 - 61 | |
public function isExpired(): bool | |
{ | |
return $this->getExpiresAt() <= new \DateTime(); | |
} | |
} |
Nice! Back in ApiTokenAuthenticator
, in getUser()
, if $token->isExpired()
, then throw new CustomUserMessageAuthenticationException()
with Token Expired
:
// ... lines 1 - 9 | |
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; | |
// ... lines 11 - 14 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 17 - 38 | |
public function getUser($credentials, UserProviderInterface $userProvider) | |
{ | |
// ... lines 41 - 44 | |
if (!$token) { | |
throw new CustomUserMessageAuthenticationException( | |
'Invalid API Token' | |
); | |
} | |
if ($token->isExpired()) { | |
throw new CustomUserMessageAuthenticationException( | |
'Token expired' | |
); | |
} | |
// ... lines 56 - 57 | |
} | |
// ... lines 59 - 85 | |
} |
We're killin' it! Oh, but, why are we putting this code here and not in checkCredentials()
? Answer: no reason! These two methods are called one after the other and you can really put any code inside either of these methods. Actually, I chose getUser()
just because we have access to the $token
object there.
Head back to Postman. Let's remove that extra space so our API token is valid once again. Send! Success! Now, go back to the ApiToken
class and, temporarily, return true
from isExpired()
so we can see the error:
class ApiToken
{
// ...
public function isExpired(): bool
{
return true;
return $this->getExpiresAt() <= new \DateTime();
}
}
And... send it again! Got it! Token Expired. Remove that dummy code.
onAuthenticationSuccess()
At this point... we're basically done! In checkCredentials()
, there is no password to check. And so, it's perfectly ok for us to return true
:
// ... lines 1 - 14 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 17 - 59 | |
public function checkCredentials($credentials, UserInterface $user) | |
{ | |
return true; | |
} | |
// ... lines 64 - 85 | |
} |
Finally, in onAuthenticationSuccess()
, hmm. What should we do when authentication is successful? With a login form, we redirect the user after success. But with an API token system we, well, want to do... nothing! Yep! We want to allow the request to continue so that it can hit the controller and return the JSON response:
// ... lines 1 - 14 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 17 - 71 | |
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) | |
{ | |
// allow the authentication to continue | |
} | |
// ... lines 76 - 85 | |
} |
start() & supportsRememberMe()
So what about start()
? Because we chose LoginFormAuthenticator
as the entry_point
, this will never be called. To prove it, I'll throw an exception that says:
Not used: entry_point from other authenticator is used:
// ... lines 1 - 14 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 17 - 76 | |
public function start(Request $request, AuthenticationException $authException = null) | |
{ | |
throw new \Exception('Not used: entry_point from other authentication is used'); | |
} | |
// ... lines 81 - 85 | |
} |
And, finally, supportsRememberMe()
. Return false
:
// ... lines 1 - 14 | |
class ApiTokenAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 17 - 81 | |
public function supportsRememberMe() | |
{ | |
return false; | |
} | |
} |
If you return true
from this method, it just means that the "remember me" system is activated and looking for that _remember_me
checkbox to be checked. Because that makes absolutely no sense for an API, just turn it off.
That's it! Find a stranger to high-five! Cheers your coffee with a co-worker! And find Postman! Brace yourself... send! Yes! It executes our controller and we are definitely authenticated because we see the info for spacebar9@example.com
.
People - we now have two valid ways to authenticate in our system! The super cool thing is that, inside of our controller, we don't care which method is used! We just say $this->getUser()
... never caring whether the user was authenticated via the login form or with an API token.
Next: let's set up a registration form and learn how we can manually authenticate the user after success.
I understand this section isn't quite finished yet. But, I need this for a project I am working so I am working my way through it.
I downloaded the code to make sure that everything was the same in security.yaml, SecurityController, ApiTokenAuthenticator, LoginFormAuthenticator, etc.
Everything works except if you send a request with Postman and do not include a Bearer Token (e.g. no auth header, basic auth, etc.).
If you do not include a Bearer Token, the response recieved is the login page code. What I would like to do is return a JsonResponse.
Any ideas about how to do this? I am not getting it to work.
I don't think it matters. But, just in case, I am doing this in a new project using API Platform.
Thanks in advance!