REST API for decoupled Drupal 8 User registration confirmation

By Ronald van Belzen | December 27, 2018

For decoupled Drupal there already exist REST API's for user login, user logout and user registration (the latter since Drupal 8.3). However, Drupal does not yet deliver REST API's for password reset and user registration confirmation. The password reset API is easy to create, but I will give an example for the API anyway as a starter.

The POST for the password reset expects a JSON in the body that looks like this:

{
    "mail": { "value": "foo@bar.com" }
}

As you can see the mail address is used for the identification of the user to which an e-mail will be send. When you need the ability to (also) use the user login name, you will need to adapt the example below.

The API does not actually reset the password. It sends the user an e-mail that can be used to visit a form at an url where the user can change her or his password (without being logged in).

<?php

namespace Drupal\vlot_api\Plugin\rest\resource;

use Drupal\Core\Session\AccountProxyInterface;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Psr\Log\LoggerInterface;

/**
 * Provides a resource to request the reset of the password for a User account.
 *
 * @RestResource(
 *   id = "password_reset_rest_resource",
 *   label = @Translation("Password Reset Request rest resource"),
 *   uri_paths = {
 *     "create" = "/service/password/reset",
 *   },
 * )
 */
class PasswordResetResource extends ResourceBase {

  /**
   * A current user instance.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $currentUser;

  /**
   * Constructs a Drupal\rest\Plugin\ResourceBase object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param array $serializer_formats
   *   The available serialization formats.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
   *   A current user instance.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    array $serializer_formats,
    LoggerInterface $logger,
    AccountProxyInterface $current_user) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);

    $this->currentUser = $current_user;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->getParameter('serializer.formats'),
      $container->get('logger.factory')->get('vlot_api'),
      $container->get('current_user')
    );
  }

  /**
   * Responds to POST requests.
   *
   * @param array $data
   *
   * @return \Drupal\rest\ResourceResponse
   */
  public function post(array $data) {
    $user = $this->ensureUserCanResetPassword($data);

    _user_mail_notify('password_reset', $user);
    $response = array(
      "uid" => ['value' => $user->id()],
      "status" => ['value' => 'OK'],
    );
    return new ResourceResponse($response);
  }

  /**
   * @param $data
   *
   * @return \Drupal\user\Entity\User
   */
  protected function ensureUserCanResetPassword($data) {
    if (isset($data['mail']['value']) && !empty($data['mail']['value'])) {
      $mail = $data['mail']['value'];
    }
    else {
      throw new BadRequestHttpException('No e-mail address was found in the request.');
    }

    /** @var \Drupal\user\Entity\User $user */
    if ($user = user_load_by_mail($mail)) {
      if ($user->isBlocked()) {
        throw new UnprocessableEntityHttpException('User is blocked.');
      }
      return $user;
    }
    else {
      throw new UnprocessableEntityHttpException('E-mail address not found.');
    }
  }
}

The post() function first visits the ensureUserCanResetPassword() were the e-mail address is validated and is checked whether the user account is blocked. The user account object is used to send the user an e-mail with a link to the url where the password can be reset.

The link in this "Password recovery" mail is of the same type of link that can be found in the "Welcome (new user created by administrator)" and "Welcome (no approval required" mails. In the e-mail templates for these messages the same variable is used to create this link ( [user:one-time-login-url] ). This means that the same API can be used for the link in all three e-mails.

The link leads to a URL in the Drupal 8 back-end that needs to be re-directed to a front-end url. The solution does use an API in the end, but first the user that clicked the link needs to end-up in the front-end with enough information for the front-end to deliver the request to the actual API.

I am going to use dynamic routing tha requires a route subscriber that needs to be subscribed:

services:
  api_route.route_subscriber:
    class: Drupal\api_route\Routing\RouteSubscriber
    tags:
      - { name: event_subscriber }

The actual subscriber just changes the controller for the 'user.reset' route.

<?php
namespace Drupal\api_route\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

/**
* Listens to the dynamic route events.
*/
class RouteSubscriber extends RouteSubscriberBase {

  /**
  * {@inheritdoc}
  */
  protected function alterRoutes(RouteCollection $collection) {
    // Change the password reset/registration confirm
    if ($route = $collection->get('user.reset')) {
      $route->setDefaults(array(
        '_controller' => '\Drupal\api_route\Controller\UserController::resetPass',
      ));
    }
  }

}

This means that the UserController function resetPass() will need to handle the request to reset the password and re-direct this request to the front-end.

<?php

namespace Drupal\api_route\Controller;

use Drupal\Component\Utility\Crypt;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Drupal\user\UserDataInterface;
use Drupal\user\UserStorageInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Drupal\user\Entity\User;

/**
 * Controller routines for user routes.
 */
class UserController extends ControllerBase {

  /**
   * The date formatter service.
   *
   * @var \Drupal\Core\Datetime\DateFormatterInterface
   */
  protected $dateFormatter;

  /**
   * The user storage.
   *
   * @var \Drupal\user\UserStorageInterface
   */
  protected $userStorage;

