diff --git a/core/modules/user/config/install/user.mail.yml b/core/modules/user/config/install/user.mail.yml index f8e41ce..6b215b5 100644 --- a/core/modules/user/config/install/user.mail.yml +++ b/core/modules/user/config/install/user.mail.yml @@ -4,6 +4,12 @@ cancel_confirm: password_reset: body: "[user:display-name],\n\nA request to reset the password for your account has been made at [site:name].\n\nYou may now log in by clicking this link or copying and pasting it into your browser:\n\n[user:one-time-login-url]\n\nThis 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.\n\n-- [site:name] team" subject: 'Replacement login information for [user:display-name] at [site:name]' +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." + subject: 'Email change information 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-login-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 information for [user:display-name] at [site:name]' register_admin_created: body: "[user:display-name],\n\nA site administrator at [site:name] has created an account for you. You may now log in by clicking this link or copying and pasting it into your browser:\n\n[user:one-time-login-url]\n\nThis link can only be used once to log in and will lead you to a page where you can set your password.\n\nAfter setting your password, you will be able to log in at [site:login-url] in the future using:\n\nusername: [user:name]\npassword: Your password\n\n-- [site:name] team" subject: 'An administrator created an account for you at [site:name]' diff --git a/core/modules/user/config/install/user.settings.yml b/core/modules/user/config/install/user.settings.yml index 8372ccd..44e0ccf 100644 --- a/core/modules/user/config/install/user.settings.yml +++ b/core/modules/user/config/install/user.settings.yml @@ -3,6 +3,8 @@ verify_mail: true notify: cancel_confirm: true password_reset: true + mail_change_notification: true + mail_change_verification: true status_activated: true status_blocked: false status_canceled: false diff --git a/core/modules/user/config/schema/user.schema.yml b/core/modules/user/config/schema/user.schema.yml index 627d8a6..d027c7b 100644 --- a/core/modules/user/config/schema/user.schema.yml +++ b/core/modules/user/config/schema/user.schema.yml @@ -20,6 +20,12 @@ user.settings: password_reset: type: boolean label: 'Notify user when password reset' + 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' status_activated: type: boolean label: 'Notify user when account is activated' @@ -61,6 +67,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 8b0149e..c51b78b 100644 --- a/core/modules/user/src/AccountForm.php +++ b/core/modules/user/src/AccountForm.php @@ -386,13 +386,35 @@ 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(); + + if (!$account->isNew() && ($old_mail !== $new_mail) && !$this->currentUser()->hasPermission('administer users')) { + + // Send a verification to the new email address. + $account_cloned = clone $account; + $account_cloned->setEmail($new_mail); + if (_user_mail_notify('mail_change_verification', $account_cloned)) { + // Send notification email to the old email address. + $account->setEmail($old_mail); + _user_mail_notify('mail_change_notification', $account); + } + + // The user's email address will be updated after verification. + $form_state->setValue('mail', $old_mail); + + drupal_set_message($this->t('Your new email address needs to be validated. Further instructions have been sent to your new email address.'), 'warning'); + } + 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. - if (isset($_SESSION['pass_reset_'. $user->id()])) { - unset($_SESSION['pass_reset_'. $user->id()]); + if (isset($_SESSION['pass_reset_' . $account->id()])) { + unset($_SESSION['pass_reset_' . $account->id()]); } } + } diff --git a/core/modules/user/src/AccountSettingsForm.php b/core/modules/user/src/AccountSettingsForm.php index b76897d..90cc974 100644 --- a/core/modules/user/src/AccountSettingsForm.php +++ b/core/modules/user/src/AccountSettingsForm.php @@ -201,7 +201,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-login-url], [user:cancel-url].'); $form['email_admin_created'] = array( '#type' => 'details', @@ -303,6 +303,50 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#rows' => 12, ); + $form['email_email_change_notification'] = array( + '#type' => 'details', + '#title' => $this->t('Email change notification'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#description' => $this->t("Edit the email message sent to the user's old email address when the email address is changed.") . ' ' . $email_token_help, + '#group' => 'email', + '#weight' => 11, + ); + $form['email_email_change_notification']['user_email_change_notification_subject'] = array( + '#type' => 'textfield', + '#title' => $this->t('Subject'), + '#default_value' => $mail_config->get('mail_change_notification.subject'), + '#maxlength' => 180, + ); + $form['email_email_change_notification']['user_email_change_notification_body'] = array( + '#type' => 'textarea', + '#title' => $this->t('Body'), + '#default_value' => $mail_config->get('mail_change_notification.body'), + '#rows' => 12, + ); + + $form['email_email_change_verification'] = array( + '#type' => 'details', + '#title' => $this->t('Email change verification'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#description' => $this->t("Edit the email message sent to user's new email address when the email address is changed.") . ' ' . $email_token_help, + '#group' => 'email', + '#weight' => 13, + ); + $form['email_email_change_verification']['user_email_change_verification_subject'] = array( + '#type' => 'textfield', + '#title' => $this->t('Subject'), + '#default_value' => $mail_config->get('mail_change_verification.subject'), + '#maxlength' => 180, + ); + $form['email_email_change_verification']['user_email_change_verification_body'] = array( + '#type' => 'textarea', + '#title' => $this->t('Body'), + '#default_value' => $mail_config->get('mail_change_verification.body'), + '#rows' => 12, + ); + $form['email_activated'] = array( '#type' => 'details', '#title' => $this->t('Account activation'), @@ -445,6 +489,10 @@ public function submitForm(array &$form, FormStateInterface $form_state) { ->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.subject', $form_state->getValue('user_email_change_notification_subject')) + ->set('mail_change_notification.body', $form_state->getValue('user_email_change_notification_body')) + ->set('mail_change_verification.subject', $form_state->getValue('user_email_change_verification_subject')) + ->set('mail_change_verification.body', $form_state->getValue('user_email_change_verification_body')) ->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/ChangeEmailController.php b/core/modules/user/src/Controller/ChangeEmailController.php new file mode 100644 index 0000000..7c0e834 --- /dev/null +++ b/core/modules/user/src/Controller/ChangeEmailController.php @@ -0,0 +1,141 @@ +entityTypeManager()->getStorage('user')->load($uid); + + // We need to set the new email here to validate the hash correctly, which + // is created using the new mail adress. We only save the account if the + // hash matches and the user is active. + $timeout = 24 * 60 * 60; + + if ($account->isActive() && $timestamp < REQUEST_TIME) { + if (REQUEST_TIME - $timestamp > $timeout) { + drupal_set_message($this->t('You have tried to use a one-time email address change link for %account that has expired -- your change of email address was not completed. Please visit your account edit page if you wish to attempt the change again.', ['%account' => $account->getAccountName()]), 'error'); + } + elseif (!$this->currentUser()->isAnonymous() && ($this->currentUser()->id() != $account->id())) { + drupal_set_message($this->t('You are currently logged in as %user, and are attempting to confirm an email address change for %account, which is not allowed. Please log in as %account and initiate a new change of email request.', ['%user' => $this->currentUser()->getAccountName(), '%account' => $account->getAccountName()]), 'error'); + } + else { + $account->setEmail($new_mail); + $account->save(); + drupal_set_message($this->t('Your email address has been changed to %mail.', ['%mail' => $new_mail])); + } + } + else { + // Deny access if the timestamp passed is in the future. + throw new AccessDeniedHttpException(); + } + + return $this->redirect('user.page'); + } + + /** + * Generates a unique URL for a one time email 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. + * @param string $hash + * (optional) Unique hash. + * + * @return \Drupal\Core\Url + * A unique URL that provides a one-time email change confirmation for the + * user. + */ + public static function getUrl(UserInterface $account, array $options = [], $timestamp = REQUEST_TIME, $hash = NULL) { + $langcode = isset($options['langcode']) ? $options['langcode'] : $account->getPreferredLangcode(); + $hash = empty($hash) ? self::getHash($account, $timestamp) : $hash; + $url_options = ['absolute' => TRUE, 'language' => \Drupal::getContainer()->get('language_manager')->getLanguage($langcode)]; + return Url::fromRoute('user.change_email', [ + 'uid' => $account->id(), + 'timestamp' => $timestamp, + 'new_mail' => $account->getEmail(), + 'hash' => $hash, + ], $url_options); + } + + /** + * Generates hash for change mail URL. + * + * @param \Drupal\user\UserInterface $account + * An object containing the user account. + * @param int $timestamp + * The timestamp when hash is created. + * + * @return string + * A new generated hash. + */ + public static function getHash(UserInterface $account, $timestamp) { + $data = $timestamp; + $data .= $account->id(); + $data .= $account->getEmail(); + $data .= $account->getCreatedTime(); + $data .= $account->getInitialEmail(); + return Crypt::hmacBase64($data, Settings::getHashSalt() . $account->getPassword()); + } + + /** + * Checks access to change email URL. + * + * @param int $uid + * The ID of the user requesting reset. + * @param int $timestamp + * The timestamp when hash is created. + * @param string $hash + * Unique hash. + * @param string $new_mail + * A new user email. + * + * @return \Drupal\Core\Access\AccessResultInterface + * An access result + */ + public function access($uid, $timestamp, $hash, $new_mail) { + /** @var \Drupal\user\UserInterface $account */ + $account = $this->entityTypeManager()->getStorage('user')->load($uid); + $account->setEmail($new_mail); + return AccessResult::allowedIf($hash == $this->getHash($account, $timestamp)); + } + +} diff --git a/core/modules/user/src/Tests/UserChangeEmailTest.php b/core/modules/user/src/Tests/UserChangeEmailTest.php new file mode 100644 index 0000000..8a5a951 --- /dev/null +++ b/core/modules/user/src/Tests/UserChangeEmailTest.php @@ -0,0 +1,166 @@ +account = $this->drupalCreateUser(); + } + + /** + * Tests email change functionality. + */ + public function testEmailChange() { + // Create a user. + $this->drupalLogin($this->account); + + // Save a new email and verify that the user was sent an email. + $new_mail = $this->getRandomEmailAddress(); + $edit = [ + 'mail' => $new_mail, + 'current_pass' => $this->account->pass_raw, + ]; + $this->drupalPostForm('user/' . $this->account->id() . '/edit', $edit, t('Save')); + + // Take email settings from user.mail.yml. + $mail_settings = $this->config('user.mail'); + + /** @var \Drupal\Core\Utility\Token $token_service */ + $token_service = $this->container->get('token'); + // Verify that the user was sent a notification email. + $this->assertMail('to', $this->account->getEmail()); + $subject = $token_service->replace($mail_settings->get('mail_change_notification.subject'), ['user' => $this->account]); + $this->assertMail('subject', $subject); + + // Verify that the user was sent a verification email. + $this->assertMailString('to', $new_mail, 2); + $subject = $token_service->replace($mail_settings->get('mail_change_verification.subject'), ['user' => $this->account]); + $this->assertMailString('subject', $subject, 2); + + $change_mail_url = $this->gerUrlEmail('user_mail_change_verification'); + + // Check that the email has been successfully updated. + $this->drupalGet($change_mail_url); + $this->assertRaw(t('Your email address has been changed to %mail.', ['%mail' => $new_mail])); + + $this->drupalGet($change_mail_url); + $this->assertFalse($this->drupalGetHeader('X-Drupal-Cache')); + + // Ensure the change email URL is not cached. + $this->drupalGet($change_mail_url); + $this->assertFalse($this->drupalGetHeader('X-Drupal-Cache')); + } + + /** + * Tests change of email for blocked users. + */ + public function testEmailChangeForBlockedUser() { + $timestamp = REQUEST_TIME - 1; + $account_cloned = $this->account; + $account_cloned->block(); + $account_cloned->save(); + $this->drupalGet(ChangeEmailController::getUrl($account_cloned, [], $timestamp)->getInternalPath()); + $this->assertResponse(403); + } + + /** + * Tests change of email for expired timestamp. + */ + public function testEmailChangeExpiredTimestamp() { + $timestamp = REQUEST_TIME - 30 * 60 * 60 * 10; + $this->drupalGet(ChangeEmailController::getUrl($this->account, [], $timestamp)->getInternalPath()); + $this->assertRaw(t('You have tried to use a one-time email address change link for %account that has expired -- your change of email address was not completed. Please visit your account edit page if you wish to attempt the change again.', ['%account' => $this->account->getAccountName()])); + } + + /** + * Tests change of email when other user is logged in. + */ + public function testEmailImpersonation() { + $timestamp = REQUEST_TIME - 1; + // Create other account and login with it. + $current_account = $this->drupalCreateUser(); + $this->drupalLogin($current_account); + // Try to change the email for the first account when the other account is + // logged in. + $this->drupalGet(ChangeEmailController::getUrl($this->account, [], $timestamp)->getInternalPath()); + $this->assertRaw(t('You are currently logged in as %user, and are attempting to confirm an email address change for %account, which is not allowed. Please log in as %account and initiate a new change of email request.', ['%user' => $current_account->getAccountName(), '%account' => $this->account->getAccountName()])); + } + + /** + * Tests change of email for timestamp in the future. + */ + public function testEmailChangeForFutureTimestamp() { + $timestamp = REQUEST_TIME + 30 * 60 * 60; + $this->drupalGet(ChangeEmailController::getUrl($this->account, [], $timestamp)->getInternalPath()); + $this->assertResponse(403); + } + + /** + * Tests change of email with the wrong hash. + */ + public function testEmailChangeWithWrongHash() { + $timestamp = REQUEST_TIME - 1; + // Generate the hash for other user. + $other_account = $this->drupalCreateUser(); + $hash = ChangeEmailController::getHash($other_account, $timestamp); + $this->drupalGet(ChangeEmailController::getUrl($this->account, [], $timestamp, $hash)->getInternalPath()); + $this->assertResponse(403); + } + + /** + * Retrieves the change email and extracts the link. + * + * @param string $mail_id + * Unique mail ID. + * + * @return string + * An URL. + */ + protected function gerUrlEmail($mail_id) { + // Assume the most recent email. + $_emails = $this->drupalGetMails(['id' => $mail_id]); + $email = end($_emails); + $urls = []; + preg_match('#.+user/change-mail/.+#', $email['body'], $urls); + return $urls[0]; + } + + /** + * Generates a random email adress. + * + * @return string + * A random email address. + */ + protected function getRandomEmailAddress() { + return Unicode::strtolower($this->randomMachineName()) . '@example.com'; + } + +} diff --git a/core/modules/user/src/Tests/UserTokenReplaceTest.php b/core/modules/user/src/Tests/UserTokenReplaceTest.php index b7877d8..017539f 100644 --- a/core/modules/user/src/Tests/UserTokenReplaceTest.php +++ b/core/modules/user/src/Tests/UserTokenReplaceTest.php @@ -12,6 +12,7 @@ use Drupal\language\Entity\ConfigurableLanguage; use Drupal\simpletest\WebTestBase; use Drupal\user\Entity\User; +use Drupal\user\Controller\ChangeEmailController; /** * Generates text using placeholders for dummy content to check user token @@ -134,6 +135,7 @@ function testUserTokenReplacement() { // Generate login and cancel link. $tests = array(); $tests['[user:one-time-login-url]'] = user_pass_reset_url($account); + $tests['[user:mail-change-login-url]'] = ChangeEmailController::getUrl($account)->toString(); $tests['[user:cancel-url]'] = user_cancel_url($account); // Generate tokens with interface language. diff --git a/core/modules/user/user.install b/core/modules/user/user.install index 91a908e..121f262 100644 --- a/core/modules/user/user.install +++ b/core/modules/user/user.install @@ -1,5 +1,7 @@ save(); } + +/** + * Updates config for change mail notifications. + */ +function user_update_8002() { + $mail_settings = Yaml::parse(file_get_contents(__DIR__ . '/config/install/user.mail.yml')); + + $mail_config = \Drupal::service('config.factory')->getEditable('user.mail'); + $mail_config->set('mail_change_notification.body', $mail_settings['mail_change_notification']['body']); + $mail_config->set('mail_change_notification.subject', $mail_settings['mail_change_notification']['subject']); + $mail_config->set('mail_change_verification.body', $mail_settings['mail_change_verification']['body']); + $mail_config->set('mail_change_verification.subject', $mail_settings['mail_change_verification']['body']); + $mail_config->save(); +} diff --git a/core/modules/user/user.module b/core/modules/user/user.module index 3d76074..35c9f19 100644 --- a/core/modules/user/user.module +++ b/core/modules/user/user.module @@ -13,6 +13,7 @@ use Drupal\Core\Session\AnonymousUserSession; use Drupal\Core\Site\Settings; use Drupal\Core\Url; +use Drupal\user\Controller\ChangeEmailController; use Drupal\user\Entity\Role; use Drupal\user\Entity\User; use Drupal\user\RoleInterface; @@ -940,6 +941,7 @@ function user_mail($key, &$message, $params) { * properties: * - login: The UNIX timestamp of the user's last login. * - pass: The hashed account login password. + * - email: The user's email address. * @param array $options * A keyed array of settings and flags to control the token replacement * process. See \Drupal\Core\Utility\Token::replace(). @@ -947,6 +949,7 @@ 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); + $replacements['[user:mail-change-login-url]'] = ChangeEmailController::getUrl($data['user'], $options)->toString(); $replacements['[user:cancel-url]'] = user_cancel_url($data['user'], $options); } } @@ -1176,6 +1179,8 @@ function user_role_revoke_permissions($rid, array $permissions = array()) { * - 'register_pending_approval': Welcome message, user pending admin * approval. * - 'password_reset': Password recovery request. + * - 'mail_change_notification': Email change notification. + * - 'mail_change_verification': Email change verification. * - 'status_activated': Account activated. * - 'status_blocked': Account blocked. * - 'cancel_confirm': Account cancellation request. diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml index 6eea7ec..25b27d2 100644 --- a/core/modules/user/user.routing.yml +++ b/core/modules/user/user.routing.yml @@ -150,3 +150,14 @@ user.reset: options: _maintenance_access: TRUE no_cache: TRUE + +user.change_email: + path: '/user/change-mail/{uid}/{timestamp}/{new_mail}/{hash}' + defaults: + _controller: '\Drupal\user\Controller\ChangeEmailController::page' + _title: 'Change email address' + requirements: + _custom_access: '\Drupal\user\Controller\ChangeEmailController::access' + options: + _maintenance_access: TRUE + no_cache: TRUE