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
12 changes: 6 additions & 6 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -1841,17 +1841,12 @@ public static function process_provider( $provider, $user, $is_post_request ) {
);
}

// Allow the provider to re-send codes, etc.
if ( true === $provider->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.
// Rate limit two factor authentication attempts, including pre-processing (e.g. resend).
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 );
Expand All @@ -1866,6 +1861,11 @@ public static function process_provider( $provider, $user, $is_post_request ) {
);
}

// Allow the provider to re-send codes, etc.
if ( true === $provider->pre_process_authentication( $user ) ) {
return false;
}

// Ask the provider to verify the second factor.
if ( true !== $provider->validate_authentication( $user ) ) {
// Store the last time a failed login occurred.
Expand Down
129 changes: 129 additions & 0 deletions tests/class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,135 @@ public function test_is_user_rate_limited() {
$this->assertFalse( Two_Factor_Core::is_user_rate_limited( $user ) );
}

/**
* Test that email resend requests are blocked while rate limited.
*
* @covers Two_Factor_Core::process_provider()
*/
public function test_process_provider_blocks_email_resend_while_rate_limited() {
$user = $this->get_dummy_user( array( 'Two_Factor_Email' => 'Two_Factor_Email' ) );
$provider = Two_Factor_Email::get_instance();

$provider->generate_token( $user->ID );

update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 1 );
update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() );

$_REQUEST[ Two_Factor_Email::INPUT_NAME_RESEND_CODE ] = 1;

$result = Two_Factor_Core::process_provider( $provider, $user, true );

unset( $_REQUEST[ Two_Factor_Email::INPUT_NAME_RESEND_CODE ] );

$this->assertWPError( $result );
$this->assertSame( 'two_factor_too_fast', $result->get_error_code() );
$this->assertTrue( $provider->user_has_token( $user->ID ), 'Token is preserved when resend is blocked, to prevent auto-send on form re-render' );
}

/**
* Test that rate limiting invalidates the email token on validation attempts.
*
* @covers Two_Factor_Core::process_provider()
*/
public function test_process_provider_invalidates_email_token_when_rate_limited() {
$user = $this->get_dummy_user( array( 'Two_Factor_Email' => 'Two_Factor_Email' ) );
$provider = Two_Factor_Email::get_instance();

$provider->generate_token( $user->ID );

$this->assertTrue( $provider->user_has_token( $user->ID ), 'Token exists before rate limiting' );

// Simulate a rate-limited state.
update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 3 );
update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() );

$result = Two_Factor_Core::process_provider( $provider, $user, true );

$this->assertWPError( $result );
$this->assertSame( 'two_factor_too_fast', $result->get_error_code() );
$this->assertTrue( $provider->user_has_token( $user->ID ), 'Token is preserved when rate limited to avoid triggering re-send on form re-render' );
}

/**
* Test that switching providers while rate-limited preserves the email token.
*
* If a user fails on TOTP triggering rate limiting, then switches back
* to email, the rate-limit gate should invalidate the email token.
*
* @covers Two_Factor_Core::process_provider()
*/
public function test_process_provider_invalidates_email_token_on_provider_switch_while_rate_limited() {
$user = $this->get_dummy_user( array( 'Two_Factor_Email' => 'Two_Factor_Email' ) );
$email_provider = Two_Factor_Email::get_instance();

// Generate an email token.
$email_provider->generate_token( $user->ID );
$this->assertTrue( $email_provider->user_has_token( $user->ID ), 'Email token exists before TOTP failures' );

// Simulate rate-limited state from TOTP failures.
update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 5 );
update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() );

// User switches back to email provider while rate-limited.
$result = Two_Factor_Core::process_provider( $email_provider, $user, true );

$this->assertWPError( $result );
$this->assertSame( 'two_factor_too_fast', $result->get_error_code() );
$this->assertTrue( $email_provider->user_has_token( $user->ID ), 'Email token is preserved when rate-limited via another provider' );
}

/**
* Test that GET requests pass through without rate-limit side effects.
*
* Page reloads should not trigger rate limiting, token deletion, or
* error messages — even when the user is rate-limited.
*
* @covers Two_Factor_Core::process_provider()
*/
public function test_process_provider_get_request_bypasses_rate_limit() {
$user = $this->get_dummy_user( array( 'Two_Factor_Email' => 'Two_Factor_Email' ) );
$provider = Two_Factor_Email::get_instance();

$provider->generate_token( $user->ID );

$this->assertTrue( $provider->user_has_token( $user->ID ), 'Token exists before GET request' );

// Simulate a rate-limited state.
update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 3 );
update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() );

// GET request (is_post_request = false).
$result = Two_Factor_Core::process_provider( $provider, $user, false );

$this->assertFalse( $result, 'GET request returns false, not WP_Error' );
$this->assertTrue( $provider->user_has_token( $user->ID ), 'Token survives GET request while rate-limited' );
}

/**
* Test that rate limiting applies during the revalidation flow.
*
* @covers Two_Factor_Core::process_provider()
*/
public function test_process_provider_rate_limits_revalidation() {
$user = $this->get_dummy_user( array( 'Two_Factor_Email' => 'Two_Factor_Email' ) );
$provider = Two_Factor_Email::get_instance();

$provider->generate_token( $user->ID );

$this->assertTrue( $provider->user_has_token( $user->ID ), 'Token exists before revalidation' );

// Simulate rate-limited state from prior failures.
update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 3 );
update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() );

// Revalidation is also a POST through process_provider.
$result = Two_Factor_Core::process_provider( $provider, $user, true );

$this->assertWPError( $result );
$this->assertSame( 'two_factor_too_fast', $result->get_error_code() );
$this->assertTrue( $provider->user_has_token( $user->ID ), 'Token is preserved during rate-limited revalidation' );
}

/**
* Test that the "invalid login attempts have occurred" login notice works as expected.
*
Expand Down
42 changes: 42 additions & 0 deletions tests/providers/class-two-factor-email.php
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,48 @@ public function test_authentication_page_with_existing_token() {
$this->assertStringContainsString( 'two-factor-email-code', $output );
}

/**
* Verify that a rate-limited POST does not trigger a new email when login_html()
* re-renders the form via authentication_page().
*
* process_provider() blocks the attempt and returns WP_Error; login_html() then
* calls authentication_page() to redisplay the form. If the token were deleted by
* the rate-limit gate, authentication_page() would see no token and send a new email
* on every blocked submission — recreating the flooding vector the rate-limit prevents.
*
* @covers Two_Factor_Email::authentication_page
* @covers Two_Factor_Core::process_provider
*/
public function test_authentication_page_does_not_send_email_when_rate_limited() {
$user = self::factory()->user->create_and_get();
Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Email' );

// Generate an initial token (simulates the user already having a code).
$this->provider->generate_token( $user->ID );
$this->assertTrue( $this->provider->user_has_token( $user->ID ) );

// Force the rate-limited state.
update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 3 );
update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() );

// Step 1: process_provider() blocks the POST (as in login_form_validate_2fa).
$result = Two_Factor_Core::process_provider( $this->provider, $user, true );
$this->assertWPError( $result );
$this->assertSame( 'two_factor_too_fast', $result->get_error_code() );

// Step 2: login_html() calls authentication_page() to re-render the form.
$emails_before = count( self::$mockmailer->mock_sent );
ob_start();
$this->provider->authentication_page( $user );
ob_get_clean();

$this->assertCount(
$emails_before,
self::$mockmailer->mock_sent,
'No new email must be sent when re-rendering the form after a rate-limited POST'
);
}

/**
* Verify user_options outputs the user's email address.
*
Expand Down
Loading