WordPress.org

Make WordPress Core

Changeset 45211


Ignore:
Timestamp:
04/16/2019 05:08:16 AM (7 months ago)
Author:
flixos90
Message:

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.

Location:
trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/class-wp-recovery-mode-key-service.php

    r44973 r45211  
    11<?php
    22/**
    3  * Error Protection API: WP_Recovery_Mode_Key_service class
     3 * Error Protection API: WP_Recovery_Mode_Key_Service class
    44 *
    55 * @package WordPress
     
    1515
    1616    /**
     17     * The option name used to store the keys.
     18     *
     19     * @since 5.2.0
     20     * @var string
     21     */
     22    private $option_name = 'recovery_keys';
     23
     24    /**
     25     * Creates a recovery mode token.
     26     *
     27     * @since 5.2.0
     28     *
     29     * @return string $token A random string to identify its associated key in storage.
     30     */
     31    public function generate_recovery_mode_token() {
     32        return wp_generate_password( 22, false );
     33    }
     34
     35    /**
    1736     * Creates a recovery mode key.
    1837     *
     
    2140     * @global PasswordHash $wp_hasher
    2241     *
    23      * @return string Recovery mode key.
     42     * @param string $token A token generated by {@see generate_recovery_mode_token()}.
     43     * @return string $key Recovery mode key.
    2444     */
    25     public function generate_and_store_recovery_mode_key() {
     45    public function generate_and_store_recovery_mode_key( $token ) {
    2646
    2747        global $wp_hasher;
    2848
    2949        $key = wp_generate_password( 22, false );
    30 
    31         /**
    32          * Fires when a recovery mode key is generated for a user.
    33          *
    34          * @since 5.2.0
    35          *
    36          * @param string $key The recovery mode key.
    37          */
    38         do_action( 'generate_recovery_mode_key', $key );
    3950
    4051        if ( empty( $wp_hasher ) ) {
     
    4556        $hashed = $wp_hasher->HashPassword( $key );
    4657
    47         update_option(
    48             'recovery_key',
    49             array(
    50                 'hashed_key' => $hashed,
    51                 'created_at' => time(),
    52             )
     58        $records = $this->get_keys();
     59
     60        $records[ $token ] = array(
     61            'hashed_key' => $hashed,
     62            'created_at' => time(),
    5363        );
     64
     65        $this->update_keys( $records );
     66
     67        /**
     68         * Fires when a recovery mode key is generated.
     69         *
     70         * @since 5.2.0
     71         *
     72         * @param string $token The recovery data token.
     73         * @param string $key   The recovery mode key.
     74         */
     75        do_action( 'generate_recovery_mode_key', $token, $key );
    5476
    5577        return $key;
     
    5981     * Verifies if the recovery mode key is correct.
    6082     *
     83     * Recovery mode keys can only be used once; the key will be consumed in the process.
     84     *
    6185     * @since 5.2.0
    6286     *
    63      * @param string $key The unhashed key.
    64      * @param int    $ttl Time in seconds for the key to be valid for.
     87     * @param string $token The token used when generating the given key.
     88     * @param string $key   The unhashed key.
     89     * @param int    $ttl   Time in seconds for the key to be valid for.
    6590     * @return true|WP_Error True on success, error object on failure.
    6691     */
    67     public function validate_recovery_mode_key( $key, $ttl ) {
     92    public function validate_recovery_mode_key( $token, $key, $ttl ) {
    6893
    69         $record = get_option( 'recovery_key' );
     94        $records = $this->get_keys();
    7095
    71         if ( ! $record ) {
    72             return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) );
     96        if ( ! isset( $records[ $token ] ) ) {
     97            return new WP_Error( 'token_not_found', __( 'Recovery Mode not initialized.' ) );
    7398        }
     99
     100        $record = $records[ $token ];
     101
     102        $this->remove_key( $token );
    74103
    75104        if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) {
     
    87116        return true;
    88117    }
     118
     119    /**
     120     * Removes expired recovery mode keys.
     121     *
     122     * @since 5.2.0
     123     *
     124     * @param int $ttl Time in seconds for the keys to be valid for.
     125     */
     126    public function clean_expired_keys( $ttl ) {
     127
     128        $records = $this->get_keys();
     129
     130        foreach ( $records as $key => $record ) {
     131            if ( ! isset( $record['created_at'] ) || time() > $record['created_at'] + $ttl ) {
     132                unset( $records[ $key ] );
     133            }
     134        }
     135
     136        $this->update_keys( $records );
     137    }
     138
     139    /**
     140     * Removes a used recovery key.
     141     *
     142     * @since 5.2.0
     143     *
     144     * @param string $token The token used when generating a recovery mode key.
     145     */
     146    private function remove_key( $token ) {
     147
     148        $records = $this->get_keys();
     149
     150        if ( ! isset( $records[ $token ] ) ) {
     151            return;
     152        }
     153
     154        unset( $records[ $token ] );
     155
     156        $this->update_keys( $records );
     157    }
     158
     159    /**
     160     * Gets the recovery key records.
     161     *
     162     * @since 5.2.0
     163     *
     164     * @return array Associative array of $token => $data pairs, where $data has keys 'hashed_key'
     165     *               and 'created_at'.
     166     */
     167    private function get_keys() {
     168        return (array) get_option( $this->option_name, array() );
     169    }
     170
     171    /**
     172     * Updates the recovery key records.
     173     *
     174     * @since 5.2.0
     175     *
     176     * @param array $keys Associative array of $token => $data pairs, where $data has keys 'hashed_key'
     177     *                    and 'created_at'.
     178     * @return bool True on success, false on failure.
     179     */
     180    private function update_keys( array $keys ) {
     181        return update_option( $this->option_name, $keys );
     182    }
    89183}
  • trunk/src/wp-includes/class-wp-recovery-mode-link-service.php

    r44973 r45211  
    3838     *
    3939     * @param WP_Recovery_Mode_Cookie_Service $cookie_service Service to handle setting the recovery mode cookie.
     40     * @param WP_Recovery_Mode_Key_Service    $key_service    Service to handle generating recovery mode keys.
    4041     */
    41     public function __construct( WP_Recovery_Mode_Cookie_Service $cookie_service ) {
     42    public function __construct( WP_Recovery_Mode_Cookie_Service $cookie_service, WP_Recovery_Mode_Key_Service $key_service ) {
    4243        $this->cookie_service = $cookie_service;
    43         $this->key_service    = new WP_Recovery_Mode_Key_Service();
     44        $this->key_service    = $key_service;
    4445    }
    4546
     
    5455     */
    5556    public function generate_url() {
    56         $key = $this->key_service->generate_and_store_recovery_mode_key();
     57        $token = $this->key_service->generate_recovery_mode_token();
     58        $key   = $this->key_service->generate_and_store_recovery_mode_key( $token );
    5759
    58         return $this->get_recovery_mode_begin_url( $key );
     60        return $this->get_recovery_mode_begin_url( $token, $key );
    5961    }
    6062
     
    7173        }
    7274
    73         if ( ! isset( $_GET['action'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) {
     75        if ( ! isset( $_GET['action'], $_GET['rm_token'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) {
    7476            return;
    7577        }
     
    7981        }
    8082
    81         $validated = $this->key_service->validate_recovery_mode_key( $_GET['rm_key'], $ttl );
     83        $validated = $this->key_service->validate_recovery_mode_key( $_GET['rm_token'], $_GET['rm_key'], $ttl );
    8284
    8385        if ( is_wp_error( $validated ) ) {
     
    9799     * @since 5.2.0
    98100     *
    99      * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}
     101     * @param string $token Recovery Mode token created by {@see generate_recovery_mode_token()}.
     102     * @param string $key   Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}.
    100103     * @return string Recovery mode begin URL.
    101104     */
    102     private function get_recovery_mode_begin_url( $key ) {
     105    private function get_recovery_mode_begin_url( $token, $key ) {
    103106
    104107        $url = add_query_arg(
    105108            array(
    106                 'action' => self::LOGIN_ACTION_ENTER,
    107                 'rm_key' => $key,
     109                'action'   => self::LOGIN_ACTION_ENTER,
     110                'rm_token' => $token,
     111                'rm_key'   => $key,
    108112            ),
    109113            wp_login_url()
     
    115119         * @since 5.2.0
    116120         *
    117          * @param string $url
    118          * @param string $key
     121         * @param string $url   The generated recovery mode begin URL.
     122         * @param string $token The token used to identify the key.
     123         * @param string $key   The recovery mode key.
    119124         */
    120         return apply_filters( 'recovery_mode_begin_url', $url, $key );
     125        return apply_filters( 'recovery_mode_begin_url', $url, $token, $key );
    121126    }
    122127}
  • trunk/src/wp-includes/class-wp-recovery-mode.php

    r45117 r45211  
    1717
    1818    /**
     19     * Service to handle cookies.
     20     *
     21     * @since 5.2.0
     22     * @var WP_Recovery_Mode_Cookie_Service
     23     */
     24    private $cookie_service;
     25
     26    /**
     27     * Service to generate a recovery mode key.
     28     *
     29     * @since 5.2.0
     30     * @var WP_Recovery_Mode_Key_Service
     31     */
     32    private $key_service;
     33
     34    /**
     35     * Service to generate and validate recovery mode links.
     36     *
     37     * @since 5.2.0
     38     * @var WP_Recovery_Mode_Link_Service
     39     */
     40    private $link_service;
     41
     42    /**
    1943     * Service to handle sending an email with a recovery mode link.
    2044     *
     
    2347     */
    2448    private $email_service;
    25 
    26     /**
    27      * Service to generate and validate recovery mode links.
    28      *
    29      * @since 5.2.0
    30      * @var WP_Recovery_Mode_Link_Service
    31      */
    32     private $link_service;
    33 
    34     /**
    35      * Service to handle cookies.
    36      *
    37      * @since 5.2.0
    38      * @var WP_Recovery_Mode_Cookie_Service
    39      */
    40     private $cookie_service;
    4149
    4250    /**
     
    7179    public function __construct() {
    7280        $this->cookie_service = new WP_Recovery_Mode_Cookie_Service();
    73         $this->link_service   = new WP_Recovery_Mode_Link_Service( $this->cookie_service );
     81        $this->key_service    = new WP_Recovery_Mode_Key_Service();
     82        $this->link_service   = new WP_Recovery_Mode_Link_Service( $this->cookie_service, $this->key_service );
    7483        $this->email_service  = new WP_Recovery_Mode_Email_Service( $this->link_service );
    7584    }
     
    8594        add_action( 'wp_logout', array( $this, 'exit_recovery_mode' ) );
    8695        add_action( 'login_form_' . self::EXIT_ACTION, array( $this, 'handle_exit_recovery_mode' ) );
     96        add_action( 'recovery_mode_clean_expired_keys', array( $this, 'clean_expired_keys' ) );
     97
     98        if ( ! wp_next_scheduled( 'recovery_mode_clean_expired_keys' ) && ! wp_installing() ) {
     99            wp_schedule_event( time(), 'daily', 'recovery_mode_clean_expired_keys' );
     100        }
    87101
    88102        if ( defined( 'WP_RECOVERY_MODE_SESSION_ID' ) ) {
     
    231245        wp_safe_redirect( $redirect_to );
    232246        die;
     247    }
     248
     249    /**
     250     * Cleans any recovery mode keys that have expired according to the link TTL.
     251     *
     252     * Executes on a daily cron schedule.
     253     *
     254     * @since 5.2.0
     255     */
     256    public function clean_expired_keys() {
     257        $this->key_service->clean_expired_keys( $this->get_link_ttl() );
    233258    }
    234259
  • trunk/tests/phpunit/tests/error-protection/recovery-mode-key-service.php

    r44973 r45211  
    1111    public function test_generate_and_store_recovery_mode_key_returns_recovery_key() {
    1212        $service = new WP_Recovery_Mode_Key_Service();
    13         $key     = $service->generate_and_store_recovery_mode_key();
     13        $token   = $service->generate_recovery_mode_token();
     14        $key     = $service->generate_and_store_recovery_mode_key( $token );
    1415
    1516        $this->assertNotWPError( $key );
     
    2122    public function test_validate_recovery_mode_key_returns_wp_error_if_no_key_set() {
    2223        $service = new WP_Recovery_Mode_Key_Service();
    23         $error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     24        $error   = $service->validate_recovery_mode_key( '', 'abcd', HOUR_IN_SECONDS );
    2425
    2526        $this->assertWPError( $error );
    26         $this->assertEquals( 'no_recovery_key_set', $error->get_error_code() );
     27        $this->assertEquals( 'token_not_found', $error->get_error_code() );
    2728    }
    2829
     
    3031     * @ticket 46130
    3132     */
    32     public function test_validate_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() {
    33         update_option( 'recovery_key', 'gibberish' );
     33    public function test_validate_recovery_mode_key_returns_wp_error_if_data_missing() {
     34        update_option( 'recovery_keys', 'gibberish' );
    3435
    3536        $service = new WP_Recovery_Mode_Key_Service();
    36         $error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     37        $error   = $service->validate_recovery_mode_key( '', 'abcd', HOUR_IN_SECONDS );
     38
     39        $this->assertWPError( $error );
     40        $this->assertEquals( 'token_not_found', $error->get_error_code() );
     41    }
     42
     43    /**
     44     * @ticket 46130
     45     */
     46    public function test_validate_recovery_mode_key_returns_wp_error_if_bad() {
     47        update_option( 'recovery_keys', array( 'token' => 'gibberish' ) );
     48
     49        $service = new WP_Recovery_Mode_Key_Service();
     50        $error   = $service->validate_recovery_mode_key( 'token', 'abcd', HOUR_IN_SECONDS );
     51
     52        $this->assertWPError( $error );
     53        $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() );
     54    }
     55
     56
     57    /**
     58     * @ticket 46130
     59     */
     60    public function test_validate_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() {
     61
     62        $token = wp_generate_password( 22, false );
     63        update_option( 'recovery_keys', array( $token => 'gibberish' ) );
     64
     65        $service = new WP_Recovery_Mode_Key_Service();
     66        $error   = $service->validate_recovery_mode_key( $token, 'abcd', HOUR_IN_SECONDS );
    3767
    3868        $this->assertWPError( $error );
     
    4575    public function test_validate_recovery_mode_key_returns_wp_error_if_empty_key() {
    4676        $service = new WP_Recovery_Mode_Key_Service();
    47         $service->generate_and_store_recovery_mode_key();
    48         $error = $service->validate_recovery_mode_key( '', HOUR_IN_SECONDS );
     77        $token   = $service->generate_recovery_mode_token();
     78        $service->generate_and_store_recovery_mode_key( $token );
     79        $error = $service->validate_recovery_mode_key( $token, '', HOUR_IN_SECONDS );
    4980
    5081        $this->assertWPError( $error );
     
    5788    public function test_validate_recovery_mode_key_returns_wp_error_if_hash_mismatch() {
    5889        $service = new WP_Recovery_Mode_Key_Service();
    59         $service->generate_and_store_recovery_mode_key();
    60         $error = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     90        $token   = $service->generate_recovery_mode_token();
     91        $service->generate_and_store_recovery_mode_key( $token );
     92        $error = $service->validate_recovery_mode_key( $token, 'abcd', HOUR_IN_SECONDS );
    6193
    6294        $this->assertWPError( $error );
     
    69101    public function test_validate_recovery_mode_key_returns_wp_error_if_expired() {
    70102        $service = new WP_Recovery_Mode_Key_Service();
    71         $key     = $service->generate_and_store_recovery_mode_key();
     103        $token   = $service->generate_recovery_mode_token();
     104        $key     = $service->generate_and_store_recovery_mode_key( $token );
    72105
    73         $record               = get_option( 'recovery_key' );
    74         $record['created_at'] = time() - HOUR_IN_SECONDS - 30;
    75         update_option( 'recovery_key', $record );
     106        $records                         = get_option( 'recovery_keys' );
     107        $records[ $token ]['created_at'] = time() - HOUR_IN_SECONDS - 30;
     108        update_option( 'recovery_keys', $records );
    76109
    77         $error = $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS );
     110        $error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS );
    78111
    79112        $this->assertWPError( $error );
     
    86119    public function test_validate_recovery_mode_key_returns_true_for_valid_key() {
    87120        $service = new WP_Recovery_Mode_Key_Service();
    88         $key     = $service->generate_and_store_recovery_mode_key();
    89         $this->assertTrue( $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS ) );
     121        $token   = $service->generate_recovery_mode_token();
     122        $key     = $service->generate_and_store_recovery_mode_key( $token );
     123        $this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) );
     124    }
     125
     126    /**
     127     * @ticket 46595
     128     */
     129    public function test_validate_recovery_mode_key_returns_error_if_token_used_more_than_once() {
     130        $service = new WP_Recovery_Mode_Key_Service();
     131        $token   = $service->generate_recovery_mode_token();
     132        $key     = $service->generate_and_store_recovery_mode_key( $token );
     133
     134        $this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) );
     135
     136        // data should be remove by first call
     137        $error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS );
     138
     139        $this->assertWPError( $error );
     140        $this->assertEquals( 'token_not_found', $error->get_error_code() );
     141    }
     142
     143    /**
     144     * @ticket 46595
     145     */
     146    public function test_validate_recovery_mode_key_returns_error_if_token_used_more_than_once_more_than_key_stored() {
     147        $service = new WP_Recovery_Mode_Key_Service();
     148
     149        // create an extra key
     150        $token = $service->generate_recovery_mode_token();
     151        $service->generate_and_store_recovery_mode_key( $token );
     152
     153        $token = $service->generate_recovery_mode_token();
     154        $key   = $service->generate_and_store_recovery_mode_key( $token );
     155
     156        $this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) );
     157
     158        // data should be remove by first call
     159        $error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS );
     160
     161        $this->assertWPError( $error );
     162        $this->assertEquals( 'token_not_found', $error->get_error_code() );
     163    }
     164
     165    /**
     166     * @ticket 46595
     167     */
     168    public function test_clean_expired_keys() {
     169        $service = new WP_Recovery_Mode_Key_Service();
     170        $token   = $service->generate_recovery_mode_token();
     171        $service->generate_and_store_recovery_mode_key( $token );
     172
     173        $records = get_option( 'recovery_keys' );
     174
     175        $records[ $token ]['created_at'] = time() - HOUR_IN_SECONDS - 30;
     176
     177        update_option( 'recovery_keys', $records );
     178
     179        $service->clean_expired_keys( HOUR_IN_SECONDS );
     180
     181        $this->assertEmpty( get_option( 'recovery_keys' ) );
    90182    }
    91183}
Note: See TracChangeset for help on using the changeset viewer.