commit 66769ad453820cc21856f83d80016d58820bc14c
Author: Jan Kellermann <jan.kellermann@werk21.de>
Date:   Fri Jul 14 13:10:36 2023 +0200

    Patch for #2346389

diff --git a/web/core/modules/user/src/AccountSettingsForm.php b/web/core/modules/user/src/AccountSettingsForm.php
index 1334e0e2..3c879ee0 100644
--- a/web/core/modules/user/src/AccountSettingsForm.php
+++ b/web/core/modules/user/src/AccountSettingsForm.php
@@ -135,6 +135,58 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#default_value' => $config->get('verify_mail'),
       '#description' => $this->t('New users will be required to validate their email address prior to logging into the site, and will be assigned a system-generated password. With this setting disabled, users will be logged in immediately upon registering, and may select their own passwords during registration.'),
     ];
+
+    $form['registration_cancellation']['infotext'] = [
+      '#markup' => '<p><strong>If self registration is enabled you should choose one of the following options or use 3rd party modules or custom code to prevent information disclosure.</strong></p>',
+    ];
+
+    // @todo The states are not working correct - please click on and off to disable correctly.
+    $form['registration_cancellation']['user_register_silent_mode'] = [
+      '#type' => 'checkbox',
+      '#xdisabled' => TRUE,
+      '#title' => $this->t('Show no information about existing mail addresses or usernames while registration.'),
+      '#default_value' => $config->get('user_register_silent_mode'),
+      '#description' => $this->t('If mail address or username are already taken no information will shown but send a mail.'),
+      '#states' => [
+        'enabled' => [
+          [
+            ':input[name="user_register"]' => [
+              ['value' => 'visitors'],
+              'or',
+              ['value' => 'visitors_admin_approval'],
+            ],
+          ],
+          'and',
+          [
+            ':input[name="user_email_verification"]' => ['checked' => FALSE],
+          ],
+        ],
+      ],
+    ];
+
+    $form['registration_cancellation']['user_register_two_step'] = [
+      '#type' => 'checkbox',
+      '#xdisabled' => TRUE,
+      '#title' => $this->t('Enable two step registration to avoid information disclosure.'),
+      '#default_value' => $config->get('user_register_two_step'),
+      '#description' => $this->t('In 1st step user will only enter mail address and consent privacy policy.'),
+      '#states' => [
+        'enabled' => [
+          [
+            ':input[name="user_register"]' => [
+              ['value' => 'visitors'],
+              'or',
+              ['value' => 'visitors_admin_approval'],
+            ],
+          ],
+          'and',
+          [
+            ':input[name="user_email_verification"]' => ['checked' => TRUE],
+          ],
+        ],
+      ],
+    ];
+
     $form['registration_cancellation']['user_password_strength'] = [
       '#type' => 'checkbox',
       '#title' => $this->t('Enable password strength indicator'),
@@ -405,6 +457,8 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
       ->set('register', $form_state->getValue('user_register'))
       ->set('password_strength', $form_state->getValue('user_password_strength'))
       ->set('verify_mail', $form_state->getValue('user_email_verification'))
+      ->set('user_register_silent_mode', $form_state->getValue('user_register_silent_mode'))
+      ->set('user_register_two_step', $form_state->getValue('user_register_two_step'))
       ->set('cancel_method', $form_state->getValue('user_cancel_method'))
       ->set('notify.status_activated', $form_state->getValue('user_mail_status_activated_notify'))
       ->set('notify.status_blocked', $form_state->getValue('user_mail_status_blocked_notify'))
diff --git a/web/core/modules/user/src/Controller/UserController.php b/web/core/modules/user/src/Controller/UserController.php
index 4e4d5e9a..102c9c42 100644
--- a/web/core/modules/user/src/Controller/UserController.php
+++ b/web/core/modules/user/src/Controller/UserController.php
@@ -5,9 +5,11 @@
 use Drupal\Component\Utility\Crypt;
 use Drupal\Component\Utility\Xss;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Language\LanguageInterface;
 use Drupal\Core\Datetime\DateFormatterInterface;
 use Drupal\Core\Flood\FloodInterface;
 use Drupal\Core\Url;
+use Drupal\user\Entity\User;
 use Drupal\user\Form\UserPasswordResetForm;
 use Drupal\user\UserDataInterface;
 use Drupal\user\UserInterface;
@@ -17,6 +19,7 @@
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 
 /**
  * Controller routines for user routes.
@@ -165,6 +168,66 @@ public function resetPass(Request $request, $uid, $timestamp, $hash) {
     );
   }
 
+  /**
+   * Controller for register user.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request.
+   * @param string $token
+   *   The token to get the data from keyvalue store..
+   * @param string $hash
+   *   Hash to decrypt the data from keyvalue store..
+   *
+   * @return Drupal\user\Form\PreregisterForm|Drupal\user\RegisterForme
+   *   Return preregister or register form.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
+   *   If token not found.
+   */
+  public function registerUser(Request $request, $token = FALSE, $hash = FALSE) {
+
+    $verify_mail = \Drupal::config('user.settings')->get('verify_mail');
+    $register_two_step = \Drupal::config('user.settings')->get('user_register_two_step');
+
+    if ($verify_mail && $register_two_step) {
+
+      if (!$token && !$hash) {
+        $user_form = \Drupal::formBuilder()->getForm('Drupal\user\Form\PreregisterForm');
+      }
+      else {
+        // @todo Decrypt data with hash.
+        $data = \Drupal::service('keyvalue.expirable')->get('user')->get($token);
+        if (!$data) {
+          throw new NotFoundHttpException();
+        }
+
+        $lang = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId();
+        $mail = $data['mail'];
+        $username = substr($mail, 0, strrpos($mail, '@'));
+
+        /** @var Drupal\user\UserInterface $entity */
+        $entity = User::create();
+
+        $entity->setEmail($mail);
+        $entity->setUsername($username);
+        $entity->enforceIsNew();
+        $entity->set("init", $mail);
+        $entity->set("langcode", $lang);
+        $entity->set("preferred_langcode", $lang);
+        $entity->set("preferred_admin_langcode", $lang);
+        $entity->activate();
+
+        $user_form = \Drupal::service('entity.form_builder')->getForm($entity, 'register');
+      }
+    }
+    else {
+      $entity = User::create();
+      $user_form = \Drupal::service('entity.form_builder')->getForm($entity, 'register');
+    }
+
+    return $user_form;
+  }
+
   /**
    * Returns the user password reset form.
    *
diff --git a/web/core/modules/user/src/Form/PreregisterForm.php b/web/core/modules/user/src/Form/PreregisterForm.php
new file mode 100644
index 00000000..d0a3309f
--- /dev/null
+++ b/web/core/modules/user/src/Form/PreregisterForm.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace Drupal\user\Form;
+
+use Drupal\Component\Utility\EmailValidatorInterface;
+use Drupal\Core\Config\ConfigFactory;
+use Drupal\Core\Flood\FloodInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\Core\Render\Element\Email;
+use Drupal\Core\TypedData\TypedDataManagerInterface;
+use Drupal\Core\Url;
+use Drupal\user\UserStorageInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a user preregister form.
+ *
+ * First step of registration process.
+ *
+ * @internal
+ */
+class PreregisterForm extends FormBase {
+
+  /**
+   * The user storage.
+   *
+   * @var \Drupal\user\UserStorageInterface
+   */
+  protected $userStorage;
+
+  /**
+   * The language manager.
+   *
+   * @var \Drupal\Core\Language\LanguageManagerInterface
+   */
+  protected $languageManager;
+
+  /**
+   * The flood service.
+   *
+   * @var \Drupal\Core\Flood\FloodInterface
+   */
+  protected $flood;
+
+  /**
+   * The typed data manager.
+   *
+   * @var \Drupal\Core\TypedData\TypedDataManagerInterface
+   */
+  protected $typedDataManager;
+
+  /**
+   * The email validator service.
+   *
+   * @var \Drupal\Component\Utility\EmailValidatorInterface
+   */
+  protected $emailValidator;
+
+  /**
+   * Constructs a UserPasswordForm object.
+   *
+   * @param \Drupal\user\UserStorageInterface $user_storage
+   *   The user storage.
+   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
+   *   The language manager.
+   * @param \Drupal\Core\Config\ConfigFactory $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\Flood\FloodInterface $flood
+   *   The flood service.
+   * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typed_data_manager
+   *   The typed data manager.
+   * @param \Drupal\Component\Utility\EmailValidatorInterface $email_validator
+   *   The email validator service.
+   */
+  public function __construct(UserStorageInterface $user_storage, LanguageManagerInterface $language_manager, ConfigFactory $config_factory, FloodInterface $flood, TypedDataManagerInterface $typed_data_manager, EmailValidatorInterface $email_validator) {
+    $this->userStorage = $user_storage;
+    $this->languageManager = $language_manager;
+    $this->configFactory = $config_factory;
+    $this->flood = $flood;
+    $this->typedDataManager = $typed_data_manager;
+    $this->emailValidator = $email_validator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager')->getStorage('user'),
+      $container->get('language_manager'),
+      $container->get('config.factory'),
+      $container->get('flood'),
+      $container->get('typed_data_manager'),
+      $container->get('email.validator')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'user_preregister';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+
+    $form['mailinfo'] = [
+      '#prefix' => '<p>',
+      '#markup' => $this->t('Please enter your mail address. After submit you will get a mail with the registration link.'),
+      '#suffix' => '</p>',
+    ];
+
+    $form['mail'] = [
+      '#type' => 'email',
+      '#title' => $this->t('email address'),
+      '#size' => 60,
+      '#maxlength' => Email::EMAIL_MAX_LENGTH,
+      '#required' => TRUE,
+      '#attributes' => [
+        'autocorrect' => 'off',
+        'autocapitalize' => 'off',
+        'spellcheck' => 'false',
+        'autofocus' => 'autofocus',
+      ],
+    ];
+
+    $form['consent'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Consent'),
+      '#required' => TRUE,
+      '#description' => 'Make text editable by backend or use new form display for preregister.',
+    ];
+
+    $form['actions'] = ['#type' => 'actions'];
+    $form['actions']['submit'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Submit'),
+    ];
+    $form['#cache']['contexts'][] = 'url.query_args';
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    $flood_config = $this->configFactory->get('user.flood');
+    if (!$this->flood->isAllowed('user.register', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
+      $form_state->setErrorByName('name', $this->t('Too many requests from your IP address. It is temporarily blocked. Try again later or contact the site administrator.'));
+      return;
+    }
+    $this->flood->register('user.register', $flood_config->get('ip_window'));
+
+    $mail = trim($form_state->getValue('mail'));
+    if (!$this->emailValidator->isValid($mail)) {
+      $form_state->setErrorByName('mail', $this->t("The email address is invalid."));
+      return;
+    }
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $mail = trim($form_state->getValue('mail'));
+
+    $data = [
+      'mail' => $mail,
+      'time' => \Drupal::time()->getRequestTime(),
+    ];
+
+    // Generate hash and token.
+    $bytes = 16;
+    $token = bin2hex(random_bytes($bytes));
+    $hash = bin2hex(random_bytes($bytes));
+    // @todo Encrypt data with hash before storing.
+    $data = $data;
+
+    // Store for 24h.
+    \Drupal::service('keyvalue.expirable')->get('user')->setWithExpire($token, $data, 86400);
+
+    // Geerate URL for step 2.
+    $url = Url::fromUri('internal:/user/register/' . $token . '/' . $hash);
+
+    // @todo Send opt in mail.
+    $this->messenger()
+      ->addStatus($this->t('Proof of concept: Instead of sending a mail you was redirected to step 2.'));
+
+    // @todo Do not redirect.
+    $form_state->setRedirectUrl($url);
+  }
+
+}
diff --git a/web/core/modules/user/src/RegisterForm.php b/web/core/modules/user/src/RegisterForm.php
index aefd7d6b..67a5d6c2 100644
--- a/web/core/modules/user/src/RegisterForm.php
+++ b/web/core/modules/user/src/RegisterForm.php
@@ -3,6 +3,7 @@
 namespace Drupal\user;
 
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
 
 /**
  * Form handler for the user register forms.
@@ -52,6 +53,17 @@ public function form(array $form, FormStateInterface $form_state) {
     // Start with the default user account fields.
     $form = parent::form($form, $form_state);
 
+    if (\Drupal::config('user.settings')->get('user_register_two_step')) {
+      // Prepopulate and deactivate mail address.
+      $mail = $account->getEmail();
+      $form['account']['mail']['#default_value'] = $mail;
+      $form['account']['mail']['#disabled'] = TRUE;
+
+      // Prepopulate account name.
+      $username = $account->getAccountName();
+      $form['account']['name']['#default_value'] = $username;
+    }
+
     return $form;
   }
 
@@ -64,6 +76,50 @@ protected function actions(array $form, FormStateInterface $form_state) {
     return $element;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
+
+    if (\Drupal::config('user.settings')->get('user_register_silent_mode')) {
+      // Filter name & mail violation to prevent information disclosure #2346389.
+      $field_names = [
+        'name',
+        'mail',
+      ];
+
+      // Get offsets of violations in list.
+      $offsets = $violations->getFieldNames();
+
+      foreach ($violations->getByFields($field_names) as $violation) {
+        $propertyPath = $violation->getPropertyPath();
+        if (in_array($propertyPath, ['name', 'mail'])) {
+          // Remove violation.
+          $violations->remove(array_search($propertyPath, $offsets));
+        }
+      }
+
+      $count_violations = count($violations->getFieldNames());
+      if (!$form_state->hasAnyErrors() && $count_violations == 0) {
+
+        // Copied from RegisterForm.php#L111.
+        $this->messenger()->addStatus($this->t('A welcome message with further instructions has been sent to your email address.'));
+        $form_state->setRedirect('<front>');
+
+        // Log failed registration.
+        $this->logger('user')->warning('Failed registration for user: %name %email.', ['%name' => $form_state->getValue('name'), '%email' => '<' . $form_state->getValue('mail') . '>']);
+
+        // @todo Use _user_mail_notify() to inform about failed registration, consider flood prevention.
+        // _user_mail_notify();
+        $this->messenger()->addStatus($this->t('Proof of concept: Username or mail address exists - you should get a mail.'));
+        // Reset submit handlers to prevent saving.
+        $form_state->setSubmitHandlers([]);
+      }
+    }
+
+    parent::flagViolations($violations, $form, $form_state);
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -111,12 +167,13 @@ public function save(array $form, FormStateInterface $form_state) {
     if ($admin && !$notify) {
       $this->messenger()->addStatus($this->t('Created a new user account for <a href=":url">%name</a>. No email has been sent.', [':url' => $account->toUrl()->toString(), '%name' => $account->getAccountName()]));
     }
-    // No email verification required; log in user immediately.
-    elseif (!$admin && !\Drupal::config('user.settings')->get('verify_mail') && $account->isActive()) {
+    // No email verification required or two-step; log in user immediately.
+    elseif (!$admin && (!\Drupal::config('user.settings')->get('verify_mail') || \Drupal::config('user.settings')->get('user_register_two_step')) && $account->isActive()) {
       _user_mail_notify('register_no_approval_required', $account);
       user_login_finalize($account);
       $this->messenger()->addStatus($this->t('Registration successful. You are now logged in.'));
       $form_state->setRedirect('<front>');
+      // @todo Remove data from keyvalue store.
     }
     // No administrator approval required.
     elseif ($account->isActive() || $notify) {
diff --git a/web/core/modules/user/user.routing.yml b/web/core/modules/user/user.routing.yml
index d4799178..e0a0daf6 100644
--- a/web/core/modules/user/user.routing.yml
+++ b/web/core/modules/user/user.routing.yml
@@ -1,10 +1,15 @@
 user.register:
-  path: '/user/register'
+  path: '/user/register/{token}/{hash}'
   defaults:
-    _entity_form: 'user.register'
+    _controller: '\Drupal\user\Controller\UserController::registerUser'
     _title: 'Create new account'
+    token: FALSE
+    hash: FALSE
   requirements:
     _access_user_register: 'TRUE'
+    _user_is_logged_in: 'FALSE'
+  options:
+    no_cache: TRUE
 
 user.logout:
   path: '/user/logout'
