diff --git a/core/modules/user/config/install/user.mail.yml b/core/modules/user/config/install/user.mail.yml index 382de7a59c..5e9b8ef673 100644 --- a/core/modules/user/config/install/user.mail.yml +++ b/core/modules/user/config/install/user.mail.yml @@ -29,6 +29,12 @@ password_reset: This link can only be used once to log in and will lead you to a page where you can set your password. It expires after one day and nothing will happen if it's not used. -- [site:name] team +mail_change_notification: + body: "[user:display-name],\n\nA request to change your email address has been made at [site:name]. In order to complete the change you will need to follow the instructions sent to your new email address within one day.\n\nIf you did not intend to make this change, contact [site-email]." + subject: 'Email change for [user:display-name] at [site:name]' +mail_change_verification: + body: "[user:display-name],\n\nA request to change your email address has been made at [site:name]. You need to verify the change by clicking on the link below or copying and pasting it in your browser:\n\n[user:mail-change-url]\n\nThis is a one-time URL, so it can be used only once. It expires after one day. If not used, your email address at [site:name] will not change." + subject: 'Email change for [user:display-name] at [site:name]' register_admin_created: subject: 'An administrator created an account for you at [site:name]' body: |- diff --git a/core/modules/user/config/install/user.settings.yml b/core/modules/user/config/install/user.settings.yml index 3232949963..3ae415fa6f 100644 --- a/core/modules/user/config/install/user.settings.yml +++ b/core/modules/user/config/install/user.settings.yml @@ -10,7 +10,10 @@ notify: register_admin_created: true register_no_approval_required: true register_pending_approval: true + mail_change_notification: true + mail_change_verification: true register: visitors cancel_method: user_cancel_block password_reset_timeout: 86400 +mail_change_timeout: 86400 password_strength: true diff --git a/core/modules/user/config/schema/user.schema.yml b/core/modules/user/config/schema/user.schema.yml index b778163bf5..7dc77f3d1a 100644 --- a/core/modules/user/config/schema/user.schema.yml +++ b/core/modules/user/config/schema/user.schema.yml @@ -38,6 +38,12 @@ user.settings: register_pending_approval: type: boolean label: 'Welcome (awaiting approval)' + mail_change_notification: + type: boolean + label: 'Notify user when email changes' + mail_change_verification: + type: boolean + label: 'Require email verification when a user changes their email address' register: type: string label: 'Who can register accounts?' @@ -47,6 +53,9 @@ user.settings: password_reset_timeout: type: integer label: 'Password reset timeout' + mail_change_timeout: + type: integer + label: 'Mail change timeout' password_strength: type: boolean label: 'Enable password strength indicator' @@ -61,6 +70,12 @@ user.mail: password_reset: type: mail label: 'Password recovery' + mail_change_notification: + type: mail + label: 'Change mail notification' + mail_change_verification: + type: mail + label: 'Change mail verification' register_admin_created: type: mail label: 'Account created by administrator' diff --git a/core/modules/user/src/AccountForm.php b/core/modules/user/src/AccountForm.php index c74619fddc..36aa1f978a 100644 --- a/core/modules/user/src/AccountForm.php +++ b/core/modules/user/src/AccountForm.php @@ -309,6 +309,15 @@ public function form(array $form, FormStateInterface $form_state) { return parent::form($form, $form_state, $account); } + /** + * {@inheritdoc} + */ + protected function actions(array $form, FormStateInterface $form_state) { + $actions = parent::actions($form, $form_state); + $actions['submit']['#submit'][] = '::notify'; + return $actions; + } + /** * {@inheritdoc} */ @@ -428,12 +437,59 @@ protected function flagViolations(EntityConstraintViolationListInterface $violat * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { + /** @var \Drupal\user\UserInterface $account */ + $account = $this->getEntity(); + $new_mail = $form_state->getValue('mail'); + $old_mail = $account->getEmail(); + + $own_account = $this->currentUser()->id() === $account->id(); + $skip_verification = !$own_account || $this->currentUser()->hasPermission('administer users'); + if (!$account->isNew() && ($old_mail !== $new_mail) && !$skip_verification) { + // After ::save, which ensures password hashes are updated, send verification emails in ::notify + $form_state->set('mail_change_verification', $new_mail); + $form_state->setValue('mail', $old_mail); + } + parent::submitForm($form, $form_state); - $user = $this->getEntity($form_state); // If there's a session set to the users id, remove the password reset tag // since a new password was saved. - $this->getRequest()->getSession()->remove('pass_reset_' . $user->id()); + $this->getRequest()->getSession()->remove('pass_reset_' . $account->id()); + } + + /** + * Send notifications after the account is updated. + * This callback is called after ::submitForm and ::save complete, so that the user + * entity is guaranteed to be fully updated with any new password hashes. + * This ensures that notifications that contain any URLs using user_pass_rehash are + * valid and are able to use the hashed password that is generated upon save only. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function notify(array &$form, FormStateInterface $form_state) { + $new_mail = $form_state->get('mail_change_verification'); + if ($new_mail !== NULL) { + // Send a verification to the new email address. + /** @var \Drupal\user\UserInterface $account */ + $account = $this->getEntity(); + /** @var \Drupal\user\UserInterface $account_cloned */ + $account_cloned = clone $account; + $account_cloned->setEmail($new_mail); + if (_user_mail_notify('mail_change_verification', $account_cloned) !== NULL) { + // Send notification email to the old email address, if it's set. + if ($account->getEmail()) { + _user_mail_notify('mail_change_notification', $account); + } + $this->messenger()->addWarning($this->t('You must confirm your email address. Further instructions have been sent to your new email address.')); + } else { + // Process change immediately if no verification email is configured. + $account->setEmail($new_mail); + $account->save(); + } + } } } diff --git a/core/modules/user/src/AccountSettingsForm.php b/core/modules/user/src/AccountSettingsForm.php index 1334e0e2c0..303edb45b7 100644 --- a/core/modules/user/src/AccountSettingsForm.php +++ b/core/modules/user/src/AccountSettingsForm.php @@ -171,7 +171,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { ]; // These email tokens are shared for all settings, so just define // the list once to help ensure they stay in sync. - $email_token_help = $this->t('Available variables are: [site:name], [site:url], [user:display-name], [user:account-name], [user:mail], [site:login-url], [site:url-brief], [user:edit-url], [user:one-time-login-url], [user:cancel-url].'); + $email_token_help = $this->t('Available variables are: [site:name], [site:url], [user:display-name], [user:account-name], [user:mail], [site:login-url], [site:url-brief], [user:edit-url], [user:one-time-login-url], [user:mail-change-url], [user:cancel-url].'); $form['email_admin_created'] = [ '#type' => 'details', @@ -272,6 +272,76 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#default_value' => $mail_config->get('password_reset.body'), '#rows' => 12, ]; + $form['mail_change'] = [ + '#type' => 'details', + '#title' => $this->t('Account email changing'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#group' => 'email', + '#weight' => 11, + ]; + $form['mail_change']['mail_change_notification'] = [ + '#type' => 'details', + '#tree' => TRUE, + '#title' => $this->t('Notification of old email'), + '#description' => $this->t("Edit the email message sent to the user's old email address when the email address is changed.") . ' ' . $email_token_help, + '#open' => TRUE, + ]; + $form['mail_change']['mail_change_notification']['enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Notify user when email changes'), + '#default_value' => $config->get('notify.mail_change_notification'), + ]; + $states = [ + 'invisible' => [ + 'input[name="mail_change_notification[enabled]"]' => ['checked' => FALSE], + ], + ]; + $form['mail_change']['mail_change_notification']['subject'] = [ + '#type' => 'textfield', + '#title' => $this->t('Subject'), + '#default_value' => $mail_config->get('mail_change_notification.subject'), + '#maxlength' => 180, + '#states' => $states, + ]; + $form['mail_change']['mail_change_notification']['body'] = [ + '#type' => 'textarea', + '#title' => $this->t('Body'), + '#default_value' => $mail_config->get('mail_change_notification.body'), + '#rows' => 12, + '#states' => $states, + ]; + $form['mail_change']['mail_change_verification'] = [ + '#type' => 'details', + '#tree' => TRUE, + '#title' => $this->t('Verification of new email'), + '#description' => $this->t("Edit the email message sent to user's new email address when the email address is changed.") . ' ' . $email_token_help, + '#open' => TRUE, + ]; + $form['mail_change']['mail_change_verification']['enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Require email verification when a user changes their email address'), + '#default_value' => $config->get('notify.mail_change_verification'), + ]; + $states = [ + 'invisible' => [ + 'input[name="mail_change_verification[enabled]"]' => ['checked' => FALSE], + ], + ]; + $form['mail_change']['mail_change_verification']['subject'] = [ + '#type' => 'textfield', + '#title' => $this->t('Subject'), + '#default_value' => $mail_config->get('mail_change_verification.subject'), + '#maxlength' => 180, + '#states' => $states, + ]; + $form['mail_change']['mail_change_verification']['body'] = [ + '#type' => 'textarea', + '#title' => $this->t('Body'), + '#default_value' => $mail_config->get('mail_change_verification.body'), + '#rows' => 12, + '#states' => $states, + ]; $form['email_activated'] = [ '#type' => 'details', @@ -409,12 +479,20 @@ public function submitForm(array &$form, FormStateInterface $form_state) { ->set('notify.status_activated', $form_state->getValue('user_mail_status_activated_notify')) ->set('notify.status_blocked', $form_state->getValue('user_mail_status_blocked_notify')) ->set('notify.status_canceled', $form_state->getValue('user_mail_status_canceled_notify')) + ->set('notify.mail_change_notification', $form_state->getValue(['mail_change_notification', 'enabled'])) + ->set('notify.mail_change_verification', $form_state->getValue(['mail_change_verification', 'enabled'])) ->save(); + + $form_state->unsetValue(['mail_change_notification', 'enabled']); + $form_state->unsetValue(['mail_change_verification', 'enabled']); + $this->config('user.mail') ->set('cancel_confirm.body', $form_state->getValue('user_mail_cancel_confirm_body')) ->set('cancel_confirm.subject', $form_state->getValue('user_mail_cancel_confirm_subject')) ->set('password_reset.body', $form_state->getValue('user_mail_password_reset_body')) ->set('password_reset.subject', $form_state->getValue('user_mail_password_reset_subject')) + ->set('mail_change_notification', $form_state->getValue('mail_change_notification')) + ->set('mail_change_verification', $form_state->getValue('mail_change_verification')) ->set('register_admin_created.body', $form_state->getValue('user_mail_register_admin_created_body')) ->set('register_admin_created.subject', $form_state->getValue('user_mail_register_admin_created_subject')) ->set('register_no_approval_required.body', $form_state->getValue('user_mail_register_no_approval_required_body')) diff --git a/core/modules/user/src/Controller/MailChangeController.php b/core/modules/user/src/Controller/MailChangeController.php new file mode 100644 index 0000000000..67ec9cf942 --- /dev/null +++ b/core/modules/user/src/Controller/MailChangeController.php @@ -0,0 +1,148 @@ +dateTime = $date_time; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('datetime.time')); + } + + /** + * Returns the user mail change page. + * + * In order to never disclose a mail change link via a referrer header this + * controller must always return a redirect response. + * + * @param \Drupal\user\UserInterface $user + * The user account requesting Email change. + * @param string $new_mail + * The user's new email address. + * @param int $timestamp + * The timestamp when the hash was created. + * @param string $hash + * Unique hash. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * An HTTP response doing a redirect. + */ + public function page(UserInterface $user, string $new_mail, int $timestamp, string $hash) : RedirectResponse { + $timeout = $this->config('user.settings')->get('mail_change_timeout'); + /** @var \Drupal\Core\Session\AccountProxyInterface $current_user */ + $current_user = $this->currentUser(); + $request_time = $this->dateTime->getRequestTime(); + $messenger = $this->messenger(); + + // Other user is authenticated. + if ($current_user->isAuthenticated() && $current_user->id() !== $user->id()) { + $arguments = [ + '%user' => $current_user->getAccountName(), + ':logout' => Url::fromRoute('user.logout')->toString(), + ]; + $messenger->addError($this->t('You are currently logged in as %user, and are attempting to confirm an email address change for another account. Please log out and try using the link again.', $arguments)); + } + // The link has expired. + elseif ($request_time - $timestamp > $timeout) { + $messenger->addError($this->t('You have tried to use an email address change link that has expired. Please visit your account and change your email again.')); + } + // The link is valid. + elseif ($timestamp <= $request_time && $timestamp >= $user->getLastLoginTime() && hash_equals($hash, user_pass_rehash($user, $timestamp, $new_mail))) { + // Save the new email but refresh also the last login time so that this + // mail change link gets expired. + $user->setEmail($new_mail)->setLastLoginTime($request_time)->save(); + /** @var \Drupal\user\UserStorageInterface $user_storage */ + $user_storage = $this->entityTypeManager()->getStorage('user'); + $user_storage->updateLastLoginTimestamp($user); + // Reflect the changes in the session if the user is logged in. + if ($current_user->isAuthenticated() && $current_user->id() === $user->id()) { + $current_user->setAccount($user); + } + $arguments = ['%mail' => $new_mail]; + $messenger->addStatus($this->t('Your email address has been changed to %mail.', $arguments)); + } + // Timestamp from the link is abnormal (in the future) or user registered a + // new login in the meantime or the hash is not valid. + else { + $messenger->addError($this->t('You have tried to use an email address change link that has either been used or is no longer valid. Please visit your account and change your email again.')); + } + + return $this->redirect(''); + } + + /** + * Checks access to change email url. + * + * @param \Drupal\user\UserInterface $user + * The user account requesting Email change. + * + * @return \Drupal\Core\Access\AccessResultInterface + * An access result + */ + public function access(UserInterface $user) { + return AccessResult::allowedIf($user->isActive()); + } + + /** + * Generates a unique URL for a one time mail change confirmation. + * + * @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 int $timestamp + * (optional) The timestamp when hash is created. If missed, the current + * request time is used. + * @param string $hash + * (optional) Unique hash. If missed, the hash is computed based on the + * account data and timestamp. + * + * @return \Drupal\Core\Url + * A unique url that provides a one-time email change confirmation. + */ + public static function getUrl(UserInterface $account, array $options = [], $timestamp = NULL, $hash = NULL) { + $timestamp = $timestamp ?: \Drupal::time()->getRequestTime(); + $langcode = $options['langcode'] ?? $account->getPreferredLangcode(); + $hash = empty($hash) ? user_pass_rehash($account, $timestamp) : $hash; + $url_options = ['absolute' => TRUE, 'language' => \Drupal::service('language_manager')->getLanguage($langcode)]; + return Url::fromRoute('user.mail_change', [ + 'user' => $account->id(), + 'timestamp' => $timestamp, + 'new_mail' => $account->getEmail(), + 'hash' => $hash, + ], $url_options); + } + +} \ No newline at end of file diff --git a/core/modules/user/user.module b/core/modules/user/user.module index 6b2d73332d..0cbd22d9df 100644 --- a/core/modules/user/user.module +++ b/core/modules/user/user.module @@ -25,6 +25,7 @@ use Drupal\image\Plugin\Field\FieldType\ImageItem; use Drupal\filter\FilterFormatInterface; use Drupal\system\Entity\Action; +use Drupal\user\Controller\MailChangeController; use Drupal\user\Entity\Role; use Drupal\user\Entity\User; use Drupal\user\RoleInterface; @@ -581,14 +582,18 @@ function user_cancel_url(UserInterface $account, $options = []) { * @param int $timestamp * A UNIX timestamp, typically \Drupal::time()->getRequestTime(). * + * @param string $mail + * (optional) If passed, this value will be used to compute the hash instead + * of that stored into $account. * @return string * A string that is safe for use in URLs and SQL statements. */ -function user_pass_rehash(UserInterface $account, $timestamp) { +function user_pass_rehash(UserInterface $account, $timestamp, $mail = NULL) { + $mail = $mail ?: $account->getEmail(); $data = $timestamp; $data .= $account->getLastLoginTime(); $data .= $account->id(); - $data .= $account->getEmail(); + $data .= $mail; return Crypt::hmacBase64($data, Settings::getHashSalt() . $account->getPassword()); } @@ -817,6 +822,9 @@ function user_mail($key, &$message, $params) { function user_mail_tokens(&$replacements, $data, $options) { if (isset($data['user'])) { $replacements['[user:one-time-login-url]'] = user_pass_reset_url($data['user'], $options); + if ($data['user']->getEmail()) { + $replacements['[user:mail-change-url]'] = MailChangeController::getUrl($data['user'], $options)->toString(); + } $replacements['[user:cancel-url]'] = user_cancel_url($data['user'], $options); } } @@ -1041,11 +1049,13 @@ function user_role_revoke_permissions($rid, array $permissions = []) { * the $langcode parameter is deprecated in drupal:9.2.0 and is removed from * drupal:10.0.0. Omit the parameter. See https://www.drupal.org/node/3187082 * - * @return array - * An array containing various information about the message. - * See \Drupal\Core\Mail\MailManagerInterface::mail() for details. + * @return array|null + * An array containing various information about the message, as they are + * returned by \Drupal\Core\Mail\MailManagerInterface::mail(), or NULL if the + * notifications for the required operation are disabled. * * @see user_mail_tokens() + * @see \Drupal\Core\Mail\MailManagerInterface::mail() */ function _user_mail_notify($op, AccountInterface $account, $langcode = NULL) { if ($langcode) { diff --git a/core/modules/user/user.post_update.php b/core/modules/user/user.post_update.php index 74f7b5b278..a4e8c9d04f 100644 --- a/core/modules/user/user.post_update.php +++ b/core/modules/user/user.post_update.php @@ -54,6 +54,33 @@ function user_post_update_update_roles(&$sandbox = NULL) { function user_post_update_sort_permissions(&$sandbox = NULL) { } +/** + * Update config for change mail notifications. + */ +function user_post_update_mail_change() { + $config_factory = \Drupal::service('config.factory'); + + $config_factory->getEditable('user.settings') + ->set('notify.mail_change_notification', FALSE) + ->set('notify.mail_change_verification', FALSE) + ->set('mail_change_timeout', 86400) + ->save(); + + $mail_change_notification = [ + 'body' => "[user:display-name],\n\nA request to change your email address has been made at [site:name]. In order to complete the change you will need to follow the instructions sent to your new email address within one day.\n\nIf you did not intend to make this change, contact [site-email].", + 'subject' => 'Email change for [user:display-name] at [site:name]', + ]; + $mail_change_verification = [ + 'body' => "[user:display-name],\n\nA request to change your email address has been made at [site:name]. You need to verify the change by clicking on the link below or copying and pasting it in your browser:\n\n[user:mail-change-url]\n\nThis is a one-time URL, so it can be used only once. It expires after one day. If not used, your email address at [site:name] will not change.", + 'subject' => 'Email change for [user:display-name] at [site:name]', + ]; + + $config_factory->getEditable('user.mail') + ->set('mail_change_notification', $mail_change_notification) + ->set('mail_change_verification', $mail_change_verification) + ->save(); +} + /** * Ensure permissions stored in role configuration are sorted using the schema. */ diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml index d479917845..a813cbcf86 100644 --- a/core/modules/user/user.routing.yml +++ b/core/modules/user/user.routing.yml @@ -242,3 +242,15 @@ user.well-known.change_password: _controller: '\Drupal\user\Controller\UserController::userEditPage' requirements: _user_is_logged_in: 'TRUE' + +user.mail_change: + path: '/user/mail-change/{user}/{new_mail}/{timestamp}/{hash}' + defaults: + _controller: '\Drupal\user\Controller\MailChangeController::page' + _title: 'Change email address' + requirements: + _custom_access: '\Drupal\user\Controller\MailChangeController::access' + user: \d+ + timestamp: \d+ + options: + no_cache: TRUE \ No newline at end of file