diff --git a/class-two-factor-core.php b/class-two-factor-core.php
index 1f039866..8aa9ef7d 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.
@@ -1161,6 +1197,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg
+
authentication_page( $user ); ?>
@@ -2624,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 38052106..20cf487b 100644
--- a/tests/class-two-factor-core.php
+++ b/tests/class-two-factor-core.php
@@ -2707,4 +2707,127 @@ 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.' );
+ }
+
+ /**
+ * @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 );
+ }
}