diff --git a/src/wp-includes/class-wp-recovery-mode-key-service.php b/src/wp-includes/class-wp-recovery-mode-key-service.php index 06f8d0fedd..229e7310ee 100644 --- a/src/wp-includes/class-wp-recovery-mode-key-service.php +++ b/src/wp-includes/class-wp-recovery-mode-key-service.php @@ -1,6 +1,6 @@ HashPassword( $key ); - update_option( - 'recovery_key', - array( - 'hashed_key' => $hashed, - 'created_at' => time(), - ) + $records = $this->get_keys(); + + $records[ $token ] = array( + 'hashed_key' => $hashed, + 'created_at' => time(), ); + $this->update_keys( $records ); + + /** + * Fires when a recovery mode key is generated. + * + * @since 5.2.0 + * + * @param string $token The recovery data token. + * @param string $key The recovery mode key. + */ + do_action( 'generate_recovery_mode_key', $token, $key ); + return $key; } /** * Verifies if the recovery mode key is correct. * + * Recovery mode keys can only be used once; the key will be consumed in the process. + * * @since 5.2.0 * - * @param string $key The unhashed key. - * @param int $ttl Time in seconds for the key to be valid for. + * @param string $token The token used when generating the given key. + * @param string $key The unhashed key. + * @param int $ttl Time in seconds for the key to be valid for. * @return true|WP_Error True on success, error object on failure. */ - public function validate_recovery_mode_key( $key, $ttl ) { + public function validate_recovery_mode_key( $token, $key, $ttl ) { - $record = get_option( 'recovery_key' ); + $records = $this->get_keys(); - if ( ! $record ) { - return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) ); + if ( ! isset( $records[ $token ] ) ) { + return new WP_Error( 'token_not_found', __( 'Recovery Mode not initialized.' ) ); } + $record = $records[ $token ]; + + $this->remove_key( $token ); + if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) { return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) ); } @@ -86,4 +115,69 @@ final class WP_Recovery_Mode_Key_Service { return true; } + + /** + * Removes expired recovery mode keys. + * + * @since 5.2.0 + * + * @param int $ttl Time in seconds for the keys to be valid for. + */ + public function clean_expired_keys( $ttl ) { + + $records = $this->get_keys(); + + foreach ( $records as $key => $record ) { + if ( ! isset( $record['created_at'] ) || time() > $record['created_at'] + $ttl ) { + unset( $records[ $key ] ); + } + } + + $this->update_keys( $records ); + } + + /** + * Removes a used recovery key. + * + * @since 5.2.0 + * + * @param string $token The token used when generating a recovery mode key. + */ + private function remove_key( $token ) { + + $records = $this->get_keys(); + + if ( ! isset( $records[ $token ] ) ) { + return; + } + + unset( $records[ $token ] ); + + $this->update_keys( $records ); + } + + /** + * Gets the recovery key records. + * + * @since 5.2.0 + * + * @return array Associative array of $token => $data pairs, where $data has keys 'hashed_key' + * and 'created_at'. + */ + private function get_keys() { + return (array) get_option( $this->option_name, array() ); + } + + /** + * Updates the recovery key records. + * + * @since 5.2.0 + * + * @param array $keys Associative array of $token => $data pairs, where $data has keys 'hashed_key' + * and 'created_at'. + * @return bool True on success, false on failure. + */ + private function update_keys( array $keys ) { + return update_option( $this->option_name, $keys ); + } } diff --git a/src/wp-includes/class-wp-recovery-mode-link-service.php b/src/wp-includes/class-wp-recovery-mode-link-service.php index 0ddbfec9d6..bbeafc77a0 100644 --- a/src/wp-includes/class-wp-recovery-mode-link-service.php +++ b/src/wp-includes/class-wp-recovery-mode-link-service.php @@ -37,10 +37,11 @@ class WP_Recovery_Mode_Link_Service { * @since 5.2.0 * * @param WP_Recovery_Mode_Cookie_Service $cookie_service Service to handle setting the recovery mode cookie. + * @param WP_Recovery_Mode_Key_Service $key_service Service to handle generating recovery mode keys. */ - public function __construct( WP_Recovery_Mode_Cookie_Service $cookie_service ) { + public function __construct( WP_Recovery_Mode_Cookie_Service $cookie_service, WP_Recovery_Mode_Key_Service $key_service ) { $this->cookie_service = $cookie_service; - $this->key_service = new WP_Recovery_Mode_Key_Service(); + $this->key_service = $key_service; } /** @@ -53,9 +54,10 @@ class WP_Recovery_Mode_Link_Service { * @return string Generated URL. */ public function generate_url() { - $key = $this->key_service->generate_and_store_recovery_mode_key(); + $token = $this->key_service->generate_recovery_mode_token(); + $key = $this->key_service->generate_and_store_recovery_mode_key( $token ); - return $this->get_recovery_mode_begin_url( $key ); + return $this->get_recovery_mode_begin_url( $token, $key ); } /** @@ -70,7 +72,7 @@ class WP_Recovery_Mode_Link_Service { return; } - if ( ! isset( $_GET['action'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) { + if ( ! isset( $_GET['action'], $_GET['rm_token'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) { return; } @@ -78,7 +80,7 @@ class WP_Recovery_Mode_Link_Service { require_once ABSPATH . WPINC . '/pluggable.php'; } - $validated = $this->key_service->validate_recovery_mode_key( $_GET['rm_key'], $ttl ); + $validated = $this->key_service->validate_recovery_mode_key( $_GET['rm_token'], $_GET['rm_key'], $ttl ); if ( is_wp_error( $validated ) ) { wp_die( $validated, '' ); @@ -96,15 +98,17 @@ class WP_Recovery_Mode_Link_Service { * * @since 5.2.0 * - * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()} + * @param string $token Recovery Mode token created by {@see generate_recovery_mode_token()}. + * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}. * @return string Recovery mode begin URL. */ - private function get_recovery_mode_begin_url( $key ) { + private function get_recovery_mode_begin_url( $token, $key ) { $url = add_query_arg( array( - 'action' => self::LOGIN_ACTION_ENTER, - 'rm_key' => $key, + 'action' => self::LOGIN_ACTION_ENTER, + 'rm_token' => $token, + 'rm_key' => $key, ), wp_login_url() ); @@ -114,9 +118,10 @@ class WP_Recovery_Mode_Link_Service { * * @since 5.2.0 * - * @param string $url - * @param string $key + * @param string $url The generated recovery mode begin URL. + * @param string $token The token used to identify the key. + * @param string $key The recovery mode key. */ - return apply_filters( 'recovery_mode_begin_url', $url, $key ); + return apply_filters( 'recovery_mode_begin_url', $url, $token, $key ); } } diff --git a/src/wp-includes/class-wp-recovery-mode.php b/src/wp-includes/class-wp-recovery-mode.php index 0f302a37bd..6ac1a09569 100644 --- a/src/wp-includes/class-wp-recovery-mode.php +++ b/src/wp-includes/class-wp-recovery-mode.php @@ -16,12 +16,20 @@ class WP_Recovery_Mode { const EXIT_ACTION = 'exit_recovery_mode'; /** - * Service to handle sending an email with a recovery mode link. + * Service to handle cookies. * * @since 5.2.0 - * @var WP_Recovery_Mode_Email_Service + * @var WP_Recovery_Mode_Cookie_Service */ - private $email_service; + private $cookie_service; + + /** + * Service to generate a recovery mode key. + * + * @since 5.2.0 + * @var WP_Recovery_Mode_Key_Service + */ + private $key_service; /** * Service to generate and validate recovery mode links. @@ -32,12 +40,12 @@ class WP_Recovery_Mode { private $link_service; /** - * Service to handle cookies. + * Service to handle sending an email with a recovery mode link. * * @since 5.2.0 - * @var WP_Recovery_Mode_Cookie_Service + * @var WP_Recovery_Mode_Email_Service */ - private $cookie_service; + private $email_service; /** * Is recovery mode initialized. @@ -70,7 +78,8 @@ class WP_Recovery_Mode { */ public function __construct() { $this->cookie_service = new WP_Recovery_Mode_Cookie_Service(); - $this->link_service = new WP_Recovery_Mode_Link_Service( $this->cookie_service ); + $this->key_service = new WP_Recovery_Mode_Key_Service(); + $this->link_service = new WP_Recovery_Mode_Link_Service( $this->cookie_service, $this->key_service ); $this->email_service = new WP_Recovery_Mode_Email_Service( $this->link_service ); } @@ -84,6 +93,11 @@ class WP_Recovery_Mode { add_action( 'wp_logout', array( $this, 'exit_recovery_mode' ) ); add_action( 'login_form_' . self::EXIT_ACTION, array( $this, 'handle_exit_recovery_mode' ) ); + add_action( 'recovery_mode_clean_expired_keys', array( $this, 'clean_expired_keys' ) ); + + if ( ! wp_next_scheduled( 'recovery_mode_clean_expired_keys' ) && ! wp_installing() ) { + wp_schedule_event( time(), 'daily', 'recovery_mode_clean_expired_keys' ); + } if ( defined( 'WP_RECOVERY_MODE_SESSION_ID' ) ) { $this->is_active = true; @@ -232,6 +246,17 @@ class WP_Recovery_Mode { die; } + /** + * Cleans any recovery mode keys that have expired according to the link TTL. + * + * Executes on a daily cron schedule. + * + * @since 5.2.0 + */ + public function clean_expired_keys() { + $this->key_service->clean_expired_keys( $this->get_link_ttl() ); + } + /** * Handles checking for the recovery mode cookie and validating it. * diff --git a/tests/phpunit/tests/error-protection/recovery-mode-key-service.php b/tests/phpunit/tests/error-protection/recovery-mode-key-service.php index 8e275c3535..5364fbf2b3 100644 --- a/tests/phpunit/tests/error-protection/recovery-mode-key-service.php +++ b/tests/phpunit/tests/error-protection/recovery-mode-key-service.php @@ -10,7 +10,8 @@ class Tests_Recovery_Mode_Key_Service extends WP_UnitTestCase { */ public function test_generate_and_store_recovery_mode_key_returns_recovery_key() { $service = new WP_Recovery_Mode_Key_Service(); - $key = $service->generate_and_store_recovery_mode_key(); + $token = $service->generate_recovery_mode_token(); + $key = $service->generate_and_store_recovery_mode_key( $token ); $this->assertNotWPError( $key ); } @@ -20,20 +21,49 @@ class Tests_Recovery_Mode_Key_Service extends WP_UnitTestCase { */ public function test_validate_recovery_mode_key_returns_wp_error_if_no_key_set() { $service = new WP_Recovery_Mode_Key_Service(); - $error = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS ); + $error = $service->validate_recovery_mode_key( '', 'abcd', HOUR_IN_SECONDS ); $this->assertWPError( $error ); - $this->assertEquals( 'no_recovery_key_set', $error->get_error_code() ); + $this->assertEquals( 'token_not_found', $error->get_error_code() ); } /** * @ticket 46130 */ - public function test_validate_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() { - update_option( 'recovery_key', 'gibberish' ); + public function test_validate_recovery_mode_key_returns_wp_error_if_data_missing() { + update_option( 'recovery_keys', 'gibberish' ); $service = new WP_Recovery_Mode_Key_Service(); - $error = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS ); + $error = $service->validate_recovery_mode_key( '', 'abcd', HOUR_IN_SECONDS ); + + $this->assertWPError( $error ); + $this->assertEquals( 'token_not_found', $error->get_error_code() ); + } + + /** + * @ticket 46130 + */ + public function test_validate_recovery_mode_key_returns_wp_error_if_bad() { + update_option( 'recovery_keys', array( 'token' => 'gibberish' ) ); + + $service = new WP_Recovery_Mode_Key_Service(); + $error = $service->validate_recovery_mode_key( 'token', 'abcd', HOUR_IN_SECONDS ); + + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() ); + } + + + /** + * @ticket 46130 + */ + public function test_validate_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() { + + $token = wp_generate_password( 22, false ); + update_option( 'recovery_keys', array( $token => 'gibberish' ) ); + + $service = new WP_Recovery_Mode_Key_Service(); + $error = $service->validate_recovery_mode_key( $token, 'abcd', HOUR_IN_SECONDS ); $this->assertWPError( $error ); $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() ); @@ -44,8 +74,9 @@ class Tests_Recovery_Mode_Key_Service extends WP_UnitTestCase { */ public function test_validate_recovery_mode_key_returns_wp_error_if_empty_key() { $service = new WP_Recovery_Mode_Key_Service(); - $service->generate_and_store_recovery_mode_key(); - $error = $service->validate_recovery_mode_key( '', HOUR_IN_SECONDS ); + $token = $service->generate_recovery_mode_token(); + $service->generate_and_store_recovery_mode_key( $token ); + $error = $service->validate_recovery_mode_key( $token, '', HOUR_IN_SECONDS ); $this->assertWPError( $error ); $this->assertEquals( 'hash_mismatch', $error->get_error_code() ); @@ -56,8 +87,9 @@ class Tests_Recovery_Mode_Key_Service extends WP_UnitTestCase { */ public function test_validate_recovery_mode_key_returns_wp_error_if_hash_mismatch() { $service = new WP_Recovery_Mode_Key_Service(); - $service->generate_and_store_recovery_mode_key(); - $error = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS ); + $token = $service->generate_recovery_mode_token(); + $service->generate_and_store_recovery_mode_key( $token ); + $error = $service->validate_recovery_mode_key( $token, 'abcd', HOUR_IN_SECONDS ); $this->assertWPError( $error ); $this->assertEquals( 'hash_mismatch', $error->get_error_code() ); @@ -68,13 +100,14 @@ class Tests_Recovery_Mode_Key_Service extends WP_UnitTestCase { */ public function test_validate_recovery_mode_key_returns_wp_error_if_expired() { $service = new WP_Recovery_Mode_Key_Service(); - $key = $service->generate_and_store_recovery_mode_key(); + $token = $service->generate_recovery_mode_token(); + $key = $service->generate_and_store_recovery_mode_key( $token ); - $record = get_option( 'recovery_key' ); - $record['created_at'] = time() - HOUR_IN_SECONDS - 30; - update_option( 'recovery_key', $record ); + $records = get_option( 'recovery_keys' ); + $records[ $token ]['created_at'] = time() - HOUR_IN_SECONDS - 30; + update_option( 'recovery_keys', $records ); - $error = $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS ); + $error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ); $this->assertWPError( $error ); $this->assertEquals( 'key_expired', $error->get_error_code() ); @@ -85,7 +118,66 @@ class Tests_Recovery_Mode_Key_Service extends WP_UnitTestCase { */ public function test_validate_recovery_mode_key_returns_true_for_valid_key() { $service = new WP_Recovery_Mode_Key_Service(); - $key = $service->generate_and_store_recovery_mode_key(); - $this->assertTrue( $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS ) ); + $token = $service->generate_recovery_mode_token(); + $key = $service->generate_and_store_recovery_mode_key( $token ); + $this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) ); + } + + /** + * @ticket 46595 + */ + public function test_validate_recovery_mode_key_returns_error_if_token_used_more_than_once() { + $service = new WP_Recovery_Mode_Key_Service(); + $token = $service->generate_recovery_mode_token(); + $key = $service->generate_and_store_recovery_mode_key( $token ); + + $this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) ); + + // data should be remove by first call + $error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ); + + $this->assertWPError( $error ); + $this->assertEquals( 'token_not_found', $error->get_error_code() ); + } + + /** + * @ticket 46595 + */ + public function test_validate_recovery_mode_key_returns_error_if_token_used_more_than_once_more_than_key_stored() { + $service = new WP_Recovery_Mode_Key_Service(); + + // create an extra key + $token = $service->generate_recovery_mode_token(); + $service->generate_and_store_recovery_mode_key( $token ); + + $token = $service->generate_recovery_mode_token(); + $key = $service->generate_and_store_recovery_mode_key( $token ); + + $this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) ); + + // data should be remove by first call + $error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ); + + $this->assertWPError( $error ); + $this->assertEquals( 'token_not_found', $error->get_error_code() ); + } + + /** + * @ticket 46595 + */ + public function test_clean_expired_keys() { + $service = new WP_Recovery_Mode_Key_Service(); + $token = $service->generate_recovery_mode_token(); + $service->generate_and_store_recovery_mode_key( $token ); + + $records = get_option( 'recovery_keys' ); + + $records[ $token ]['created_at'] = time() - HOUR_IN_SECONDS - 30; + + update_option( 'recovery_keys', $records ); + + $service->clean_expired_keys( HOUR_IN_SECONDS ); + + $this->assertEmpty( get_option( 'recovery_keys' ) ); } }