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
17 changes: 11 additions & 6 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -1688,18 +1688,18 @@ 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 ) ) {
// Invalidate any provider token to prevent reuse after rate limiting.
if ( method_exists( $provider, 'delete_token' ) ) {
$provider->delete_token( $user->ID );
}

$time_delay = self::get_user_time_delay( $user );
$last_login = get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true );

Expand All @@ -1713,6 +1713,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 @@ -644,6 +644,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->assertFalse( $provider->get_user_token( $user->ID ), 'Token is invalidated when rate limited' );
}

/**
* 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->assertFalse( $provider->user_has_token( $user->ID ), 'Token is invalidated when rate limited' );
}

/**
* Test that switching providers while rate-limited invalidates 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->assertFalse( $email_provider->user_has_token( $user->ID ), 'Email token is invalidated 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->assertFalse( $provider->user_has_token( $user->ID ), 'Token is invalidated during rate-limited revalidation' );
}

/**
* Test that the "invalid login attempts have occurred" login notice works as expected.
*
Expand Down
Loading