  /**
   * The user data service.
   *
   * @var \Drupal\user\UserDataInterface
   */
  protected $userData;

  /**
   * A logger instance.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * Constructs a UserController object.
   *
   * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
   *   The date formatter service.
   * @param \Drupal\user\UserStorageInterface $user_storage
   *   The user storage.
   * @param \Drupal\user\UserDataInterface $user_data
   *   The user data service.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   */
  public function __construct(DateFormatterInterface $date_formatter, UserStorageInterface $user_storage, UserDataInterface $user_data, LoggerInterface $logger) {
    $this->dateFormatter = $date_formatter;
    $this->userStorage = $user_storage;
    $this->userData = $user_data;
    $this->logger = $logger;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('date.formatter'),
      $container->get('entity.manager')->getStorage('user'),
      $container->get('user.data'),
      $container->get('logger.factory')->get('user')
    );
  }

  /**
   * Redirects to the user password reset form.
   *
   * In order to never disclose a reset link via a referrer header this
   * controller must always return a redirect response.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   * @param int $uid
   *   User ID of the user requesting reset.
   * @param int $timestamp
   *   The current timestamp.
   * @param string $hash
   *   Login link hash.
   *
   * @return \Symfony\Component\HttpFoundation\RedirectResponse
   *   The redirect response.
   */
  public function resetPass(Request $request, $uid, $timestamp, $hash) {
    $account = $this->currentUser();
    // When processing the one-time login link, we have to make sure that a user
    // isn't already logged in.
    if ($account->isAuthenticated()) {
      // The current user is already logged in.
      if ($account->id() == $uid) {
        user_logout();
        // We need to begin the redirect process again because logging out will
        // destroy the session.
        return $this->redirect(
          'user.reset',
          [
            'uid' => $uid,
            'timestamp' => $timestamp,
            'hash' => $hash,
          ]
        );
      }
      // A different user is already logged in on the computer.
      else {
        /** @var \Drupal\user\UserInterface $reset_link_user */
        if ($reset_link_user = $this->userStorage->load($uid)) {
          drupal_set_message($this->t('Another user (%other_user) is already logged into the site on this computer, but you tried to use a one-time link for user %resetting_user. Please <a href=":logout">log out</a> and try using the link again.',
            ['%other_user' => $account->getUsername(), '%resetting_user' => $reset_link_user->getUsername(), ':logout' => $this->url('user.logout')]), 'warning');
        }
        else {
          // Invalid one-time link specifies an unknown user.
          drupal_set_message($this->t('The one-time login link you clicked is invalid.'), 'error');
        }
        return $this->redirect('<front>');
      }
    }

    $user = User::load($uid);
    $valid = 'no';
    $lastAccess = 0;
    if ($user != NULL) {
      $valid = $this->validateHash($user, $timestamp, $hash);
      $lastAccess = $user->getLastAccessedTime();
    }

    $path = \Drupal::config('api_route')->get('api_register_frontend');
    $query = "/{$uid}/{$timestamp}/{$hash}/{$valid}/{$lastAccess}";

    $url = $path . $query;

    return new TrustedRedirectResponse($url);
  }

  private function validateHash($user, $timestamp, $hash) {
    $timeout = \Drupal::config('user.settings')->get('password_reset_timeout');
    if (time() > ($timestamp + $timeout)) {
      return 'no';
    }
    if (Crypt::hashEquals($hash, user_pass_rehash($user, $timestamp))) {
      return 'yes';
    }
    return 'no';
  }
}

You will notice that the resetPassword() function receives the uid, timestamp and hash that are part of the url path. The code up till the loading of the user account into the variable $user is copied from the core functionality and safe-guards against the user already being logged in its own or another account.

The method always re-routes the request to the front-end, also when the hash does not validate or when the temporary login has expired. These things will be validated again by the actual API that handles the new password that needs to be set, but the front-end is informed about the validity of the link before-hand by the 'valid' parameter. The front-end is also informed whether the user has logged in before or not by passing the last access timestamp of his account, which can be used to display different information to the first time user.

The path to the front-end is defined in the settings.php file as $config['api_route']['api_register_frontend'].

The front-end only needs to pass the new password, uid, timestamp and hash to the API when the user has filled in the form.

However, the REST API can do more then set a new password for the temporary login, it can also change the e-mail address, and it can also do this for a user that is already logged into her or his account. For this reason it expect the following JSON in the body of the POST request:

{
    "uid": { "value": "33" }, 
    "timestamp": { "value": "1511340939"}, 
    "hash": { "value": "9qn4J2HumMJfbwIet3r1IiE-ZMCE81CvnFxjRCDdIhY"}, 
    "pass": { "value": "password"},
    "mail": { "value": "foo@bar.com"}
}

None of the above paramaters is mandatory. When a user is not logged in, however, the parameters uid, timestamp and hash become mandatory.

A user is logged in when he has a valid cookie, and in that case a valid CSRF token needs to be included in the header of the request.

