From 24d111a2198d1300711b3aad920688b3c21b591d Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Jul 2026 19:39:09 +0530 Subject: [PATCH 1/2] Introduce two_factor_revalidate_session action hook This hook allows third-party plugins to easily trigger a 2FA re-authentication flow for sensitive actions. Fixes #644. --- class-two-factor-core.php | 36 +++++++++++++++++++ tests/class-two-factor-core.php | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 1f039866..e238e21a 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -147,6 +147,8 @@ public static function add_hooks( $compat ) { add_action( 'admin_init', array( __CLASS__, 'trigger_user_settings_action' ) ); add_filter( 'two_factor_providers', array( __CLASS__, 'enable_dummy_method_for_debug' ) ); + add_action( 'two_factor_revalidate_session', array( __CLASS__, 'action_revalidate_session' ), 10, 2 ); + // Add Settings link to plugin action links. add_filter( 'plugin_action_links_' . plugin_basename( TWO_FACTOR_DIR . 'two-factor.php' ), array( __CLASS__, 'add_settings_action_link' ) ); @@ -603,6 +605,40 @@ public static function trigger_user_settings_action() { } } + /** + * Require a recent Two Factor session. + * + * Triggers a redirect to the two-factor revalidation screen if the current session + * hasn't been validated within the specified time window. + * + * @since NEXT + * + * @param int $time_window The grace period in seconds. Default 300 (5 minutes). + * @param string $redirect_to The URL to redirect back to after revalidation. Defaults to the current request URI. + * + * @return void + */ + public static function action_revalidate_session( $time_window = 300, $redirect_to = '' ) { + if ( ! is_user_logged_in() || ! self::is_user_using_two_factor() ) { + return; + } + + $last_2fa = self::is_current_user_session_two_factor(); + $is_recent = $last_2fa && ( time() - $last_2fa < (int) $time_window ); + + if ( ! $is_recent ) { + if ( empty( $redirect_to ) && isset( $_SERVER['REQUEST_URI'] ) ) { + $redirect_to = wp_unslash( $_SERVER['REQUEST_URI'] ); + } + + $reauth_url = self::get_user_two_factor_revalidate_url(); + $reauth_url = add_query_arg( 'redirect_to', urlencode( $redirect_to ), $reauth_url ); + + wp_safe_redirect( $reauth_url ); + exit; + } + } + /** * Keep track of all the authentication cookies that need to be * invalidated before the second factor authentication. diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 38052106..77a43146 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -2707,4 +2707,66 @@ public function test_add_settings_action_link() { $this->assertStringContainsString( 'Settings', $first ); $this->assertStringContainsString( 'options-general.php', $first ); } + + /** + * @covers Two_Factor_Core::action_revalidate_session + */ + public function test_action_revalidate_session_redirects_when_not_recent() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy' ) ); + update_user_meta( $user_id, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Dummy' ); + + // Simulate an old session (20 minutes ago) + $old_time = time() - ( 20 * MINUTE_IN_SECONDS ); + Two_Factor_Core::update_current_user_session( + array( + 'two-factor-provider' => 'Two_Factor_Dummy', + 'two-factor-login' => $old_time, + ) + ); + + $_SERVER['REQUEST_URI'] = '/wp-admin/post.php?post=1&action=edit'; + + // Catch the redirect exception + $redirected = false; + try { + Two_Factor_Core::action_revalidate_session( 300, '/custom-redirect/' ); + } catch ( Two_Factor_Redirect_Exception $e ) { + $redirected = true; + $location = $e->getMessage(); + $this->assertStringContainsString( 'action=revalidate_2fa', $location ); + $this->assertStringContainsString( 'redirect_to=%2Fcustom-redirect%2F', $location ); + } + + $this->assertTrue( $redirected, 'Expected wp_safe_redirect to throw Two_Factor_Redirect_Exception.' ); + } + + /** + * @covers Two_Factor_Core::action_revalidate_session + */ + public function test_action_revalidate_session_bypasses_when_recent() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy' ) ); + + // Simulate a recent session (1 minute ago) + $recent_time = time() - MINUTE_IN_SECONDS; + Two_Factor_Core::update_current_user_session( + array( + 'two-factor-provider' => 'Two_Factor_Dummy', + 'two-factor-login' => $recent_time, + ) + ); + + // Should not throw an exception (no redirect) + $exception = false; + try { + Two_Factor_Core::action_revalidate_session( 300 ); + } catch ( Two_Factor_Redirect_Exception $e ) { + $exception = true; + } + + $this->assertFalse( $exception, 'Expected no redirect for a recent session.' ); + } } From 4db3286ce910af9c6fea24d04f51a449f9c6ea72 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Jul 2026 21:49:50 +0530 Subject: [PATCH 2/2] Fix #705: Persist custom fields during 2FA challenge This safely copies unknown variables into hidden fields on the 2FA interstitial screen, ensuring third-party login forms function properly without leaking standard WordPress fields or plaintext passwords. --- class-two-factor-core.php | 61 +++++++++++++++++++++++++++++++++ tests/class-two-factor-core.php | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index e238e21a..8aa9ef7d 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -1197,6 +1197,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg + authentication_page( $user ); ?> @@ -2660,5 +2661,65 @@ public static function filter_session_information( $session, $user_id ) { return $session; } + + /** + * Output custom $_POST fields as hidden inputs. + * + * Iterates over $_POST and outputs hidden inputs for fields added by third-party plugins, + * ensuring that standard WordPress and Two-Factor fields (especially sensitive ones like `pwd`) + * are explicitly ignored. + * + * @since 0.17.0 + */ + private static function print_custom_post_fields() { + $blocklist = array( + // Standard WP Login fields. + 'log', + 'pwd', + 'wp-submit', + 'redirect_to', + 'rememberme', + 'interim-login', + 'testcookie', + '_wpnonce', + '_wp_http_referer', + // Additional common login fields (e.g., WooCommerce, custom forms). + 'password', + 'user_pass', + 'username', + 'user_login', + // Two Factor specific fields. + 'provider', + 'wp-auth-id', + 'wp-auth-nonce', + 'action', + ); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + foreach ( $_POST as $key => $value ) { + if ( in_array( $key, $blocklist, true ) ) { + continue; + } + self::print_hidden_inputs( $key, $value ); + } + } + + /** + * Recursively output hidden inputs for a given key/value. + * + * @since 0.17.0 + * + * @param string $name Input name. + * @param string|array $value Input value. + */ + private static function print_hidden_inputs( $name, $value ) { + if ( is_array( $value ) ) { + foreach ( $value as $k => $v ) { + self::print_hidden_inputs( $name . '[' . $k . ']', $v ); + } + } else { + echo '' . "\n"; + } + } } diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 77a43146..20cf487b 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -2769,4 +2769,65 @@ public function test_action_revalidate_session_bypasses_when_recent() { $this->assertFalse( $exception, 'Expected no redirect for a recent session.' ); } + + /** + * @covers Two_Factor_Core::print_custom_post_fields + */ + public function test_print_custom_post_fields_includes_custom_fields() { + $original_post = $_POST; + $_POST = array( + 'custom_field_1' => 'value1', + 'custom_array' => array( + 'key1' => 'val1', + 'key2' => 'val2', + ), + ); + + $method = new ReflectionMethod( 'Two_Factor_Core', 'print_custom_post_fields' ); + $method->setAccessible( true ); + + ob_start(); + $method->invoke( null ); + $output = ob_get_clean(); + + $_POST = $original_post; + + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( '', $output ); + } + + /** + * @covers Two_Factor_Core::print_custom_post_fields + */ + public function test_print_custom_post_fields_excludes_blocked_fields() { + $original_post = $_POST; + $_POST = array( + 'pwd' => 'my_secret_password', + 'password' => 'my_other_password', + 'log' => 'admin', + 'user_pass' => 'secret', + 'rememberme' => '1', + 'custom_ok' => 'allowed', + ); + + $method = new ReflectionMethod( 'Two_Factor_Core', 'print_custom_post_fields' ); + $method->setAccessible( true ); + + ob_start(); + $method->invoke( null ); + $output = ob_get_clean(); + + $_POST = $original_post; + + $this->assertStringContainsString( '', $output ); + $this->assertStringNotContainsString( 'my_secret_password', $output ); + $this->assertStringNotContainsString( 'my_other_password', $output ); + $this->assertStringNotContainsString( 'secret', $output ); + $this->assertStringNotContainsString( 'pwd', $output ); + $this->assertStringNotContainsString( 'password', $output ); + $this->assertStringNotContainsString( 'log', $output ); + $this->assertStringNotContainsString( 'user_pass', $output ); + $this->assertStringNotContainsString( 'rememberme', $output ); + } }