diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 0b814f3b..61215108 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -99,6 +99,8 @@ public static function add_hooks( $compat ) { add_filter( 'wp_login_errors', array( __CLASS__, 'maybe_show_reset_password_notice' ) ); add_action( 'after_password_reset', array( __CLASS__, 'clear_password_reset_notice' ) ); add_action( 'login_form_validate_2fa', array( __CLASS__, 'login_form_validate_2fa' ) ); + add_action( 'login_form_revalidate_2fa', array( __CLASS__, 'login_form_revalidate_2fa' ) ); + add_action( 'show_user_profile', array( __CLASS__, 'user_two_factor_options' ) ); add_action( 'edit_user_profile', array( __CLASS__, 'user_two_factor_options' ) ); add_action( 'personal_options_update', array( __CLASS__, 'user_two_factor_options_update' ) ); @@ -242,17 +244,15 @@ protected static function is_wp_debug() { * @return string */ protected static function get_user_settings_page_url( $user_id ) { - $page = 'user-edit.php'; - if ( defined( 'IS_PROFILE_PAGE' ) && IS_PROFILE_PAGE ) { - $page = 'profile.php'; + return self_admin_url( 'profile.php' ); } return add_query_arg( array( 'user_id' => intval( $user_id ), ), - self_admin_url( $page ) + self_admin_url( 'user-edit.php' ) ); } @@ -277,6 +277,23 @@ public static function get_user_update_action_url( $user_id, $action ) { ); } + /** + * Get the two-factor revalidate URL. + * + * @param bool $interim If the URL should load the interim login iframe modal. + * @return string + */ + public static function get_user_two_factor_revalidate_url( $interim = false ) { + $args = array( + 'action' => 'revalidate_2fa', + ); + if ( $interim ) { + $args['interim-login'] = 1; + } + + return self::login_url( $args ); + } + /** * Check if a user action is valid. * @@ -715,7 +732,7 @@ public static function clear_password_reset_notice( $user ) { * @param string $error_msg Optional. Login error message. * @param string|object $provider An override to the provider. */ - public static function login_html( $user, $login_nonce, $redirect_to, $error_msg = '', $provider = null ) { + public static function login_html( $user, $login_nonce, $redirect_to, $error_msg = '', $provider = null, $action = 'validate_2fa' ) { if ( empty( $provider ) ) { $provider = self::get_primary_provider_for_user( $user->ID ); } elseif ( is_string( $provider ) && method_exists( $provider, 'get_instance' ) ) { @@ -741,12 +758,12 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg if ( ! empty( $error_msg ) ) { echo '
' . esc_html( $error_msg ) . '
'; - } else { + } elseif ( 'validate_2fa' === $action ) { self::maybe_show_last_login_failure_notice( $user ); } ?> -
+ @@ -759,7 +776,25 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg authentication_page( $user ); ?>
- + + $action, + ); + if ( $rememberme ) { + $backup_link_args['rememberme'] = $rememberme; + } + if ( $login_nonce ) { + $backup_link_args['wp-auth-id'] = $user->ID; + $backup_link_args['wp-auth-nonce'] = $login_nonce; + } + if ( $redirect_to ) { + $backup_link_args['redirect_to'] = $redirect_to; + } + if ( $interim_login ) { + $backup_link_args['interim-login'] = 1; + } + ?>

@@ -767,19 +802,10 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg

+ + + pre_process_authentication( $user ) ) { + return false; + } + + // If it's not a POST request, there's no processing to perform. + if ( ! $is_post_request ) { + return false; + } + + // Rate limit two factor authentication attempts. + if ( true === self::is_user_rate_limited( $user ) ) { + $time_delay = self::get_user_time_delay( $user ); + $last_login = get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true ); + + return new WP_Error( + 'two_factor_too_fast', + sprintf( + __( 'ERROR: Too many invalid verification codes, you can try again in %s. This limit protects your account against automated attacks.', 'two-factor' ), + human_time_diff( $last_login + $time_delay ) + ) + ); + } + + // Ask the provider to verify the second factor. + if ( true !== $provider->validate_authentication( $user ) ) { + // Store the last time a failed login occured. + update_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, time() ); + + // Store the number of failed login attempts. + update_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, 1 + (int) get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + + if ( ! is_user_logged_in() && self::should_reset_password( $user->ID ) ) { + self::reset_compromised_password( $user ); + self::send_password_reset_emails( $user ); + self::show_password_reset_error(); + exit; + } + + return new WP_Error( + 'two_factor_invalid', + __( 'ERROR: Invalid verification code.', 'two-factor' ) + ); + } + + return true; + } + /** * Determine if the user's password should be reset. * @@ -1462,11 +1674,32 @@ public static function user_two_factor_options( $user ) { $primary_provider_key = null; } - wp_nonce_field( 'user_two_factor_options', '_nonce_user_two_factor_options', false ); + // This is specific to the current session, not the displayed user. + $show_2fa_options = self::current_user_can_update_two_factor_options(); + + if ( ! $show_2fa_options ) { + $url = self::get_user_two_factor_revalidate_url(); + $url = add_query_arg( 'redirect_to', urlencode( self::get_user_settings_page_url( $user->ID ) . '#two-factor-options' ), $url ); + printf( + '

%s

', + sprintf( + __( 'To update your Two-Factor options, you must first revalidate your session.', 'two-factor' ) . + '
' . __( 'Revalidate now', 'two-factor' ) . '', + esc_url( $url ) + ) + ); + } + + printf( + '
', + $show_2fa_options ? '' : 'disabled="disabled"', + ); + + wp_nonce_field( 'user_two_factor_options', '_nonce_user_two_factor_options', false ); ?> - +
@@ -1508,7 +1741,9 @@ public static function user_two_factor_options( $user ) {
+
get( $token ); + + $session['two-factor-provider'] = ''; // Set the key, but not the provider, as no provider has been used yet. + $session['two-factor-login'] = time(); + + $manager->update( $token, $session ); + } + } } } diff --git a/providers/class-two-factor-backup-codes.php b/providers/class-two-factor-backup-codes.php index 57b8667a..c53a8448 100644 --- a/providers/class-two-factor-backup-codes.php +++ b/providers/class-two-factor-backup-codes.php @@ -56,7 +56,7 @@ public function register_rest_routes() { 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_generate_codes' ), 'permission_callback' => function( $request ) { - return current_user_can( 'edit_user', $request['user_id'] ); + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); }, 'args' => array( 'user_id' => array( diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 26ceb201..4b075495 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -65,7 +65,7 @@ public function register_rest_routes() { 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'rest_delete_totp' ), 'permission_callback' => function( $request ) { - return current_user_can( 'edit_user', $request['user_id'] ); + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); }, 'args' => array( 'user_id' => array( @@ -78,7 +78,7 @@ public function register_rest_routes() { 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_setup_totp' ), 'permission_callback' => function( $request ) { - return current_user_can( 'edit_user', $request['user_id'] ); + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); }, 'args' => array( 'user_id' => array( diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 9a3ebf9e..1bc181d2 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -811,6 +811,57 @@ public function test_show_password_reset_error() { $this->assertStringContainsString( 'check your email for instructions on regaining access', $contents ); } + /** + * Ensure that when a user enables two factor, that they are able to continue to change settings. + * + * @covers Two_Factor_Core::current_user_can_update_two_factor_options() + * @covers Two_Factor_Core::user_two_factor_options_update() + */ + public function test_enabling_two_factor_is_factored_session() { + $user = self::factory()->user->create_and_get(); + + $this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() ); + + // Set the cookie without going through two-factor, and fill in $_COOKIE. + wp_set_current_user( $user->ID ); + wp_set_auth_cookie( $user->ID ); + + // Session is not two-factored. + $this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() ); + + // Can view 2FA edit settings. + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options() ); + // Can save 2FA settings. + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options( 'save' ) ); + + $key = '_nonce_user_two_factor_options'; + $nonce = wp_create_nonce( 'user_two_factor_options' ); + $_POST[ $key ] = $nonce; + $_REQUEST[ $key ] = $nonce; + + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = [ 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ]; + + Two_Factor_Core::user_two_factor_options_update( $user->ID ); + + // Validate that the session is flagged as 2FA, the return value being int. + $this->assertNotFalse( Two_Factor_Core::is_current_user_session_two_factor() ); + + $manager = WP_Session_Tokens::get_instance( $user->ID ); + $token = wp_get_session_token(); + $session = $manager->get( $token ); + + // Validate that the session provider is as expected. + $this->assertArrayHasKey( 'two-factor-login', $session ); + $this->assertEquals( '', $session['two-factor-provider'] ); // No provider was used for login. + $this->assertGreaterThan( time() - MINUTE_IN_SECONDS, $session['two-factor-login'] ); + + // Can view 2FA edit settings. + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options() ); + // Can save 2FA settings. + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options( 'save' ) ); + + } + /** * Validate that a non-2fa login doesn't set the session two-factor data. * @@ -847,6 +898,7 @@ public function test_is_current_user_session_two_factor_without_two_factor() { * Validate that a simulated 2fa login sets the session two-factor data. * * @covers Two_Factor_Core::is_current_user_session_two_factor() + * @covers Two_Factor_Core::current_user_can_update_two_factor_options() * @covers Two_Factor_Core::_login_form_validate_2fa() */ public function test_is_current_user_session_two_factor_with_two_factor() { @@ -859,11 +911,21 @@ public function test_is_current_user_session_two_factor_with_two_factor() { // Assert user not logged in is false. $this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() ); - // Simulate a 2FA login. + // Display it. $login_nonce = Two_Factor_Core::create_login_nonce( $user->ID ); + $this->assertNotFalse( $login_nonce ); + ob_start(); + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', false ); + ob_end_clean(); + + // Validate that the session is not set, as it wasn't a POST. + $this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() ); + + $login_nonce = Two_Factor_Core::create_login_nonce( $user->ID ); $this->assertNotFalse( $login_nonce ); + // Process it. ob_start(); Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); ob_end_clean(); @@ -884,4 +946,141 @@ public function test_is_current_user_session_two_factor_with_two_factor() { } + /** + * Validate that a simulated 2fa revalidation updates the session two-factor data. + * + * @covers Two_Factor_Core::_login_form_revalidate_2fa() + * @covers Two_Factor_Core::current_user_can_update_two_factor_options() + */ + public function test_revalidation_sets_time() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + // Assert no cookies are set. + $this->assertArrayNotHasKey( AUTH_COOKIE, $_COOKIE ); + $this->assertArrayNotHasKey( LOGGED_IN_COOKIE, $_COOKIE ); + + // Assert user not logged in is false. + $this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() ); + $this->assertFalse( Two_Factor_Core::current_user_can_update_two_factor_options() ); + + // Simulate a 2FA login. + + // Display it. + $login_nonce = Two_Factor_Core::create_login_nonce( $user->ID ); + $this->assertNotFalse( $login_nonce ); + + ob_start(); + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', false ); + ob_end_clean(); + + $login_nonce = Two_Factor_Core::create_login_nonce( $user->ID ); + $this->assertNotFalse( $login_nonce ); + + // Process it. + ob_start(); + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); + ob_end_clean(); + + $this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] ); + $this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] ); + + // Validate that the session is flagged as 2FA, and now-ish. + $current_session_two_factor = Two_Factor_Core::is_current_user_session_two_factor(); + $this->assertNotFalse( $current_session_two_factor ); + // Verify that it was set to now. + $this->assertLessThanOrEqual( time(), $current_session_two_factor ); + $this->assertGreaterThanOrEqual( time() - MINUTE_IN_SECONDS, $current_session_two_factor ); + + // Validate that the user can update options. + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options() ); + + $manager = WP_Session_Tokens::get_instance( $user->ID ); + $token = wp_get_session_token(); + $session = $manager->get( $token ); + + // Validate that the session provider is as expected. + $this->assertArrayHasKey( 'two-factor-provider', $session ); + $this->assertEquals( 'Two_Factor_Dummy', $session['two-factor-provider'] ); + $this->assertEquals( $current_session_two_factor, $session['two-factor-login'] ); + + // Set the Session to have started an hour ago. + $session['two-factor-login'] = time() - HOUR_IN_SECONDS; + $manager->update( $token, $session ); + + // The session should now be "expired" for revalidation. + $this->assertLessThan( time(), Two_Factor_Core::is_current_user_session_two_factor() ); + + // Revalidate. + // Simulate displaying it. + ob_start(); + Two_Factor_Core::_login_form_revalidate_2fa( 'Two_Factor_Dummy', '', false ); + ob_end_clean(); + + // Check it's still expired. + $this->assertLessThan( time(), Two_Factor_Core::is_current_user_session_two_factor() ); + + // Simulate clicking it. + ob_start(); + Two_Factor_Core::_login_form_revalidate_2fa( 'Two_Factor_Dummy', '', true ); + ob_end_clean(); + + // Validate that the session is flagged as 2FA, and set to now-ish. + $current_session_two_factor = Two_Factor_Core::is_current_user_session_two_factor(); + $this->assertNotFalse( $current_session_two_factor ); + // Verify that it was set to now. + $this->assertLessThanOrEqual( time(), $current_session_two_factor ); + $this->assertGreaterThanOrEqual( time() - MINUTE_IN_SECONDS, $current_session_two_factor ); + } + + /** + * @covers Two_Factor_Core::current_user_can_update_two_factor_options() + */ + public function test_current_user_can_update_two_factor_options() { + // Logged out. + $this->assertFalse( Two_Factor_Core::current_user_can_update_two_factor_options() ); + + // Create a user, set a session. + $user = self::factory()->user->create_and_get(); + + wp_set_current_user( $user->ID ); + wp_set_auth_cookie( $user->ID ); + + // Logged in, no 2FA setup. + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options() ); + + // Manually setup 2FA, but not through the User Options API, such that the above session is not-2fa. + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Dummy' ); + + // Logged in, user has 2FA, session has no 2FA + $this->assertFalse( Two_Factor_Core::current_user_can_update_two_factor_options() ); + + // Set the session as 2FA. + $manager = WP_Session_Tokens::get_instance( $user->ID ); + $token = wp_get_session_token(); + $session = $manager->get( $token ); + + $session['two-factor-provider'] = 'Two_Factor_Dummy'; + $session['two-factor-login'] = time(); + $manager->update( $token, $session ); + + // Logged in, user has 2FA, session has 2FA "now". + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options() ); + + // Set the two factor login time to a minute less than the grace time. + $session['two-factor-login'] = time() - ( 11 * MINUTE_IN_SECONDS ); + $manager->update( $token, $session ); + + // Logged in, user has 2FA, session has 2FA that's longer than the grace period. Can Save, can't Display. + $this->assertTrue( Two_Factor_Core::current_user_can_update_two_factor_options( 'save' ) ); + $this->assertFalse( Two_Factor_Core::current_user_can_update_two_factor_options() ); + + // Set the two factor login time to a older than the saving grace time. + $session['two-factor-login'] = time() - ( 30 * MINUTE_IN_SECONDS ); + $manager->update( $token, $session ); + + // Logged in, user has 2FA, session has 2FA way past grace period. Can't Save, can't Display. + $this->assertFalse( Two_Factor_Core::current_user_can_update_two_factor_options( 'save' ) ); + $this->assertFalse( Two_Factor_Core::current_user_can_update_two_factor_options() ); + } + }