Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1161,6 +1197,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg
<input type="hidden" name="redirect_to" value="<?php echo esc_attr( $redirect_to ); ?>" />
<?php } ?>
<input type="hidden" name="rememberme" id="rememberme" value="<?php echo esc_attr( $rememberme ); ?>" />
<?php self::print_custom_post_fields(); ?>

<?php $provider->authentication_page( $user ); ?>
</form>
Expand Down Expand Up @@ -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 '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '" />' . "\n";
}
}
}

123 changes: 123 additions & 0 deletions tests/class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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( '<input type="hidden" name="custom_field_1" value="value1" />', $output );
$this->assertStringContainsString( '<input type="hidden" name="custom_array[key1]" value="val1" />', $output );
$this->assertStringContainsString( '<input type="hidden" name="custom_array[key2]" value="val2" />', $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( '<input type="hidden" name="custom_ok" value="allowed" />', $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 );
}
}
Loading