This tutorial has a new version, check it out!

The "Entry Point" & Multiple Firewalls

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

The authentication system works great! Except for how it behaves when things go wrong. When an API client tries to access a protected endpoint but forgets to send an Authorization header, they're redirected to the login page. But, why?

Here's what's going on. Whenever an anonymous user comes into a Symfony app and tries to access a protected page, Symfony triggers something called an "entry point". Basically, Symfony wants to be super hip and helpful by instructing the user that they need to login. In a traditional HTML form app, that means redirecting the user to the login page.

But in an api, we instruct the API client that credentials are needed by returning a 401 response. So, how can we control this entry point? In Guard authentication, you control it with the start() method.

The start() Method

Return a new JsonResponse and we'll just say error => 'auth required' as a start. Then, set the status code to 401:

... 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);
}
}

To see if it's working, copy the testRequiresAuthentication method name and run that test:

./vendor/bin/phpunit --filter testRequiresAuthentication

Huh, it didn't change anything: we're still redirected to the login page. I thought Symfony was supposed to call our start() method in this situation? So what gives?

One Entry Point per Firewall

Open up security.yml:

security:
... lines 2 - 8
firewalls:
main:
pattern: ^/
anonymous: true
form_login:
# The route name that the login form submits to
check_path: security_login_check
login_path: security_login_form
logout:
# The route name the user can go to in order to logout
path: security_logout
guard:
authenticators:
- 'jwt_token_authenticator'
... lines 24 - 32

Here's the problem: we have a single firewall. When an anonymous request accesses the site and hits a page that requires a valid user, Symfony has to figure out what one thing to do. If this were a traditional app, we should redirect the user to /login. If this were an API, we should return a 401 response. But our app is both: we have an HTML frontend and API endpoints. Symfony doesn't really know what one thing to do.

security:
... lines 2 - 8
firewalls:
main:
... lines 11 - 12
form_login:
# The route name that the login form submits to
check_path: security_login_check
login_path: security_login_form
... lines 17 - 32

The form_login authentication mechanism has a built-in entry point and it is taking priority. Our cute start() entry point function is being totally ignored.

But no worries, you can control this! You could add an entry_point key under your firewall and point to the authenticator service to say "No no no: I want to use my authenticator as the one entry point". But then, our HTML app would break: we still want users on the frontend to be redirected.

Normally, I'm a big advocate of having a single firewall. But this is a perfect use-case for splitting into two firewalls: we really do have two very different authentication systems at work.

Adding the Second Firewall

Above, the main firewall, add a new key called api: the name is not important. And set pattern: ^/api/:

security:
... lines 2 - 8
firewalls:
api:
pattern: ^/api/
... lines 12 - 36

That's a regular expression, so it'll match anything starting with /api/. Oh, and when Symfony boots, it only matches and uses one firewall. Going to /api/something will use the api firewall. Everything else will match the main firewall. And this is exactly what we want.

Add the anonymous key: we may still want some endpoints to not require authentication:

security:
... lines 2 - 8
firewalls:
api:
pattern: ^/api/
anonymous: true
stateless: true
... lines 14 - 36

I'll also add stateless: true. This is kind of cool: it tells Symfony to not store the user in the session. That's perfect: we expect the client to send a valid Authorization header on every request.

Move the guard authenticator up into the api firewall:

security:
... lines 2 - 8
firewalls:
api:
pattern: ^/api/
anonymous: true
stateless: true
guard:
authenticators:
- 'jwt_token_authenticator'
main:
pattern: ^/
anonymous: true
form_login:
# The route name that the login form submits to
check_path: security_login_check
login_path: security_login_form
logout:
# The route name the user can go to in order to logout
path: security_logout
... lines 28 - 36

And that should do it! Now, it will use the start() method from our authenticator.

Give it a try!

./vendor/bin/phpunit –filter testRequiresAuthentication

It passes! Don't rush into having multiple firewalls, but if you have two very different ways of authentication, it could be useful.

Leave a comment!

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
    }
}