Manual Authentication / Registration
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 SubscribeHey! You've made it through almost this entire tutorial! Nice work! I have just a few more tricks to show you before we're done - and they're good ones!
Creating the Registration Form
First, I want to create a registration form. Find your code and open SecurityController
. In addition to login
and logout
, add a new public function register()
:
// ... lines 1 - 8 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 11 - 38 | |
public function register() | |
{ | |
// ... line 41 | |
} | |
} |
Give it a route - /register
and a name: app_register
:
// ... lines 1 - 8 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 11 - 35 | |
/** | |
* @Route("/register", name="app_register") | |
*/ | |
public function register() | |
{ | |
// ... line 41 | |
} | |
} |
Here's the interesting thing about registration. It has nothing to do with security! Think about it. What is registration? It's just a form that creates a new record in the User
table. That's it! That's just database stuff.
So then... why are we even talking about this in a security tutorial? Well... to create the best user experience, there will be just a little bit of security right at the end. Because, after registration, I want to instantly authenticate the new user.
More on that later. Right now, render a template: $this->render('security/register.html.twig')
:
// ... lines 1 - 8 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 11 - 35 | |
/** | |
* @Route("/register", name="app_register") | |
*/ | |
public function register() | |
{ | |
return $this->render('security/register.html.twig'); | |
} | |
} |
Then... I'll cheat: in security/
, copy the login.html.twig
template, paste and call it register.html.twig
:
{% extends 'base.html.twig' %} | |
{% block title %}Login!{% endblock %} | |
{% block stylesheets %} | |
{{ parent() }} | |
<link rel="stylesheet" href="{{ asset('css/login.css') }}"> | |
{% endblock %} | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-sm-12"> | |
<form class="form-signin" method="post"> | |
{% if error %} | |
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div> | |
{% endif %} | |
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1> | |
<label for="inputEmail" class="sr-only">Email address</label> | |
<input type="email" value="{{ last_username }}" name="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus> | |
<label for="inputPassword" class="sr-only">Password</label> | |
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required> | |
<input type="hidden" name="_csrf_token" | |
value="{{ csrf_token('authenticate') }}" | |
> | |
<div class="checkbox mb-3"> | |
<label> | |
<input type="checkbox" name="_remember_me"> Remember me | |
</label> | |
</div> | |
<button class="btn btn-lg btn-primary btn-block" type="submit"> | |
Sign in | |
</button> | |
</form> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
Let's see: change the title, delete the authentication error stuff and I am going to add a little comment here that says that we should replace this with a Symfony form later:
// ... lines 1 - 2 | |
{% block title %}Register!{% endblock %} | |
// ... lines 4 - 10 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-sm-12"> | |
{# todo - replace with a Symfony form! #} | |
<form class="form-signin" method="post"> | |
// ... lines 17 - 30 | |
</form> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
We haven't talked about the form system yet, so I don't want to use it here. But, normally, I would use the form system because it handles validation and automatically adds CSRF protection.
But, to show off how to manually authenticate a user after registration, this HTML form will work beautifully. Change the h1
, remove the value=
on the email
field so that it always starts blank and take out the CSRF token:
// ... lines 1 - 2 | |
{% block title %}Register!{% endblock %} | |
// ... lines 4 - 10 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-sm-12"> | |
{# todo - replace with a Symfony form! #} | |
<form class="form-signin" method="post"> | |
<h1 class="h3 mb-3 font-weight-normal">Register</h1> | |
<label for="inputEmail" class="sr-only">Email address</label> | |
<input type="email" name="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus> | |
<label for="inputPassword" class="sr-only">Password</label> | |
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required> | |
// ... lines 22 - 30 | |
</form> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
We do need CSRF protection on this form... but I'll skip it for now, because we'll refactor this into a Symfony form in a future tutorial.
And finally, hijack the "remember me" checkbox and turn it into a terms box. We'll say:
Agree to terms I for sure read
// ... lines 1 - 2 | |
{% block title %}Register!{% endblock %} | |
// ... lines 4 - 10 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-sm-12"> | |
{# todo - replace with a Symfony form! #} | |
<form class="form-signin" method="post"> | |
<h1 class="h3 mb-3 font-weight-normal">Register</h1> | |
<label for="inputEmail" class="sr-only">Email address</label> | |
<input type="email" name="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus> | |
<label for="inputPassword" class="sr-only">Password</label> | |
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required> | |
<div class="checkbox mb-3"> | |
<label> | |
<input type="checkbox" name="_remember_me" required> Agree to terms I for sure read | |
</label> | |
</div> | |
// ... lines 28 - 30 | |
</form> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
Oh, and update the button: Register:
// ... lines 1 - 2 | |
{% block title %}Register!{% endblock %} | |
// ... lines 4 - 10 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-sm-12"> | |
{# todo - replace with a Symfony form! #} | |
<form class="form-signin" method="post"> | |
<h1 class="h3 mb-3 font-weight-normal">Register</h1> | |
<label for="inputEmail" class="sr-only">Email address</label> | |
<input type="email" name="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus> | |
<label for="inputPassword" class="sr-only">Password</label> | |
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required> | |
<div class="checkbox mb-3"> | |
<label> | |
<input type="checkbox" name="_remember_me" required> Agree to terms I for sure read | |
</label> | |
</div> | |
<button class="btn btn-lg btn-primary btn-block" type="submit"> | |
Register | |
</button> | |
</form> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
Let's see how it looks! Move over, go to /register
and... got it! Logout, then move back over and open up base.html.twig
. Scroll down just a little bit to find the "Login" link. Let's create a second link that points to the new app_register
route. Say, "Register":
<html lang="en"> | |
// ... lines 3 - 15 | |
<body> | |
// ... lines 17 - 22 | |
<nav class="navbar navbar-expand-lg navbar-dark navbar-bg mb-5"> | |
// ... lines 24 - 27 | |
<div class="collapse navbar-collapse" id="navbarNavDropdown"> | |
// ... lines 29 - 40 | |
<ul class="navbar-nav ml-auto"> | |
{% if is_granted('ROLE_USER') %} | |
// ... lines 43 - 58 | |
<li class="nav-item"> | |
<a style="color: #fff;" class="nav-link" href="{{ path('app_register') }}">Register</a> | |
</li> | |
{% endif %} | |
</ul> | |
</div> | |
</nav> | |
// ... lines 66 - 83 | |
</body> | |
</html> |
Move back and check it out. Not bad!
Handing the Registration Submit
Just like with the login
form, because there is no action=
on the form, this will submit right back to the same URL. But, unlike login, because this is just a normal page, we are going to handle that submit logic right inside of the controller.
First, get the Request object by adding an argument with the Request
type hint: the one from HttpFoundation. Below, I'm going to add another reminder to use the Symfony form & validation system later:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
// ... lines 45 - 55 | |
} | |
} |
Then, to only process the data when the form is being submitted, add if ($request->isMethod('POST'))
:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 46 - 52 | |
} | |
// ... lines 54 - 55 | |
} | |
} |
Inside... our job is simple! Registration is nothing more than a mechanism to create a new User
object. So $user = new User()
. Then set some data on it: $user->setEmail($request->request->get('email'))
:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
$user = new User(); | |
$user->setEmail($request->request->get('email')); | |
// ... lines 48 - 52 | |
} | |
// ... lines 54 - 55 | |
} | |
} |
Remember $request->request
is the way that you get $_POST
data. And, the names of the fields on our form are name="email"
and name="password"
. But before we handle the password, add $user->setFirstName()
. This field is required in the database... but, we don't actually have that field on the form. Just use Mystery
for now:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
$user = new User(); | |
$user->setEmail($request->request->get('email')); | |
$user->setFirstName('Mystery'); | |
// ... lines 49 - 52 | |
} | |
// ... lines 54 - 55 | |
} | |
} |
In a real app, I would either add this field to the registration form, or make it nullable
in the database, so it's optional.
Finally, let's set the password. But... of course! We are never ever, ever, ever going to save the plain password. We need to encode it. We already did this inside of UserFixture
:
// ... lines 1 - 7 | |
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; | |
class UserFixture extends BaseFixture | |
{ | |
private $passwordEncoder; | |
public function __construct(UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
$this->passwordEncoder = $passwordEncoder; | |
} | |
protected function loadData(ObjectManager $manager) | |
{ | |
$this->createMany(10, 'main_users', function($i) use ($manager) { | |
// ... lines 22 - 29 | |
$user->setPassword($this->passwordEncoder->encodePassword( | |
$user, | |
'engage' | |
)); | |
// ... lines 34 - 40 | |
}); | |
$this->createMany(3, 'admin_users', function($i) { | |
// ... lines 44 - 48 | |
$user->setPassword($this->passwordEncoder->encodePassword( | |
$user, | |
'engage' | |
)); | |
// ... lines 53 - 54 | |
}); | |
// ... lines 56 - 57 | |
} | |
} |
Ah yes, the key was the UserPasswordEncoderInterface
service. In our controller, add another argument: UserPasswordEncoderInterface
$passwordEncoder
:
// ... lines 1 - 8 | |
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; | |
// ... lines 10 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// ... lines 44 - 55 | |
} | |
} |
Below, we can say $passwordEncoder->encodePassword()
. This needs the User
object and the plain password that was just submitted: $request->request->get('password')
:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 46 - 48 | |
$user->setPassword($passwordEncoder->encodePassword( | |
$user, | |
$request->request->get('password') | |
)); | |
} | |
// ... lines 54 - 55 | |
} | |
} |
We are ready to save! Get the entity manager with $em = $this->getDoctrine()->getManager()
. Then, $em->persist($user)
and $em->flush()
:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 46 - 48 | |
$user->setPassword($passwordEncoder->encodePassword( | |
$user, | |
$request->request->get('password') | |
)); | |
$em = $this->getDoctrine()->getManager(); | |
$em->persist($user); | |
$em->flush(); | |
// ... lines 57 - 58 | |
} | |
// ... lines 60 - 61 | |
} | |
} |
All delightfully boring code. This looks a lot like what we're doing in our fixtures.
Finally, after any successful form submit, we always redirect. Use return $this->redirectToRoute()
. This is the shortcut method that we were looking at earlier. Redirect to the account page: app_account
:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 46 - 48 | |
$user->setPassword($passwordEncoder->encodePassword( | |
$user, | |
$request->request->get('password') | |
)); | |
$em = $this->getDoctrine()->getManager(); | |
$em->persist($user); | |
$em->flush(); | |
return $this->redirectToRoute('app_account'); | |
} | |
// ... lines 60 - 61 | |
} | |
} |
Awesome! Let's give this thing a spin! I'll register as ryan@symfonycasts.com
, password engage
. Agree to the terms that I for sure read and... Register! Bah! That smells like a Ryan mistake! Yep! Use $this->getDoctrine()->getManager()
:
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 46 - 53 | |
$em = $this->getDoctrine()->getManager(); | |
// ... lines 55 - 58 | |
} | |
// ... lines 60 - 61 | |
} | |
} |
That's what I meant to do.
Move over and try this again: ryan@symfonycasts.com
, password engage
, agree to the terms that I read and... Register!
Authentication after Registration
Um... what? We're on the login form? What happened? First, according to the web debug toolbar, we are still anonymous. That makes sense: we registered, but we did not login. After registration, we were redirected to /account
...
// ... lines 1 - 11 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 14 - 41 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 46 - 57 | |
return $this->redirectToRoute('app_account'); | |
} | |
// ... lines 60 - 61 | |
} | |
} |
But because we are not logged in, that sent us here.
This is not the flow that I want my users to experience. Nope, as soon as the user registers, I want to log them in automatically.
Oh, and there's also another problem. Open LoginFormAuthenticator
and find onAuthenticationSuccess()
:
// ... lines 1 - 19 | |
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator | |
{ | |
// ... lines 22 - 74 | |
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) | |
{ | |
if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { | |
return new RedirectResponse($targetPath); | |
} | |
return new RedirectResponse($this->router->generate('app_homepage')); | |
} | |
// ... lines 83 - 87 | |
} |
We added some extra code here to make sure that if the user went to, for example, /admin/comment
as an anonymous user, then, after they log in, they would be sent back to /admin/comment
.
And... hey! I want that same behavior for my registration form! Imagine that you're building a store. As an anonymous user, I add some things to my cart and finally go to /checkout
. But because /checkout
requires me to be logged in, I'm sent to the login form. And because I don't have an account yet, I instead click to register and fill out that form. After submitting, where should I be taken to? That's easy! I should definitely be taken back to /checkout
so I can continue what I was doing!
These two problems - the fact that we want to automatically authenticate the user after registration and redirect them intelligently - can be solved at the same time! After we save the User
to the database, we're basically going to tell Symfony to use our LoginFormAuthenticator
class to authenticate the user and redirect by using its onAuthenticationSuccess()
method.
Check it out: add two arguments to our controller. First, a service called GuardAuthenticatorHandler $guardHandler
. Second, the authenticator that you want to authenticate through: LoginFormAuthenticator $formAuthenticator
:
// ... lines 1 - 5 | |
use App\Security\LoginFormAuthenticator; | |
// ... lines 7 - 10 | |
use Symfony\Component\Security\Guard\GuardAuthenticatorHandler; | |
// ... lines 12 - 13 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 16 - 43 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, LoginFormAuthenticator $formAuthenticator) | |
{ | |
// ... lines 46 - 68 | |
} | |
} |
Once we have those two things, instead of redirecting to a normal route use return $guardHandler->authenticateUserAndHandleSuccess()
:
// ... lines 1 - 13 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 16 - 43 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, LoginFormAuthenticator $formAuthenticator) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 48 - 59 | |
return $guardHandler->authenticateUserAndHandleSuccess( | |
// ... lines 61 - 64 | |
); | |
} | |
// ... lines 67 - 68 | |
} | |
} |
This needs a few arguments: the $user
that's being logged in, the $request
object, the authenticator - $formAuthenticator
and the "provider key". That's just the name of your firewall: main
:
// ... lines 1 - 13 | |
class SecurityController extends AbstractController | |
{ | |
// ... lines 16 - 43 | |
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, LoginFormAuthenticator $formAuthenticator) | |
{ | |
// TODO - use Symfony forms & validation | |
if ($request->isMethod('POST')) { | |
// ... lines 48 - 59 | |
return $guardHandler->authenticateUserAndHandleSuccess( | |
$user, | |
$request, | |
$formAuthenticator, | |
'main' | |
); | |
} | |
// ... lines 67 - 68 | |
} | |
} |
Cool! Let's try it! Click back to register. This time, make sure that you register as a different user, password engage
, agree to the terms, submit and... nice! We're authenticated and sent to the correct place.
Next - we're going to start talking about a very important and very fun feature called "voters". Voters are the way to make more complex access decisions, like, determining that a User can edit this Article because they are its author, but not an Article created by someone else.
Why not just include EntityManagerInterface $em in the params? The only reason I can think is that we don't necessarily need it (if POST isn't submitted).