diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index a13b3f75c0bdb..03268d4a65fdf 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3409,7 +3409,24 @@ function retrieve_password( $user_login = '' ) { * * @see https://core.trac.wordpress.org/tickets/42957 */ - $message .= network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ) . '&wp_lang=' . $locale . "\r\n\r\n"; + $reset_url = network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ); + + /** + * Filters the URL used to reset a user's password. + * + * Returning a different URL allows the password reset link sent by email + * to point at a custom reset page instead of wp-login.php. + * + * @since 7.2.0 + * + * @param string $reset_url The password reset URL. + * @param string $user_login The user's login name. + * @param string $key The activation key used to reset the password. + * @param WP_User $user_data WP_User object. + */ + $reset_url = apply_filters( 'reset_password_url', $reset_url, $user_login, $key, $user_data ); + + $message .= $reset_url . '&wp_lang=' . $locale . "\r\n\r\n"; if ( ! is_user_logged_in() ) { $requester_ip = $_SERVER['REMOTE_ADDR']; diff --git a/tests/phpunit/tests/user/retrievePassword.php b/tests/phpunit/tests/user/retrievePassword.php index da72bed8b70de..4cb643fd6d331 100644 --- a/tests/phpunit/tests/user/retrievePassword.php +++ b/tests/phpunit/tests/user/retrievePassword.php @@ -18,6 +18,15 @@ class Tests_User_RetrievePassword extends WP_UnitTestCase { */ protected $user; + /** + * The message body of the password reset email captured by the test. + * + * @since 7.2.0 + * + * @var string + */ + protected $captured_message = ''; + /** * Create users for tests. * @@ -26,6 +35,8 @@ class Tests_User_RetrievePassword extends WP_UnitTestCase { public function set_up() { parent::set_up(); + $this->captured_message = ''; + // Create the user. $this->user = self::factory()->user->create_and_get( array( @@ -35,6 +46,35 @@ public function set_up() { ); } + /** + * Removes the filters and actions added by these tests. + * + * @since 7.2.0 + */ + public function tear_down() { + remove_all_filters( 'reset_password_url' ); + remove_filter( 'retrieve_password_notification_email', array( $this, 'capture_retrieve_password_notification_email' ) ); + remove_all_actions( 'retrieve_password_key' ); + + parent::tear_down(); + } + + /** + * Captures the message body of the password reset email. + * + * Used as the callback of the `retrieve_password_notification_email` filter. + * + * @since 7.2.0 + * + * @param array $defaults The default notification email arguments. + * @return array The unmodified notification email arguments. + */ + public function capture_retrieve_password_notification_email( $defaults ) { + $this->captured_message = $defaults['message']; + + return $defaults; + } + /** * The function should not error when the email was sent. * @@ -88,6 +128,120 @@ public function test_retrieve_password_does_not_throw_deprecation_notice_with_de $this->assertWPError( retrieve_password() ); } + /** + * Tests that the `reset_password_url` filter replaces the reset URL in the email. + * + * @ticket 34712 + */ + public function test_retrieve_password_should_apply_reset_password_url_filter() { + $custom_url = 'https://example.org/custom-reset-page/'; + + add_filter( + 'reset_password_url', + static function () use ( $custom_url ) { + return $custom_url; + } + ); + add_filter( 'retrieve_password_notification_email', array( $this, 'capture_retrieve_password_notification_email' ) ); + + $this->assertTrue( retrieve_password( $this->user->user_login ), 'Sending the password reset notification email failed.' ); + $this->assertStringContainsString( + $custom_url, + $this->captured_message, + 'The custom password reset URL was not used in the email message.' + ); + $this->assertStringNotContainsString( + 'wp-login.php?login=', + $this->captured_message, + 'The default password reset URL was still used in the email message.' + ); + } + + /** + * Tests that the `reset_password_url` filter receives the expected arguments. + * + * @ticket 34712 + */ + public function test_retrieve_password_should_pass_expected_arguments_to_reset_password_url_filter() { + $filter_args = array(); + $generated_key = ''; + $expected_user_id = $this->user->ID; + $expected_user_login = $this->user->user_login; + + add_action( + 'retrieve_password_key', + static function ( $user_login, $key ) use ( &$generated_key ) { + $generated_key = $key; + }, + 10, + 2 + ); + add_filter( + 'reset_password_url', + static function ( $reset_url, $user_login, $key, $user_data ) use ( &$filter_args ) { + $filter_args = array( + 'reset_url' => $reset_url, + 'user_login' => $user_login, + 'key' => $key, + 'user_data' => $user_data, + ); + + return $reset_url; + }, + 10, + 4 + ); + + $this->assertTrue( retrieve_password( $this->user->user_login ), 'Sending the password reset notification email failed.' ); + + $this->assertNotEmpty( $generated_key, 'The password reset key was not generated.' ); + $this->assertSame( $expected_user_login, $filter_args['user_login'], 'The user login passed to the filter is incorrect.' ); + $this->assertIsString( $filter_args['key'], 'The activation key passed to the filter is not a string.' ); + $this->assertNotEmpty( $filter_args['key'], 'The activation key passed to the filter is empty.' ); + $this->assertSame( $generated_key, $filter_args['key'], 'The activation key passed to the filter is incorrect.' ); + $this->assertInstanceOf( WP_User::class, $filter_args['user_data'], 'The user data passed to the filter is not a WP_User object.' ); + $this->assertSame( $expected_user_id, $filter_args['user_data']->ID, 'The user ID passed to the filter is incorrect.' ); + + $expected_default_url = network_site_url( 'wp-login.php?login=' . rawurlencode( $expected_user_login ) . "&key=$generated_key&action=rp", 'login' ); + + $this->assertSame( $expected_default_url, $filter_args['reset_url'], 'The URL passed to the filter is not the default password reset URL.' ); + } + + /** + * Tests that the default reset URL, including the `wp_lang` query arg, is unchanged + * when the `reset_password_url` filter is not registered. + * + * @ticket 34712 + */ + public function test_retrieve_password_should_keep_default_reset_url_when_unfiltered() { + $generated_key = ''; + + add_action( + 'retrieve_password_key', + static function ( $user_login, $key ) use ( &$generated_key ) { + $generated_key = $key; + }, + 10, + 2 + ); + add_filter( 'retrieve_password_notification_email', array( $this, 'capture_retrieve_password_notification_email' ) ); + + $this->assertTrue( retrieve_password( $this->user->user_login ), 'Sending the password reset notification email failed.' ); + + $this->assertNotEmpty( $generated_key, 'The password reset key was not generated.' ); + + $default_url = network_site_url( 'wp-login.php?login=' . rawurlencode( $this->user->user_login ) . "&key=$generated_key&action=rp", 'login' ); + $locale = get_user_locale( $this->user ); + + $this->assertStringContainsString( 'wp-login.php?login=', $this->captured_message, 'The default password reset URL is missing from the email message.' ); + $this->assertStringContainsString( 'action=rp', $this->captured_message, 'The default password reset URL is missing the action argument.' ); + $this->assertStringContainsString( + $default_url . '&wp_lang=' . $locale . "\r\n\r\n", + $this->captured_message, + 'The default password reset URL and locale are not intact in the email message.' + ); + } + /** * Tests that a fatal error is not thrown when the login passed via `$_POST` * is an array instead of a string.