Password Management - Reset Password - Part 3


In the previous video we saw how we could create our own Mailer implementation to override the one provided by FOSUserBundle, allowing us to direct password reset requests to a route we control.

We discussed how we wouldn't actually rely on this implementation in a real world setup, instead we would redirect our User to some front-end code whereby they could "staple" the reset token to their new password and send both in together. If everything matches up then their password would be successfully reset.

In this video we are going to create the confirmation endpoint.

We still need to confirm the reset even if we have the extra step of going by the front end. From the prespective of our API test suite, it doesn't care how the token and new password credentials come to be together, only that they are, and they match up.

Let's look at the test:

# /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 confirm with valid new password
    When I send a "POST" request to "/password/reset/confirm" with body:
      """
      {
        "token": "some-token-string",
        "plainPassword": {
          "first": "new password",
          "second": "new password"
        }
      }
      """
    Then the response code should be 200
    And the response should contain "The password has been reset successfully"
    And I send a "POST" request to "/login" with body:
      """
      {
        "username": "john",
        "password": "new password"
      }
      """
    Then the response code should be 200
    And the response should contain "token"

Again, I have removed the extra lines from this file, only including the parts specific to this video.

Let's start off by covering the controller code:

<?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\FilterUserResponseEvent;
use FOS\UserBundle\Event\GetResponseNullableUserEvent;
use FOS\UserBundle\Event\GetResponseUserEvent;
use FOS\UserBundle\FOSUserEvents;
use FOS\UserBundle\Event\FormEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\User\UserInterface;

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

        if (null === $token) {
            return new JsonResponse('You must submit a token.', JsonResponse::HTTP_BAD_REQUEST);
        }

        /** @var $formFactory \FOS\UserBundle\Form\Factory\FactoryInterface */
        $formFactory = $this->get('fos_user.resetting.form.factory');
        /** @var $userManager \FOS\UserBundle\Model\UserManagerInterface */
        $userManager = $this->get('fos_user.user_manager');
        /** @var $dispatcher \Symfony\Component\EventDispatcher\EventDispatcherInterface */
        $dispatcher = $this->get('event_dispatcher');

        $user = $userManager->findUserByConfirmationToken($token);

        if (null === $user) {
            return new JsonResponse(
            // no translation provided for this in \FOS\UserBundle\Controller\ResettingController
                sprintf('The user with "confirmation token" does not exist for value "%s"', $token),
                JsonResponse::HTTP_BAD_REQUEST
            );
        }

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

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

        $form = $formFactory->createForm([
            'csrf_protection'    => false,
            'allow_extra_fields' => true,
        ]);
        $form->setData($user);
        $form->submit($request->request->all());

        if (!$form->isValid()) {
            return $form;
        }

        $event = new FormEvent($form, $request);
        $dispatcher->dispatch(FOSUserEvents::RESETTING_RESET_SUCCESS, $event);

        $userManager->updateUser($user);

        if (null === $response = $event->getResponse()) {
            return new JsonResponse(
                $this->get('translator')->trans('resetting.flash.success', [], 'FOSUserBundle'),
                JsonResponse::HTTP_OK
            );
        }

        // unsure if this is now needed / will work the same
        $dispatcher->dispatch(FOSUserEvents::RESETTING_RESET_COMPLETED, new FilterUserResponseEvent($user, $request, $response));

        return new JsonResponse(
            $this->get('translator')->trans('resetting.flash.success', [], 'FOSUserBundle'),
            JsonResponse::HTTP_OK
        );
    }
}

The primary differences here are:

  • we want to grab the token value from the incoming request body
  • we want to allow_extra_fields on our form submission

Aside from these two, we have been back through the controller code and updated any HTML rendering sections to instead return JSON, as we have throughout this series so far.

We have also added in references to translated strings from FOSUserBundle, to maintain some semblence of continuity between the standard FOSUserBundle implementation, and this system.

Grabbing the token from the request body shouldn't be any major cause for alarm. We've covered why this is already.

The potentially more confusing part is why we need to allow_extra_fields on our form?

We want to POST in this data:

      {
        "token": "some-token-string",
        "plainPassword": {
          "first": "new password",
          "second": "new password"
        }
      }

We haven't overridden the FOSUserBundle password reset form, which currently looks like this:

<?php

// /vendor/friendsofsymfony/user-bundle/Form/Type/ResettingFormType.php

namespace FOS\UserBundle\Form\Type;

class ResettingFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add(
            'plainPassword', LegacyFormHelper::getType('Symfony\Component\Form\Extension\Core\Type\RepeatedType'),
            // ... snip
        ));
    }

I've cut out a lot of stuff from the ResettingFormType here to highlight one thing:

all we have is the plainPassword field.

If we send in the JSON containing a token AND a plainPassword, then Symfony's form component will give us my least favourite error message:

"This form should not contain extra fields"

We could define our own form. If we did, we could add in the token field, update the FOSUserBundle config, and that would properly solve the problem.

However, we can - more easily - simply allow the extra field(s) and ignore them. That's what I choose to do here, as it is the easiest choice and has - as best I can tell - no noticable downsides in our circumstances.

Code For This Course

Get the code for this course.

Episodes