Password Management - Reset Password - Part 2
In the previous video we covered the initial implementation to allow a User to request a password reset.
The gist of this is that we expect a User to POST
in their username
as JSON. Should we know about that User, we will generate, and mail out an email to that User, containing a link they must click to continue.
Important - as mentioned in the previous video write-up, FOSUserBundle has had a security fix that means we no longer display email details in rendered templates, or response messages. The code in the GitHub repo (link below), and write up reflect this - but the video content does not. We will not need the getObfuscatedEmail
method.
Now, here's a couple of issues we will run in to:
- We need to ensure password reset requests can be initiated by anonymous users - these people can't log in, so we can't expect them to be... logged in :)
- We will need some intermediate step where the User goes to a front-end, whereby they can "staple" the password reset token to their new password credentials.
Ok, so let's tackle the most pressing problem first: allowing anonymous visitors to request a password reset.
This is fairly straightforward, just an update to security.yml
:
# /app/config/security.yml
security:
firewalls:
api_password_reset:
pattern: ^/password/reset
anonymous: true
api:
pattern: ^/
stateless: true
lexik_jwt: ~
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/password/reset, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, role: IS_AUTHENTICATED_FULLY }
I've taken out all but the relevant pieces here.
With that, and whilst our Mailer code is still commented out, our reset request happy path test from the previous video should now be passing:
# /src/AppBundle/Features/password_reset.feature
Feature: Handle password changing via the RESTful API
In order to help users quickly regain access to their account
As a client software developer
I need to be able to let users request a password reset
Background:
Given there are Users with the following details:
| id | username | email | password | confirmation_token |
| 1 | peter | peter@test.com | testpass | |
| 2 | john | john@test.org | johnpass | some-token-string |
And I set header "Content-Type" with value "application/json"
Scenario: Can request a password reset for a valid username
When I send a "POST" request to "/password/reset/request" with body:
"""
{ "username": "peter" }
"""
Then the response code should be 200
And the response should contain "An email has been sent. It contains a link you must click to reset your password."
Of course, as soon as we uncomment out the meddlesome mailer line:
$this->get('fos_user.mailer')->sendResettingEmailMessage($user);
Then our test will break again. D'oh.
One way to fix this is to implement our own Mailer. That's what I am going to do here:
<?php
// /src/AppBundle/Mailer/RestMailer.php
namespace AppBundle\Mailer;
use FOS\UserBundle\Mailer\MailerInterface;
use FOS\UserBundle\Model\UserInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class RestMailer implements MailerInterface
{
protected $mailer;
protected $router;
protected $twig;
protected $parameters;
public function __construct(\Swift_Mailer $mailer, UrlGeneratorInterface $router, \Twig_Environment $twig, array $parameters)
{
$this->mailer = $mailer;
$this->router = $router;
$this->twig = $twig;
$this->parameters = $parameters;
}
public function sendConfirmationEmailMessage(UserInterface $user)
{
$template = $this->parameters['template']['confirmation'];
$url = $this->router->generate('fos_user_registration_confirm', array('token' => $user->getConfirmationToken()), UrlGeneratorInterface::ABSOLUTE_URL);
$context = array(
'user' => $user,
'confirmationUrl' => $url
);
$this->sendMessage($template, $context, $this->parameters['from_email']['confirmation'], $user->getEmail());
}
public function sendResettingEmailMessage(UserInterface $user)
{
$template = $this->parameters['template']['resetting'];
$url = $this->router->generate(
'confirm_password_reset',
['token' => $user->getConfirmationToken()],
UrlGeneratorInterface::ABSOLUTE_URL
);
$context = [
'user' => $user,
'confirmationUrl' => $url
];
$this->sendMessage($template, $context, $this->parameters['from_email']['resetting'], $user->getEmail());
}
/**
* @param string $templateName
* @param array $context
* @param string $fromEmail
* @param string $toEmail
*/
protected function sendMessage($templateName, $context, $fromEmail, $toEmail)
{
$context = $this->twig->mergeGlobals($context);
$template = $this->twig->loadTemplate($templateName);
$subject = $template->renderBlock('subject', $context);
$textBody = $template->renderBlock('body_text', $context);
$htmlBody = $template->renderBlock('body_html', $context);
$message = \Swift_Message::newInstance()
->setSubject($subject)
->setFrom($fromEmail)
->setTo($toEmail);
if (!empty($htmlBody)) {
$message->setBody($htmlBody, 'text/html')
->addPart($textBody, 'text/plain');
} else {
$message->setBody($textBody);
}
$this->mailer->send($message);
}
}
The name RestMailer
is pretty terrible. A better name should be used.
Really this is a copy / paste from the TwigSwiftMailer
provided with FOSUserBundle. All I have done is change up the link to one that points to a controller action I control:
$url = $this->router->generate(
'confirm_password_reset',
['token' => $user->getConfirmationToken()],
UrlGeneratorInterface::ABSOLUTE_URL
);
Actually, for what it's worth, at this stage that route doesn't exist yet, either.
This route is acting a placeholder. It won't work properly without some front-end integration. Here's why:
At the start of the previous video write up we covered the five high level steps that make up a journey down the password reset "happy path". Steps 3 & 4 were as follows:
- Server-side we create a
token
, assign it to the user matching "their username", and return it as JSON - User receives
token
, adds in their new password credentials, andPOST
's to/password/reset/confirm
The thing is, we need a way to give the password reset token
to the User, but we also need to capture the User's newly changed password.
In our case, this will be handled by the front-end. As such we will need to change up this generated route for a route that Symfony doesn't know about. We will hardcode this into parameters.yml
later.
To give a better understanding of this, consider we have two sub-domains: app.site.com
, and api.site.com
.
Let's pretend our app.site.com
site has some modern JS front-end - React, Angular, Vue, whatever.
We go through the motions of requesting a password reset. Symfony does its thing and our User gets associated with a reset token.
This token is emailed to our user - john@site.com
.
John checks his email. It contains a link.
We could have it direct to:
GET api.site.com/password/reset/confirm?some-token-here
But if we do that, not only do we get a lame-looking URL, we also can't send the new password, without also somehow editing the URL... Remember this could be a simple plaintext email containing a link. If it's on our API we either do some funky behaviour to implement a HTML form for this particular end-point (don't do this), or we handle it properly.
Instead, let's assume we want to do this:
POST api.site.com/password/reset/confirm
With a body:
{
"token": "some-token-here",
"plainPassword": {
"first": "new password",
"second": "new password"
}
}
This is more work. We must now send the User from the email link to our app.site.com/confirm/reset/some-token-here
, which can pull the token from the URL, and then put that into the next request payload, and offer the User a repeated password entry form.
Going back to the mailer, you may be wondering why sendConfirmationEmailMessage
still uses a FOSUserBundle controller action in its route generation - the answer is simply because we won't be implementing sign up confirmation in our system so this will never be hit. Feel free to change it as appropriate.
With our RestMailer
(still a terrible name), we must also define it as a Symfony service before we can user it with FOSUserBundle.
# /app/config/services.yml
services:
user.mailer.rest:
class: AppBundle\Mailer\RestMailer
public: false
parent: fos_user.mailer.twig_swift
This seems like as good a time as any to use a parent
service. Simply put, we've copy / pasted from the TwigSwiftMailer
, our constructor has exactly the same signature as the TwigSwiftMailer
, so we can share its dependencies.
With our user.mailer.rest
service set up, we can then override the mailer inside FOSUserBundle to use ours instead:
# /app/config/config.yml
# FOS User
fos_user:
db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel'
firewall_name: api
user_class: AppBundle\Entity\User
from_email:
address: "support@mywebsite.com"
sender_name: "Your Name Here"
service:
mailer: user.mailer.rest
That's enough for the moment. We still have a problem in that our test will fail as it relies on being able to generate a route for confirm_password_reset
, which doesn't exist. We will add this route in the very next video.