Bootstrap/Load: Allow more than one recovery link to be valid at a time.

While currently a recovery link is only made available via the admin email address, this will be expanded in the future. In order to accomplish that, the mechanisms to store and validate recovery keys must support multiple keys to be valid at the same time.

This changeset adds that support, adding an additional token parameter which is part of a recovery link in addition to the key. A key itself is always associated with a token, so the two are only valid in combination. These associations are stored in a new `recovery_keys` option, which is regularly cleared in a new Cron hook, to prevent potential cluttering from unused recovery keys.

This changeset does not have any user-facing implications otherwise.

Props pbearne, timothyblynjacobs.
Fixes #46595. See #46130.


git-svn-id: https://develop.svn.wordpress.org/trunk@45211 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Felix Arntz
2019-04-16 05:08:16 +00:00
parent 2f8a683fdc
commit aa377f582d
4 changed files with 277 additions and 61 deletions

View File

@@ -1,6 +1,6 @@
<?php
/**
* Error Protection API: WP_Recovery_Mode_Key_service class
* Error Protection API: WP_Recovery_Mode_Key_Service class
*
* @package WordPress
* @since 5.2.0
@@ -13,6 +13,25 @@
*/
final class WP_Recovery_Mode_Key_Service {
/**
* The option name used to store the keys.
*
* @since 5.2.0
* @var string
*/
private $option_name = 'recovery_keys';
/**
* Creates a recovery mode token.
*
* @since 5.2.0
*
* @return string $token A random string to identify its associated key in storage.
*/
public function generate_recovery_mode_token() {
return wp_generate_password( 22, false );
}
/**
* Creates a recovery mode key.
*
@@ -20,23 +39,15 @@ final class WP_Recovery_Mode_Key_Service {
*
* @global PasswordHash $wp_hasher
*
* @return string Recovery mode key.
* @param string $token A token generated by {@see generate_recovery_mode_token()}.
* @return string $key Recovery mode key.
*/
public function generate_and_store_recovery_mode_key() {
public function generate_and_store_recovery_mode_key( $token ) {
global $wp_hasher;
$key = wp_generate_password( 22, false );
/**
* Fires when a recovery mode key is generated for a user.
*
* @since 5.2.0
*
* @param string $key The recovery mode key.
*/
do_action( 'generate_recovery_mode_key', $key );
if ( empty( $wp_hasher ) ) {
require_once ABSPATH . WPINC . '/class-phpass.php';
$wp_hasher = new PasswordHash( 8, true );
@@ -44,34 +55,52 @@ final class WP_Recovery_Mode_Key_Service {
$hashed = $wp_hasher->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 );
}
}

View File

@@ -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 );
}
}

View File

@@ -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.
*

View File

@@ -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' ) );
}
}