This course is archived!
Guard: Joyful Authentication
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.
My favorite new feature for Symfony 2.8 is Guard. It makes creating custom and crazy authentication systems really really easy. I'm a bit biased: Guard was my creation, inspired by a lot of people and projects.
The Weirdest Login Form Ever
Later, I'll do some in-depth screencasts about Guard, but I want to give you a taste of what's possible. In this project, I have a tradition login system:
// ... lines 1 - 21 | |
/** | |
* @Route("/login", name="login") | |
*/ | |
public function sillyLoginAction() | |
{ | |
$error = $this->get('security.authentication_utils') | |
->getLastAuthenticationError(); | |
return $this->render('default/login.html.twig', [ | |
'error' => $error | |
]); | |
} | |
// ... lines 34 - 42 |
The /login
controller renders a login template and this template builds a form
that POSTs right back to that same loginAction
:
// ... lines 1 - 9 | |
<form action="{{ path('login') }}" method="POST"> | |
// ... line 11 | |
<label for="exampleInputEmail1">Username</label> | |
<input type="text" name="_username" class="form-control" id="exampleInputEmail1" placeholder="Username"> | |
// ... lines 14 - 15 | |
<label for="exampleInputPassword1">Password</label> | |
<input type="password" name="_password" class="form-control" | |
id="exampleInputPassword1" placeholder="Password"> | |
// ... lines 19 - 20 | |
<label for="the-answer">The answer to life the universe and everything</label> | |
<input type="text" name="the_answer" class="form-control" id="the-answer"> | |
// ... lines 23 - 25 | |
<input type="checkbox" name="terms"> I agree to want to be logged in | |
// ... lines 27 - 28 | |
<button type="submit" class="btn btn-default">Login</button> | |
</form> | |
// ... lines 31 - 32 |
It has a username field, a password field and then a couple of extra fields. One of them is "the answer to life the universe and everything", which of course must be set to 42. The user also needs to check this "agreement" checkbox.
So how can we make a login system that checks four fields instead of just the usual username and password? Hello Guard.
Before coding, start the built-in web server, which is now bin/console server:run
:
bin/console server:run
Try out /login
.
Creating the Authenticator Class
To use guard, we need a new class. I'll create a new directory called Security
,
but that's not important. Call the class WeirdFormAuthenticator
.Next, make
this class implement GuardAuthenticatorInterface
or extend the slightly easier
AbstractGuardAuthenticator
:
// ... lines 1 - 2 | |
namespace AppBundle\Security; | |
// ... lines 4 - 10 | |
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator; | |
class WeirdFormAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 15 - 42 | |
} |
In PhpStorm, I'll open the generate menu
and select "Implement Methods". Select all of them, including start()
, which is
hiding at the bottom. Also move the start()
method to the bottom of the class:
it'll fit more thematically down there:
// ... lines 1 - 4 | |
use Symfony\Component\HttpFoundation\Request; | |
// ... line 6 | |
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |
use Symfony\Component\Security\Core\Exception\AuthenticationException; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
use Symfony\Component\Security\Core\User\UserProviderInterface; | |
// ... lines 11 - 12 | |
class WeirdFormAuthenticator extends AbstractGuardAuthenticator | |
{ | |
public function getCredentials(Request $request) | |
{ | |
} | |
public function getUser($credentials, UserProviderInterface $userProvider) | |
{ | |
} | |
public function checkCredentials($credentials, UserInterface $user) | |
{ | |
} | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception) | |
{ | |
} | |
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) | |
{ | |
} | |
public function supportsRememberMe() | |
{ | |
} | |
public function start(Request $request, AuthenticationException $authException = null) | |
{ | |
} | |
} |
Your job is simple: fill in each of these methods.
getCredentials()
Once we hook it up, the getCredentials()
method will be called on the authenticator
on every single request. This is your opportunity to collect the username, password,
API token or whatever "credentials" the user is sending in the request.
For this system, we don't need to bother looking for the credentials unless the user
is submitting the login form. Add an if statement: if $request->getPathInfo()
- that's
the current, cleaned URL - != /login
or !$request->isMethod('POST')
, then return
null
:
// ... lines 1 - 28 | |
public function getCredentials(Request $request) | |
{ | |
if ($request->getPathInfo() != '/login' || !$request->isMethod('POST')) { | |
return; | |
} | |
// ... lines 34 - 40 | |
} | |
// ... lines 42 - 101 |
When you return null
from getCredentials()
, no other methods will be called on
the authenticator.
Below that, return an array that contains any credential information we need. Add
a username
key set to $request->request->get('_username')
: that's the name of
the field in the form:
// ... lines 1 - 28 | |
public function getCredentials(Request $request) | |
{ | |
// ... lines 31 - 34 | |
return [ | |
'username' => $request->request->get('_username'), | |
// ... lines 37 - 39 | |
]; | |
} | |
// ... lines 42 - 101 |
Repeat that for password and answer
. In the form, answer's
name is the_answer
and the last is named terms
. Finish the array by fetching
the_answer
and terms
:
// ... lines 1 - 28 | |
public function getCredentials(Request $request) | |
{ | |
// ... lines 31 - 34 | |
return [ | |
'username' => $request->request->get('_username'), | |
'password' => $request->request->get('_password'), | |
'answer' => $request->request->get('the_answer'), | |
'terms' => $request->request->get('terms'), | |
]; | |
} | |
// ... lines 42 - 101 |
The keys on the right are obviously the names of the submitted form fields. The keys on the left can be anything: you'll see how to use them soon.
getUser()
If getCredentials()
returns a non-null value, then getUser()
is called next.
I have a really simple User
entity that has basically just one field: username
:
// ... lines 1 - 11 | |
class User implements UserInterface | |
{ | |
// ... lines 14 - 20 | |
/** | |
* @ORM\Column(type="string") | |
*/ | |
private $username; | |
// ... lines 25 - 54 | |
} |
I'm not even storing a password. Whatever your situation, just make sure you have
a User
class that implements UserInterface
and a "user provider" for it that's
configured in security.yml
:
// ... lines 1 - 2 | |
security: | |
// ... lines 4 - 5 | |
providers: | |
my_entity_users: | |
entity: | |
class: AppBundle:User | |
// ... lines 10 - 31 |
See the $credentials
variable? That's equal to whatever you returned from getCredentials()
.
Set a new $username
variable by grabbing the username
key from it:
// ... lines 1 - 17 | |
class WeirdFormAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... lines 20 - 42 | |
public function getUser($credentials, UserProviderInterface $userProvider) | |
{ | |
$username = $credentials['username']; | |
// ... lines 46 - 53 | |
} | |
// ... lines 55 - 99 | |
} |
Let's get crazy and say: "Hey, if you have a username that starts with an @
symbol,
that's not okay and you can't login". To fail authentication, return null:
// ... lines 1 - 42 | |
public function getUser($credentials, UserProviderInterface $userProvider) | |
{ | |
$username = $credentials['username']; | |
// this looks like a weird username | |
if (substr($username, 0, 1) == '@') { | |
return; | |
} | |
// ... lines 51 - 53 | |
} | |
// ... lines 55 - 101 |
Now, query for the User! We need the entity manager, so add a private $em
property
and generate the constructor. Type-hint it with EntityManager
:
// ... lines 1 - 5 | |
use Doctrine\ORM\EntityManager; | |
// ... lines 7 - 17 | |
class WeirdFormAuthenticator extends AbstractGuardAuthenticator | |
{ | |
private $em; | |
// ... lines 21 - 22 | |
public function __construct(EntityManager $em, RouterInterface $router) | |
{ | |
$this->em = $em; | |
// ... line 26 | |
} | |
// ... lines 28 - 99 | |
} |
Back in getUser()
, this is easy: return $this->em->getRepository('AppBundle:User')->findOneBy()
with username
equals $username
:
// ... lines 1 - 42 | |
public function getUser($credentials, UserProviderInterface $userProvider) | |
{ | |
$username = $credentials['username']; | |
// ... lines 46 - 51 | |
return $this->em->getRepository('AppBundle:User') | |
->findOneBy(['username' => $username]); | |
} | |
// ... lines 55 - 101 |
The job of getUser()
is to return a User object or null to fail authentication.
It's perfect.
checkCredentials()
If getUser()
does return a User, checkCredentials()
is called. This is where
you check if the password is valid or anything else to determine that the authentication
request is legitimate.
And surprise! The $credentials
argument is once again what you returned from getCredentials()
.
Let's check a few things. First, the user doesn't have a password stored in the database.
In my system, the password for everybody is the same: symfony3
. If it doesn't equal
symfony3
, return null
and authentication will fail:
// ... lines 1 - 55 | |
public function checkCredentials($credentials, UserInterface $user) | |
{ | |
if ($credentials['password'] != 'symfony3') { | |
return; | |
} | |
// ... lines 61 - 70 | |
} | |
// ... lines 72 - 101 |
Second, if the answer
does not equal 42, return null
. And finally, if the terms
weren't checked, you guessed it, return null
. Authentication will fail unless checkCredentials()
exactly returns true
. So return that at the bottom:
// ... lines 1 - 55 | |
public function checkCredentials($credentials, UserInterface $user) | |
{ | |
// ... lines 58 - 61 | |
if ($credentials['answer'] != 42) { | |
return; | |
} | |
if (!$credentials['terms']) { | |
return; | |
} | |
return true; | |
} | |
// ... lines 72 - 101 |
Handling Authentication Success and Failure
That's it! The last few methods handle what happens on authentication failure and
success. Both should return a Response
object, or null to do nothing and let the
request continue.
onAuthenticationFailure()
The $exception
passed to onAuthenticationFailure
holds details about what went
wrong:
// ... lines 1 - 72 | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception) | |
{ | |
// ... lines 75 - 79 | |
} | |
// ... lines 81 - 101 |
Was the user not found? Were the credentials wrong? Store this in the session
so we can show it to the user. For the key, use the constant: Security::AUTHENTICATION_ERROR
:
// ... lines 1 - 72 | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception) | |
{ | |
$request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception); | |
// ... lines 76 - 79 | |
} | |
// ... lines 81 - 101 |
This is exactly what the normal form login system does: it adds an error to this same key on the session.
In the controller, the security.authentication.utils
service reads this key from the session
when you call getLastAuthenticationError()
:
// ... lines 1 - 8 | |
class DefaultController extends Controller | |
{ | |
// ... lines 11 - 21 | |
/** | |
* @Route("/login", name="login") | |
*/ | |
public function sillyLoginAction() | |
{ | |
$error = $this->get('security.authentication_utils') | |
->getLastAuthenticationError(); | |
// ... lines 29 - 32 | |
} | |
// ... lines 34 - 40 | |
} |
Other than storing the error, what we really want to do when authentication fails
is redirect back to the login page. To do this, add the Router
as an argument to
the constructor and use that to set a new property. I'll do that with a
shortcut:
// ... lines 1 - 9 | |
use Symfony\Component\Routing\RouterInterface; | |
// ... lines 11 - 17 | |
class WeirdFormAuthenticator extends AbstractGuardAuthenticator | |
{ | |
// ... line 20 | |
private $router; | |
public function __construct(EntityManager $em, RouterInterface $router) | |
{ | |
// ... line 25 | |
$this->router = $router; | |
} | |
// ... lines 28 - 99 | |
} |
Now it's simple: $url = $this->router->generate()
and pass it login
- that's the
route name to my login page. Then, return a new RedirectResponse
:
// ... lines 1 - 72 | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception) | |
{ | |
// ... lines 75 - 76 | |
$url = $this->router->generate('login'); | |
return new RedirectResponse($url); | |
} | |
// ... lines 81 - 101 |
onAuthenticationSuccess()
When authentication works, keep it simple and redirect back to the homepage. In a real app, you'll want to redirect them back to the previous page:
// ... lines 1 - 81 | |
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) | |
{ | |
$url = $this->router->generate('homepage'); | |
return new RedirectResponse($url); | |
} | |
// ... lines 88 - 101 |
There's a base class called AbstractFormLoginAuthenticator
that can help with this.
start() - Inviting the User to Authentication
The start()
method is called when the user tries to access a page that requires
login as an anonymous user. For this situation, redirect them to the login page:
// ... lines 1 - 88 | |
public function start(Request $request, AuthenticationException $authException = null) | |
{ | |
$url = $this->router->generate('login'); | |
return new RedirectResponse($url); | |
} | |
// ... lines 95 - 101 |
supportsRememberMe()
Finally, if you want to be able to support remember me functionality, return true
from supportsRememberMe()
:
// ... lines 1 - 95 | |
public function supportsRememberMe() | |
{ | |
return true; | |
} | |
// ... lines 100 - 101 |
You'll still need to configure the remember_me
key in the firewall.
Configuring the Authenticator
That's it! Now we need to tell Symfony about the authentication with two steps. The
first shouldn't surprise you: register this as a service. In services.yml
create
a new service - how about weird_authenticator
. The class is WeirdFormLoginAuthenticator
and there are two arguments: the entity manager and the router:
// ... lines 1 - 5 | |
services: | |
weird_authenticator: | |
class: AppBundle\Security\WeirdFormAuthenticator | |
arguments: ['@doctrine.orm.entity_manager', '@router'] |
Finally, hook this up in security.yml. Under your firewall, add a guard
key and
an authenticators
key below that. Add one authenticator - weird_authenticator
-
the service name:
// ... lines 1 - 2 | |
security: | |
// ... lines 4 - 10 | |
firewalls: | |
// ... lines 12 - 16 | |
main: | |
anonymous: ~ | |
// ... lines 19 - 21 | |
guard: | |
authenticators: | |
- weird_authenticator | |
// ... lines 25 - 31 |
Now, getCredentials()
will be called on every request. If it returns something
other than null
, getUser()
will be called. If this returns a User
, then
checkCredentials()
will be called. And if this returns true
, authentication
will pass.
Test the Login
Try it out! With a blank form, it says Username could not be found
. It is looking
for the credentials on the request, but it fails on the getUser()
method. I do
have one user in the database: weaverryan
. Put symfony3
for the password but
set the answer to 50. This fails with "Invalid Credential". Finally, fill in everything
correctly. And it works! The web debug toolbar congratulates us: logged in as weaverryan.
Customize the Error Messages
We saw two different error messages, depending on whether authentication failed in
getUser()
or checkCredentials()
. But you can completely control these messages
by throwing a CustomUserMessageAuthenticationException()
. I know, it has a long name,
but it's job is clear: pass it the message you want want to show the user, like:
// ... lines 1 - 12 | |
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; | |
// ... lines 14 - 43 | |
public function getUser($credentials, UserProviderInterface $userProvider) | |
{ | |
// ... lines 46 - 48 | |
if (substr($username, 0, 1) == '@') { | |
throw new CustomUserMessageAuthenticationException( | |
'Starting a username with @ is weird, don\'t you think?' | |
); | |
} | |
// ... lines 54 - 56 | |
} | |
// ... lines 58 - 108 |
Below if the answer
is wrong, do the same thing: throw new CustomUserMessageAuthenticationException()
with:
// ... lines 1 - 58 | |
public function checkCredentials($credentials, UserInterface $user) | |
{ | |
// ... lines 61 - 64 | |
if ($credentials['answer'] != 42) { | |
throw new CustomUserMessageAuthenticationException( | |
'Don\'t you read any books?' | |
); | |
} | |
// ... lines 70 - 77 | |
} | |
// ... lines 79 - 108 |
And in case the terms checkbox isn't checked, throw a new CustomUserMessageAuthenticationException
and threaten them to agree to our terms!
// ... lines 1 - 58 | |
public function checkCredentials($credentials, UserInterface $user) | |
{ | |
// ... lines 61 - 70 | |
if (!$credentials['terms']) { | |
throw new CustomUserMessageAuthenticationException( | |
'Agree to our terms!!!' | |
); | |
} | |
return true; | |
} | |
// ... lines 79 - 108 |
Try it out with @weaverryan
. There's the first message! Try 50 for the answer:
there's the second message. And if you fill in everything right but have no checkbox,
you see the final message.
That's Guard authentication: create one class and do whatever the heck that you want.
I showed a login form, but it's even easier to use for API authentication. Basically,
authentication is not hard anymore. Invent whatever insane system you want and give
the user exactly the message you want. If you want to return JSON
on authentication
failure and success instead of redirecting, awesome, do it.
Hello,
I am implementing custom authentication with guard end everything works fine until I create password encoding. To encode password I use doctrine event listeners (I pass @security.password_encoder as a servcie argument) and I encode password this way
And then inside FormLoginAuthenticator I check password in checkCredentials method
And here i have the problem becasue it alwys return false. I tried to expermient with password encoding and i did dump() of
$this->userPasswordEncoder->encodePassword($user, 'admin123'); a couple of times and it always returned different string. I wonder what I am doing wrong?
When I get rid of password encoding and i save plain password in datase authentication works fine, except the fact that in symfony toolbar i see:
Loggedin: admin
Authenticated: No
What might be the reason of this?