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.

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

When 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.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.9.10
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.4
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.0", // v1.7.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.4
    }
}