Unverified Commit 70276131 authored by Alex Pott's avatar Alex Pott
Browse files

task: #3581056 Introduce a OneTimeAuthentication service and deprecate user_pass_rehash

By: znerol
By: berdir
By: phenaproxima
By: nicxvan
By: benjifisher
By: heddn
By: godotislate
By: longwave
(cherry picked from commit a38f1d17)
parent 4985bc5a
Loading
Loading
Loading
Loading
Loading
+12 −3
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@
use Drupal\Core\DrupalKernelInterface;
use Drupal\Core\Site\Settings;
use Drupal\user\Entity\User;
use Drupal\user\OneTimeAuthentication;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -183,8 +184,13 @@ protected function openBrowser($url, SymfonyStyle $io) {
   *
   * @return string
   *   The one time login URL for user 1.
   *
   * @deprecated in drupal:11.4.0 and is removed from drupal:12.0.0. There is no
   *   replacement.
   * @see https://www.drupal.org/node/3581062
   */
  protected function getOneTimeLoginUrl() {
    @trigger_error(__METHOD__ . '() is deprecated in drupal:11.4.0 and is removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3581062', E_USER_DEPRECATED);
    $user = User::load(1);
    \Drupal::moduleHandler()->load('user');
    return user_pass_reset_url($user);
@@ -214,14 +220,17 @@ protected function start($host, $port, DrupalKernelInterface $kernel, InputInter
      throw new \RuntimeException('Unable to find the PHP binary.');
    }

    $one_time_authentication = \Drupal::service(OneTimeAuthentication::class);
    $login_url = $one_time_authentication->generateOneTimeLoginUrl(User::load(1), immediate: TRUE);

    $io->writeln("<info>Drupal development server started:</info> <http://{$host}:{$port}>");
    $io->writeln('<info>This server is not meant for production use.</info>');
    $one_time_login = "http://$host:$port{$this->getOneTimeLoginUrl()}/login";
    $io->writeln("<info>One time login url:</info> <$one_time_login>");
    $io->writeln("<info>One time login url:</info> <http://$host:$port{$login_url->toString()}>");
    $io->writeln('Press Ctrl-C to quit the Drupal development server.');

    if (!$input->getOption('suppress-login')) {
      if ($this->openBrowser("$one_time_login?destination=" . urlencode("/"), $io) === 1) {
      $login_url->mergeOptions(['query' => ['destination' => '/']]);
      if ($this->openBrowser("http://$host:$port{$login_url->toString()}", $io) === 1) {
        $io->error('Error while opening up a one time login URL');
      }
    }
+19 −20
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@
use Drupal\Core\Flood\FloodInterface;
use Drupal\Core\Url;
use Drupal\user\Form\UserPasswordResetForm;
use Drupal\user\OneTimeAuthentication;
use Drupal\user\UserDataInterface;
use Drupal\user\UserInterface;
use Drupal\user\UserStorageInterface;
@@ -60,21 +61,10 @@ class UserController extends ControllerBase {
  protected $flood;

  /**
   * 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.
   * @param \Drupal\Core\Flood\FloodInterface $flood
   *   The flood service.
   * @param \Drupal\Component\Datetime\TimeInterface|null $time
   *   The time service.
   * One time authentication service.
   */
  protected OneTimeAuthentication $oneTimeAuthentication;

  public function __construct(
    DateFormatterInterface $date_formatter,
    UserStorageInterface $user_storage,
@@ -82,12 +72,17 @@ public function __construct(
    LoggerInterface $logger,
    FloodInterface $flood,
    protected TimeInterface $time,
    ?OneTimeAuthentication $one_time_authentication = NULL,
  ) {
    $this->dateFormatter = $date_formatter;
    $this->userStorage = $user_storage;
    $this->userData = $user_data;
    $this->logger = $logger;
    $this->flood = $flood;
    if ($one_time_authentication === NULL) {
      @trigger_error('Calling ' . __METHOD__ . '() without the $one_time_authentication argument is deprecated in drupal:11.4.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/node/3581062', E_USER_DEPRECATED);
    }
    $this->oneTimeAuthentication = $one_time_authentication ?? \Drupal::service(OneTimeAuthentication::class);
  }

  /**
@@ -101,6 +96,7 @@ public static function create(ContainerInterface $container) {
      $container->get('logger.factory')->get('user'),
      $container->get('flood'),
      $container->get('datetime.time'),
      $container->get(OneTimeAuthentication::class),
    );
  }

@@ -145,7 +141,7 @@ public function resetPass(Request $request, $uid, $timestamp, $hash) {
      else {
        /** @var \Drupal\user\UserInterface $reset_link_user */
        $reset_link_user = $this->userStorage->load($uid);
        if ($reset_link_user && $this->validatePathParameters($reset_link_user, $timestamp, $hash)) {
        if ($reset_link_user && $this->oneTimeAuthentication->verifyHmac($reset_link_user, $timestamp, $hash)) {
          $this->messenger()
            ->addWarning($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. <a href=":logout">Log out</a> and try using the link again.',
              [
@@ -318,7 +314,7 @@ protected function determineErrorRedirect(?UserInterface $user, int $timestamp,
      $this->messenger()->addError($this->t('You have tried to use a one-time login link that has expired. Request a new one using the form below.'));
      return $this->redirect('user.pass');
    }
    elseif ($user->isAuthenticated() && $this->validatePathParameters($user, $timestamp, $hash, $timeout)) {
    elseif ($user->isAuthenticated() && $this->oneTimeAuthentication->verifyHmac($user, $timestamp, $hash, $timeout)) {
      // The information provided is valid.
      return NULL;
    }
@@ -341,11 +337,14 @@ protected function determineErrorRedirect(?UserInterface $user, int $timestamp,
   *
   * @return bool
   *   Whether the provided data are valid.
   *
   * @deprecated in drupal:11.4.0 and is removed from drupal:12.0.0. Use
   *   \Drupal\user\OneTimeAuthentication::verifyHmac() instead.
   * @see https://www.drupal.org/node/3581062
   */
  protected function validatePathParameters(UserInterface $user, int $timestamp, string $hash, int $timeout = 0): bool {
    $current = \Drupal::time()->getRequestTime();
    $timeout_valid = ((!empty($timeout) && $current - $timestamp < $timeout) || empty($timeout));
    return ($timestamp >= $user->getLastLoginTime()) && $timestamp <= $current && $timeout_valid && hash_equals($hash, user_pass_rehash($user, $timestamp));
    @trigger_error(__METHOD__ . ' is deprecated in drupal:11.4.0 and is removed from drupal:12.0.0. Use \Drupal\user\OneTimeAuthentication::verifyHmac() instead. See https://www.drupal.org/node/3581062', E_USER_DEPRECATED);
    return $this->oneTimeAuthentication->verifyHmac($user, $timestamp, $hash, $timeout);
  }

  /**
@@ -424,7 +423,7 @@ public function confirmCancel(UserInterface $user, $timestamp = 0, $hashed_pass
    $account_data = $this->userData->get('user', $user->id());
    if (isset($account_data['cancel_method']) && !empty($timestamp) && !empty($hashed_pass)) {
      // Validate expiration and hashed password/login.
      if ($user->id() && $this->validatePathParameters($user, $timestamp, $hashed_pass, $timeout)) {
      if ($user->id() && $this->oneTimeAuthentication->verifyHmac($user, $timestamp, $hashed_pass, $timeout)) {
        $edit = [
          'user_cancel_notify' => $account_data['cancel_notify'] ?? $this->config('user.settings')->get('notify.status_canceled'),
        ];
+6 −1
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\user\OneTimeAuthentication;

/**
 * Hook implementations for user.
@@ -232,7 +233,11 @@ public function mail($key, &$message, $params): void {
    $original_language = $language_manager->getConfigOverrideLanguage();
    $language_manager->setConfigOverrideLanguage($language);
    $mail_config = \Drupal::config('user.mail');
    $token_options = ['langcode' => $langcode, 'callback' => 'user_mail_tokens', 'clear' => TRUE];
    $token_options = [
      'langcode' => $langcode,
      'callback' => \Drupal::service(OneTimeAuthentication::class)->tokens(...),
      'clear' => TRUE,
    ];
    $message['subject'] .= PlainTextOutput::renderFromHtml($token_service->replace($mail_config->get($key . '.subject'), $variables, $token_options));
    $message['body'][] = $token_service->replacePlain($mail_config->get($key . '.body'), $variables, $token_options);
    $language_manager->setConfigOverrideLanguage($original_language);
+176 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\user;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Site\Settings;
use Drupal\Core\Url;

/**
 * Generate and verify one time authentication codes.
 *
 * One time authentication codes are used to build a unique and secure URL that
 * is sent to the user by email for purposes such as resetting the user's
 * password. The authentication code includes a timestamp, the user's last login
 * time, the numeric user ID, the user's email address. This data is keyed by
 * the users hashed password and the site's hash salt. All of this data is used
 * to verify the authentication link whenever it is used.
 */
final readonly class OneTimeAuthentication {

  public function __construct(
    protected TimeInterface $time,
    protected LanguageManagerInterface $languageManager,
  ) {
  }

  /**
   * Create a one time authentication code.
   *
   * One time authentication codes are used to build a unique and secure URL
   * that is sent to the user by email for purposes such as resetting the user's
   * password.
   *
   * For a usage example, see
   * \Drupal\user\OneTimeAuthentication::generateCancelConfirmUrl() and
   * \Drupal\user\Controller\UserController::confirmCancel().
   *
   * @param \Drupal\user\UserInterface $account
   *   An object containing the user account.
   * @param int $timestamp
   *   A UNIX timestamp, typically \Drupal::time()->getRequestTime().
   *
   * @return string
   *   A string that is safe for use in URLs and SQL statements.
   */
  public function generateHmac(UserInterface $account, int $timestamp): string {
    $data = $timestamp;
    $data .= ':' . $account->getLastLoginTime();
    $data .= ':' . $account->id();
    $data .= ':' . $account->getEmail();
    return Crypt::hmacBase64($data, Settings::getHashSalt() . $account->getPassword());
  }

  /**
   * Verify a one time authentication code and its timestamp.
   *
   * For a usage example, see
   * \Drupal\user\OneTimeAuthentication::generateCancelConfirmUrl() and
   * \Drupal\user\Controller\UserController::confirmCancel().
   *
   * @param \Drupal\user\UserInterface $account
   *   An account for which to verify the authentication code.
   * @param int $timestamp
   *   The timestamp of the authentication code.
   * @param string $hmac
   *   One time authentication code.
   * @param int $timeout
   *   Expiration timeout of authentication code.
   *
   * @return bool
   *   Whether the provided data are valid.
   */
  public function verifyHmac(UserInterface $account, int $timestamp, string $hmac, int $timeout = 0): bool {
    $current = $this->time->getRequestTime();
    $timeout_valid = ((!empty($timeout) && $current - $timestamp < $timeout) || empty($timeout));
    return ($timestamp >= $account->getLastLoginTime()) && $timestamp <= $current && $timeout_valid && hash_equals($hmac, $this->generateHmac($account, $timestamp));
  }

  /**
   * Generates a unique URL for a user to log in and reset their password.
   *
   * @param \Drupal\user\UserInterface $account
   *   An object containing the user account.
   * @param array $options
   *   (optional) A keyed array of settings. Supported options are:
   *   - langcode: A language code to be used when generating locale-sensitive
   *     URLs. If langcode is NULL the users preferred language is used.
   * @param bool $immediate
   *   Whether or not to perform the login action immediately when the URL is
   *   opened. Defaults to false.
   */
  public function generateOneTimeLoginUrl(UserInterface $account, array $options = [], bool $immediate = FALSE): Url {
    $timestamp = $this->time->getCurrentTime();
    $langcode = $options['langcode'] ?? $account->getPreferredLangcode();
    $routeName = $immediate ? 'user.reset.login' : 'user.reset';
    return Url::fromRoute($routeName,
      [
        'uid' => $account->id(),
        'timestamp' => $timestamp,
        'hash' => $this->generateHmac($account, $timestamp),
      ],
      [
        'absolute' => TRUE,
        'language' => $this->languageManager->getLanguage($langcode),
      ]
    );
  }

  /**
   * Generates a URL to confirm an account cancellation request.
   *
   * @param \Drupal\user\UserInterface $account
   *   The user account object.
   * @param array $options
   *   (optional) A keyed array of settings. Supported options are:
   *   - langcode: A language code to be used when generating locale-sensitive
   *     URLs. If langcode is NULL the users preferred language is used.
   *
   * @see ::tokens()
   * @see \Drupal\user\Controller\UserController::confirmCancel()
   */
  public function generateCancelConfirmUrl(UserInterface $account, array $options = []): Url {
    $timestamp = $this->time->getRequestTime();
    $langcode = $options['langcode'] ?? $account->getPreferredLangcode();
    return Url::fromRoute('user.cancel_confirm',
      [
        'user' => $account->id(),
        'timestamp' => $timestamp,
        'hashed_pass' => $this->generateHmac($account, $timestamp),
      ],
      [
        'absolute' => TRUE,
        'language' => $this->languageManager->getLanguage($langcode),
      ]
    );
  }

  /**
   * Token callback to add unsafe tokens for user notifications.
   *
   * This function is used by \Drupal\Core\Utility\Token::replace() to set up
   * some additional tokens that can be used in notifications generated by
   * user_mail().
   *
   * @param array $replacements
   *   An associative array variable containing mappings from token names to
   *   values (for use with strtr()).
   * @param array $data
   *   An associative array of token replacement values. If the 'user' element
   *   exists, it must contain a user account.
   * @param array $options
   *   A keyed array of settings and flags to control the token replacement
   *   process. See \Drupal\Core\Utility\Token::replace().
   * @param \Drupal\Core\Render\BubbleableMetadata $bubbleableMetadata
   *   Target for adding metadata.
   *
   * @internal
   */
  public function tokens(&$replacements, $data, $options, BubbleableMetadata $bubbleableMetadata): void {
    if (isset($data['user'])) {
      $oneTimeLoginUrl = $this->generateOneTimeLoginUrl($data['user'], $options)->toString(TRUE);
      $bubbleableMetadata->addCacheableDependency($oneTimeLoginUrl);
      $replacements['[user:one-time-login-url]'] = $oneTimeLoginUrl->getGeneratedUrl();

      $cancelConfirmUrl = $this->generateCancelConfirmUrl($data['user'], $options)->toString(TRUE);
      $bubbleableMetadata->addCacheableDependency($cancelConfirmUrl);
      $replacements['[user:cancel-url]'] = $cancelConfirmUrl->getGeneratedUrl();
    }
  }

}
+11 −10
Original line number Diff line number Diff line
@@ -10,10 +10,11 @@
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\node\NodeAccessRebuild;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\node\Traits\NodeAccessTrait;
use Drupal\user\Entity\User;
use Drupal\node\NodeAccessRebuild;
use Drupal\user\OneTimeAuthentication;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

@@ -69,7 +70,7 @@ public function testUserCancelWithoutPermission(): void {

    // Attempt bogus account cancellation request confirmation.
    $timestamp = $account->getLastLoginTime();
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . user_pass_rehash($account, $timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, (int) $timestamp));
    $this->assertSession()->statusCodeEquals(403);
    $user_storage->resetCache([$account->id()]);
    $account = $user_storage->load($account->id());
@@ -155,14 +156,14 @@ public function testUserCancelInvalid(): void {

    // Attempt bogus account cancellation request confirmation.
    $bogus_timestamp = $timestamp + 60;
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$bogus_timestamp/" . user_pass_rehash($account, $bogus_timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$bogus_timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $bogus_timestamp));
    $this->assertSession()->pageTextContains('You have tried to use an account cancellation link that has expired. Request a new one using the form below.');
    $account = $user_storage->load($account->id());
    $this->assertTrue($account->isActive(), 'User account was not canceled.');

    // Attempt expired account cancellation request confirmation.
    $bogus_timestamp = $timestamp - 86400 - 60;
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$bogus_timestamp/" . user_pass_rehash($account, $bogus_timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$bogus_timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $bogus_timestamp));
    $this->assertSession()->pageTextContains('You have tried to use an account cancellation link that has expired. Request a new one using the form below.');
    $account = $user_storage->load($account->id());
    $this->assertTrue($account->isActive(), 'User account was not canceled.');
@@ -200,7 +201,7 @@ public function testUserBlock(): void {
    $this->assertSession()->pageTextContains('A confirmation request to cancel your account has been sent to your email address.');

    // Confirm account cancellation request.
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . user_pass_rehash($account, $timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $timestamp));
    $account = $user_storage->load($account->id());
    $this->assertTrue($account->isBlocked(), 'User has been blocked.');

@@ -255,7 +256,7 @@ public function testUserBlockUnpublish(): void {
    $this->assertSession()->pageTextContains('A confirmation request to cancel your account has been sent to your email address.');

    // Confirm account cancellation request.
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . user_pass_rehash($account, $timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $timestamp));
    // Confirm that the user was redirected to the front page.
    $this->assertSession()->addressEquals('');
    $this->assertSession()->statusCodeEquals(200);
@@ -370,7 +371,7 @@ public function testUserAnonymize(): void {
    $this->assertSession()->pageTextContains('A confirmation request to cancel your account has been sent to your email address.');

    // Confirm account cancellation request.
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . user_pass_rehash($account, $timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $timestamp));
    $this->assertNull($user_storage->load($account->id()), 'User is not found in the database.');

    // Confirm that user's content has been attributed to anonymous user.
@@ -429,7 +430,7 @@ public function testUserAnonymizeBatch(): void {
    $this->assertSession()->pageTextContains('A confirmation request to cancel your account has been sent to your email address.');

    // Confirm account cancellation request.
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . user_pass_rehash($account, $timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $timestamp));
    $this->assertNull($user_storage->load($account->id()), 'User is not found in the database.');

    // Confirm that user's content has been attributed to anonymous user.
@@ -499,7 +500,7 @@ public function testUserDelete(): void {
    $this->assertSession()->pageTextContains('A confirmation request to cancel your account has been sent to your email address.');

    // Confirm account cancellation request.
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . user_pass_rehash($account, $timestamp));
    $this->drupalGet("user/" . $account->id() . "/cancel/confirm/$timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $timestamp));
    $this->assertNull($user_storage->load($account->id()), 'User is not found in the database.');

    // Confirm there's only one session in the database. The user will be logged
@@ -703,7 +704,7 @@ public function testUserAnonymizeTranslations(): void {
    $this->assertSession()->pageTextContains('A confirmation request to cancel your account has been sent to your email address.');

    // Confirm account cancellation request.
    $this->drupalGet('user/' . $account->id() . "/cancel/confirm/$timestamp/" . user_pass_rehash($account, $timestamp));
    $this->drupalGet('user/' . $account->id() . "/cancel/confirm/$timestamp/" . \Drupal::service(OneTimeAuthentication::class)->generateHmac($account, $timestamp));
    $user_storage->resetCache([$account->id()]);
    $this->assertNull($user_storage->load($account->id()), 'User is not found in the database.');

Loading