When the parameter pass and/or mail are not included this will not lead to an error. Password and e-mail address are only updated when they are included in the request JSON. This makes this API somewhat flexible and allows the front-end to use this API in multiple ways in different scenarios for changing the password and.or e-mail address of an user account.

<?php

namespace Drupal\my_api\Plugin\rest\resource;

use Drupal\Component\Utility\Crypt;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Drupal\user\Entity\User;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Psr\Log\LoggerInterface;

/**
 * Provides a resource to post User update data.
 *
 * @RestResource(
 *   id = "user_reset_rest_resource",
 *   label = @Translation("User Reset rest resource"),
 *   uri_paths = {
 *     "create" = "/service/user/reset",
 *   },
 * )
 */
class UserResetResource extends ResourceBase {

  /**
   * A current user instance.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $currentUser;

  /**
   * Constructs a Drupal\rest\Plugin\ResourceBase object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param array $serializer_formats
   *   The available serialization formats.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
   *   A current user instance.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    array $serializer_formats,
    LoggerInterface $logger,
    AccountProxyInterface $current_user) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);

    $this->currentUser = $current_user;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->getParameter('serializer.formats'),
      $container->get('logger.factory')->get('vlot_api'),
      $container->get('current_user')
    );
  }

  /**
   * Responds to POST requests.
   *
   * @param array $data
   *
   * @return \Drupal\rest\ResourceResponse
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function post(array $data) {

    if (empty($data)) {
      $request = \Drupal::requestStack()->getCurrentRequest();
      $data = \Drupal\Component\Serialization\Json::decode($request->getContent());
    }

    /** @var \Drupal\user\Entity\User $user */
    $user = $this->ensureUserCanReset($data);

    if (isset($data['pass']['value']) && !empty($data['pass']['value'])) {
      $password = $this->validatePassword($data['pass']['value']);
      $user->setPassword($password);
    }
    if (isset($data['mail']['value']) && !empty($data['mail']['value'])) {
      $mail = $data['mail']['value'];
      if ($mail != $user->getEmail()) {
        if (user_load_by_mail($mail)) {
          throw new UnprocessableEntityHttpException('E-mail address is already in use.');
        }
      }
      $user->setEmail($mail);
    }
    $user->save();

    $response = array(
      "uid" => ['value' => $user->id()],
      "status" => ['value' => 'OK'],
    );

    return new ResourceResponse($response);
  }

  /**
   * @param array $data
   *
   * @return \Drupal\Core\Entity\EntityInterface|null|static
   */
  protected function ensureUserCanReset($data) {
    $current = \Drupal::time()->getRequestTime();
    $timeout = \Drupal::config('user.settings')->get('password_reset_timeout');
    $uid_valid = isset($data['uid']['value']) && !empty($data['uid']['value']);
    $timestamp_valid = isset($data['timestamp']['value']) && !empty($data['timestamp']['value']);
    $hash_valid = isset($data['hash']['value']) && !empty($data['hash']['value']);

    // For anonymous users parameters uid, timestamp and hash need to be
    // validated.
    if ($this->currentUser->isAnonymous()) {
      if ($uid_valid && $timestamp_valid && $hash_valid) {
        $user = User::load($data['uid']['value']);
        if ($user === NULL || !$user->isActive()) {
          throw new AccessDeniedHttpException('User not found.');
        }
        if ($current > ($data['timestamp']['value'] + $timeout)) {
          throw new UnprocessableEntityHttpException('Reset request has expired. Please request a password reset.');
        }
        if (!Crypt::hashEquals($data['hash']['value'], user_pass_rehash($user, $data['timestamp']['value']))) {
          throw new UnprocessableEntityHttpException('Reset request is no longer valid. Please request a password reset.');
        }
      }
      else {
        throw new UnprocessableEntityHttpException('Anonymous user cannot be validated (parameter(s) missing).');
      }
    }
    else {
      $user = User::load($this->currentUser->id());
      if ($user === NULL) {
        throw new AccessDeniedHttpException();
      }
    }

    return $user;
  }

  /**
   * @param string $password
   *
   * @return string
   */
  protected function validatePassword($password) {
    $password = trim($password);
    // Length
    if (strlen($password) < 8) {
      throw new UnprocessableEntityHttpException('Password should be at least 8 characters long.');
    }
    // Uppercase character
    $containsUpper = preg_match('/[A-Z]/', $password);
    if (!$containsUpper) {
      throw new UnprocessableEntityHttpException('Password should contain at least one upper case character.');
    }
    // Lowercase character
    $containsLower = preg_match('/[a-z]/', $password);
    if (!$containsLower) {
      throw new UnprocessableEntityHttpException('Password should contain at least one lower case character.');
    }
    // Special character or decimal
    $containsSpecial = preg_match('/[^a-zA-Z]/', $password);
    if (!$containsSpecial) {
      throw new UnprocessableEntityHttpException('Password should contain at least one special character.');
    }

    return $password;
  }
}

 

Add new comment