Password Management - Reset Password - Part 1


In this video we are going to implement Password Reset functionality for our API users.

This part of the course will be split into the "Requesting" step, and the "Confirmation" step.

The basic flow goes like this:

  1. User forgets password
  2. User POST's password reset request to /password/reset/request, containing `{ "username": "their username"}
  3. Server-side we create a token, assign it to the user matching "their username", and return it as JSON
  4. User receives token, adds in their new password credentials, and POST's to /password/reset/confirm
  5. Server-side the user is checked, and if confirmed, updated

Believe me, these two end-points will do a lot of work over their production lifetime.

Important - As has been pointed out a number of times during this course, the underlying implementation relies on replacing (copy / pasting) the existing FOS User Bundle controller implementations, and creating our own versions that return JSON.

It pains me to have to do this.

FOSUserBundle - at the time of writing - is in version 2.0.0-alpha3, and the changes to 2.0.0-alpha4 are ~50% breaking changes. Even during recording these videos I hit on issues where keeping up to date with FOSUserBundle became quite painful / time consuming. Be aware of what you are doing if you go down this route.

Ok, so big warning out of the way - let's continue.

As ever we started with our Behat feature, and first scenario definiton, which will be the happy path for requesting a password reset:

# /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."

It should be noted that the video content is based on a slightly older version of FOSUserBundle whereby the response would contain the user's email address. This is now considered a potential vulnerability, and has been patched.

The sample above, and the code on GitHub reflects this change.

From there, as we have seen throughout this course, we need to copy / paste the existing FOSUserBundle implementation to our own controller, and doctor accordingly.

Here is the controller after changing to respond as JSON:

<?php

// /src/AppBundle/Controller/RestPasswordManagementController.php

namespace AppBundle\Controller;

use FOS\RestBundle\Controller\Annotations;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\UserBundle\Event\GetResponseNullableUserEvent;
use FOS\UserBundle\Event\GetResponseUserEvent;
use FOS\UserBundle\FOSUserEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @Annotations\Prefix("password")
 * @RouteResource("password", pluralize=false)
 */
class RestPasswordManagementController extends FOSRestController implements ClassResourceInterface
{
    /**
     * @Annotations\Post("/reset/request")
     */
    public function requestResetAction(Request $request)
    {
        $username = $request->request->get('username');

        /** @var $user UserInterface */
        $user = $this->get('fos_user.user_manager')->findUserByUsernameOrEmail($username);

        /** @var $dispatcher EventDispatcherInterface */
        $dispatcher = $this->get('event_dispatcher');

        /* Dispatch init event */
        $event = new GetResponseNullableUserEvent($user, $request);
        $dispatcher->dispatch(FOSUserEvents::RESETTING_SEND_EMAIL_INITIALIZE, $event);

        if (null !== $event->getResponse()) {
            return $event->getResponse();
        }

        if (null === $user) {
            return new JsonResponse(
                'User not recognised',
                JsonResponse::HTTP_FORBIDDEN
            );
        }

        $event = new GetResponseUserEvent($user, $request);
        $dispatcher->dispatch(FOSUserEvents::RESETTING_RESET_REQUEST, $event);

        if (null !== $event->getResponse()) {
            return $event->getResponse();
        }

        if ($user->isPasswordRequestNonExpired($this->container->getParameter('fos_user.resetting.token_ttl'))) {
            return new JsonResponse(
                $this->get('translator')->trans('resetting.password_already_requested', [], 'FOSUserBundle'),
                JsonResponse::HTTP_FORBIDDEN
            );
        }

        if (null === $user->getConfirmationToken()) {
            /** @var $tokenGenerator \FOS\UserBundle\Util\TokenGeneratorInterface */
            $tokenGenerator = $this->get('fos_user.util.token_generator');
            $user->setConfirmationToken($tokenGenerator->generateToken());
        }

        /* Dispatch confirm event */
        $event = new GetResponseUserEvent($user, $request);
        $dispatcher->dispatch(FOSUserEvents::RESETTING_SEND_EMAIL_CONFIRM, $event);

        if (null !== $event->getResponse()) {
            return $event->getResponse();
        }

        $this->get('fos_user.mailer')->sendResettingEmailMessage($user);
        $user->setPasswordRequestedAt(new \DateTime());
        $this->get('fos_user.user_manager')->updateUser($user);

        /* Dispatch completed event */
        $event = new GetResponseUserEvent($user, $request);
        $dispatcher->dispatch(FOSUserEvents::RESETTING_SEND_EMAIL_COMPLETED, $event);

        if (null !== $event->getResponse()) {
            return $event->getResponse();
        }

        return new JsonResponse(
            $this->get('translator')->trans(
                'resetting.check_email',
                [ '%tokenLifetime%' => floor($this->container->getParameter('fos_user.resetting.token_ttl') / 3600) ],
                'FOSUserBundle'
            ),
            JsonResponse::HTTP_OK
        );
    }
}

There's really nothing new in this controller, compared to what we have seen / discussed so far.

There are changes from the video content, and as mentioned, this is because of this security fix.

Now, this code won't work properly without a little more work.

The issue here is that we are relying on the FOSUserBundle mailer to send out an email to the User who just requested a password reset:

$this->get('fos_user.mailer')->sendResettingEmailMessage($user);

And inside that particular method there is a call to the router to generate a route for fos_user_resetting_reset.

Of course, we haven't imported FOSUserBundle's route definitions, so this will fail.

We therefore need to define our own mailer, in which we can create a route that will work for our project, and then email that link out instead.

That's exactly what we shall fix in the next video.

Code For This Course

Get the code for this course.

Episodes