Password Management - Change Password - Part 1


In this video we are going to configure the "happy path" for a logged in User updating their password via our RESTful API.

To successfully complete a password update the User must be logged in, and then send in their current password, their new password, and a repeated version of their new password.

Assuming the User completes the above successfully, they should get back a 200 status code, we a short message informing them that their password has successfully updated.

Let's see how this looks in our Behat setup:

# /src/AppBundle/Features/password_change.feature

Feature: Handle password changing via the RESTful API

  In order to provide a more secure system
  As a client software developer
  I need to be able to let users change their current API password

  Background:
    Given there are Users with the following details:
      | id | username | email          | password |
      | 1  | peter    | peter@test.com | testpass |
      | 2  | john     | john@test.org  | johnpass |
     And I set header "Content-Type" with value "application/json"

  Scenario: Can change password with valid credentials
    When I am successfully logged in with username: "peter", and password: "testpass"
     And I send a "POST" request to "/password/1/change" with body:
      """
      {
        "current_password": "testpass",
        "plainPassword": {
          "first": "new password",
          "second": "new password"
        }
      }
      """
    Then the response code should be 200
     And the response should contain "The password has been changed"

Again, we make use of the existing Behat step definitions as we have done throughout the course so far. If unsure on this, please watch this video.

This test will currently be failing as we do not yet have any code in place to handle such things.

Much like in the login and profile videos, we must first start off by telling Symfony about our intended new controller resource:

# /app/config/routing_rest.yml

login:
    type: rest
    resource: AppBundle\Controller\RestLoginController

password_management:
    type: rest
    resource: AppBundle\Controller\RestPasswordManagementController

profile:
    type: rest
    resource: AppBundle\Controller\RestProfileController

And with this in place we can go ahead and create the RestPasswordManagementController controller. Of course you are free to call this controller class any name you like.

I'm going to deviate slightly from the more common route setup we have used throughout this series so far. I want a path similar to:

/password/{userId}/change

Again, you are free to use any convention / path you like here. This works for me, even if it is a little ugly. I want to keep the concept of having the {userId} somewhere in the URI.

To achieve this setup, I will add the password prefix to all my routes by annotating the controller class itself, and then define the more specific parts of the route on a per controller action basis. An example illustrates this better:

<?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 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
{
    /**
     * Change user password
     *
     * @ParamConverter("user", class="AppBundle:User")
     *
     * @Annotations\Post("/{user}/change")
     */
    public function changeAction(Request $request, UserInterface $user)
    {
    }
}

We're going to try and convert the given {user} placeholder into a User entity through the use of a ParamConverter. We've already covered this so I won't go into detail here.

Changing Passwords

As in the Profile videos, the implementation of this controller action already exists for us. It's in the FOSUserBundle code base. Yes, this means we must copy / paste the controller contents into our project, and yes, this is a big draw back.

Here's the converted controller action:

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

/**
 * @Annotations\Prefix("password")
 * @RouteResource("password", pluralize=false)
 */
class RestPasswordManagementController extends FOSRestController implements ClassResourceInterface
{
    /**
     * Change user password
     *
     * @ParamConverter("user", class="AppBundle:User")
     *
     * @Annotations\Post("/{user}/change")
     */
    public function changeAction(Request $request, UserInterface $user)
    {
        if ($user !== $this->getUser()) {
            throw new AccessDeniedHttpException();
        }

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

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

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

        /** @var $formFactory \FOS\UserBundle\Form\Factory\FactoryInterface */
        $formFactory = $this->get('fos_user.change_password.form.factory');

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

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

        /** @var $userManager \FOS\UserBundle\Model\UserManagerInterface */
        $userManager = $this->get('fos_user.user_manager');

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

        $userManager->updateUser($user);

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

        $dispatcher->dispatch(FOSUserEvents::CHANGE_PASSWORD_COMPLETED, new FilterUserResponseEvent($user, $request, $response));

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

There are a few changes here, so let's step through them one by one:

if ($user !== $this->getUser()) {
    throw new AccessDeniedHttpException();
}

As mentioned, we need to ensure that the user is editting their own password only, so here we check that they are indeed who they say they are.

$form = $formFactory->createForm([
    'csrf_protection'    => false
]);

We don't want CSRF protection here, and anyway, it would cause our implementation to break.

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

In the FOSUserBundle implementation, the bulk of the form process lives inside the if statement. In our case we can de-nest this, and simply return the $form if the submitted data is invalid.

FOSRESTBundle will actually convert this object to JSON for us, so we don't need to worry about this beyond simply returning the form.

There is a downside to this - the data returned by the form is not the most front-end friendly thing that you will ever see. It contains all the information needed, but it's a bit of a beast all the same. We will see more on this in a forthcoming series where we build the React-based front end for this back end.

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

Should everything have gone to plan, we don't want to redirect the User to the FOSUserBundle route that is configured inside the Change Password action in FOSUserBundle. This would - of course - completely break our implementation, as we haven't imported their routes.

Instead, we want to do what our test tells us to do - return a 200 status code, and send a helpful message to say things went well.

Now, FOSUserBundle already contains this message - and a whole slew of translations for the same text - so we would be wise to make use of this, even though in this instance we are not really concerning ourselves with localisation.

This is painless enough to do, we just need to grab a hold of the translator service, feed in the translation string, an array of variables our translation string needs (none, in our case), and the translation domain (or group) our translation lives in.

Here we can see we use the translation string of change_password.flash.success, which maps to this line in FOSUserBundle.

The second parameter, [], is an empty array. This would contain our translation string variables, if it used any. An example might be to include our user's name, for personalisation.

And then thirdly, FOSUserBundle is the name of the translation domain where the translator should look for this particular string.

And cool, just like that we have a working implementation of FOSUserBundle's change password functionality via a JSON API.

Code For This Course

Get the code for this course.

Episodes