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.