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:
- User forgets password
- User
POST
's password reset request to/password/reset/request
, containing `{ "username": "their username"} - Server-side we create a
token
, assign it to the user matching "their username", and return it as JSON - User receives
token
, adds in their new password credentials, andPOST
's to/password/reset/confirm
- 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.