From 24d111a2198d1300711b3aad920688b3c21b591d Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Jul 2026 19:39:09 +0530 Subject: [PATCH] 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.' ); + } }