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:

  1. 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 :)
  2. 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:

  1. Server-side we create a token, assign it to the user matching "their username", and return it as JSON
  2. User receives token, adds in their new password credentials, and POST'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.

Code For This Course

Get the code for this course.

Episodes