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