diff --git a/class-two-factor-core.php b/class-two-factor-core.php index bcd89301..28c31d9a 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -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 ); @@ -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. diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index a540628b..c898234a 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -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. *