Course: Symfony RESTful API: Authentication with JWT (Course 4) Tutorial
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.
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?
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.
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.