From 4c9607bdbe2c37a83ca165c3f26cb1e6bf685679 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 13 Feb 2023 14:18:56 +1000 Subject: [PATCH] Add rate limiting to two factor attempts. (#510) * Add a rate limit between two-factor login attempts. * Add a warning upon login that the user previously failed to complete the two-factor prompt. Co-authored-by: Ian Dunn --- class-two-factor-core.php | 142 ++++++++++++++++++++++++++++++++ tests/class-two-factor-core.php | 90 ++++++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 8faa53eb..20877a07 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -35,6 +35,20 @@ class Two_Factor_Core { */ const USER_META_NONCE_KEY = '_two_factor_nonce'; + /** + * The user meta key to store the last failed timestamp. + * + * @type string + */ + const USER_RATE_LIMIT_KEY = '_two_factor_last_login_failure'; + + /** + * The user meta key to store the number of failed login attempts. + * + * @var string + */ + const USER_FAILED_LOGIN_ATTEMPTS_KEY = '_two_factor_failed_login_attempts'; + /** * URL query paramater used for our custom actions. * @@ -600,6 +614,31 @@ public static function backup_2fa() { exit; } + /** + * Displays a message informing the user that their account has had failed login attempts. + * + * @param WP_User $user WP_User object of the logged-in user. + */ + public static function maybe_show_last_login_failure_notice( $user ) { + $last_failed_two_factor_login = (int) get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true ); + $failed_login_count = (int) get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ); + + if ( $last_failed_two_factor_login ) { + echo '
'; + printf( + _n( + 'WARNING: Your account has attempted to login without providing a valid two factor token. The last failed login occured %2$s ago. If this wasn\'t you, you should reset your password.', + 'WARNING: Your account has attempted to login %1$s times without providing a valid two factor token. The last failed login occured %2$s ago. If this wasn\'t you, you should reset your password.', + $failed_login_count, + 'two-factor' + ), + number_format_i18n( $failed_login_count ), + human_time_diff( $last_failed_two_factor_login, time() ) + ); + echo '
'; + } + } + /** * Generates the html form for the second step of the authentication process. * @@ -635,6 +674,8 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg if ( ! empty( $error_msg ) ) { echo '
' . esc_html( $error_msg ) . '
'; + } else { + self::maybe_show_last_login_failure_notice( $user ); } ?> @@ -864,6 +905,75 @@ public static function verify_login_nonce( $user_id, $nonce ) { return false; } + /** + * Determine the minimum wait between two factor attempts for a user. + * + * This implements an increasing backoff, requiring an attacker to wait longer + * each time to attempt to brute-force the login. + * + * @param WP_User $user The user being operated upon. + * @return int Time delay in seconds between login attempts. + */ + public static function get_user_time_delay( $user ) { + /** + * Filter the minimum time duration between two factor attempts. + * + * @param int $rate_limit The number of seconds between two factor attempts. + */ + $rate_limit = apply_filters( 'two_factor_rate_limit', 1 ); + + $user_failed_logins = get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ); + if ( $user_failed_logins ) { + $rate_limit = pow( 2, $user_failed_logins ) * $rate_limit; + + /** + * Filter the maximum time duration a user may be locked out from retrying two factor authentications. + * + * @param int $max_rate_limit The maximum number of seconds a user might be locked out for. Default 15 minutes. + */ + $max_rate_limit = apply_filters( 'two_factor_max_rate_limit', 15 * MINUTE_IN_SECONDS ); + + $rate_limit = min( $max_rate_limit, $rate_limit ); + } + + /** + * Filters the per-user time duration between two factor login attempts. + * + * @param int $rate_limit The number of seconds between two factor attempts. + * @param WP_User $user The user attempting to login. + */ + return apply_filters( 'two_factor_user_rate_limit', $rate_limit, $user ); + } + + /** + * Determine if a time delay between user two factor login attempts should be triggered. + * + * @since 0.8.0 + * + * @param WP_User $user The User. + * @return bool True if rate limit is okay, false if not. + */ + public static function is_user_rate_limited( $user ) { + $rate_limit = self::get_user_time_delay( $user ); + $last_failed = get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true ); + + $rate_limited = false; + if ( $last_failed && $last_failed + $rate_limit > time() ) { + $rate_limited = true; + } + + /** + * Filter whether this login attempt is rate limited or not. + * + * This allows for dedicated plugins to rate limit two factor login attempts + * based on their own rules. + * + * @param bool $rate_limited Whether the user login is rate limited. + * @param WP_User $user The user attempting to login. + */ + return apply_filters( 'two_factor_is_user_rate_limited', $rate_limited, $user ); + } + /** * Login form validation. * @@ -910,10 +1020,40 @@ public static function login_form_validate_2fa() { exit; } + // Rate limit two factor authentication attempts. + 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 ); + + $error = new WP_Error( + 'two_factor_too_fast', + sprintf( + __( 'ERROR: Too many invalid verification codes, you can try again in %s. This limit protects your account against automated attacks.', 'two-factor' ), + human_time_diff( $last_login + $time_delay ) + ) + ); + + do_action( 'wp_login_failed', $user->user_login, $error ); + + $login_nonce = self::create_login_nonce( $user->ID ); + if ( ! $login_nonce ) { + wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) ); + } + + self::login_html( $user, $login_nonce['key'], $_REQUEST['redirect_to'], esc_html( $error->get_error_message() ), $provider ); + exit; + } + // Ask the provider to verify the second factor. if ( true !== $provider->validate_authentication( $user ) ) { do_action( 'wp_login_failed', $user->user_login, new WP_Error( 'two_factor_invalid', __( 'ERROR: Invalid verification code.', 'two-factor' ) ) ); + // Store the last time a failed login occured. + update_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, time() ); + + // Store the number of failed login attempts. + update_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, 1 + (int) get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + $login_nonce = self::create_login_nonce( $user->ID ); if ( ! $login_nonce ) { wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) ); @@ -924,6 +1064,8 @@ public static function login_form_validate_2fa() { } self::delete_login_nonce( $user->ID ); + delete_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY ); + delete_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY ); $rememberme = false; if ( isset( $_REQUEST['rememberme'] ) && $_REQUEST['rememberme'] ) { diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 5986d766..1ee3dc5a 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -472,4 +472,94 @@ public function test_invalid_nonce_deletes_valid_nonce() { ); } + /** + * Test that the lockout time delay for two factor attempts is respected. + * + * @covers Two_Factor_Core::get_user_time_delay() + */ + public function test_get_user_time_delay() { + $user = $this->get_dummy_user(); + + // Default values, sans filters. + $rate_limit = 1; + $max_rate_limit = 15 * MINUTE_IN_SECONDS; + + // User has never logged in, validate the minimum time delay is in play. + $this->assertEquals( $rate_limit, Two_Factor_Core::get_user_time_delay( $user ) ); + + // Simulate 5 failed login attempts, and validate that the lockout is as expected. + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 5 ); + $this->assertEquals( pow( 2, 5 ) * $rate_limit, Two_Factor_Core::get_user_time_delay( $user ) ); + + // Simulate 100 failed login attempts, validate that the lockout is not greater than $max_rate_limit + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 100 ); + $this->assertEquals( $max_rate_limit, Two_Factor_Core::get_user_time_delay( $user ) ); + } + + /** + * Test that the user rate limit functions return as expected. + * + * @covers Two_Factor_Core::is_user_rate_limited() + */ + public function test_is_user_rate_limited() { + $user = $this->get_dummy_user(); + + // User has never logged in, validate they're not rate limited. + $this->assertFalse( Two_Factor_Core::is_user_rate_limited( $user ) ); + + // Failed login attempt at time(), user should be rate limited. + 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() ); + $this->assertTrue( Two_Factor_Core::is_user_rate_limited( $user ) ); + + // 8 failed logins a minite ago, user should be rate limited. + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 8 ); + update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() - MINUTE_IN_SECONDS ); + $this->assertTrue( Two_Factor_Core::is_user_rate_limited( $user ) ); + + // 8 failed logins an hour ago, user should not be rate limited. + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 8 ); + update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() - HOUR_IN_SECONDS ); + $this->assertFalse( Two_Factor_Core::is_user_rate_limited( $user ) ); + } + + /** + * Test that the "invalid login attempts have occurred" login notice works as expected. + * + * @covers Two_Factor_Core::maybe_show_last_login_failure_notice() + */ + public function test_maybe_show_last_login_failure_notice() { + $user = $this->get_dummy_user(); + + // User has never logged in, validate they're not rate limited. + ob_start(); + Two_Factor_Core::maybe_show_last_login_failure_notice( $user ); + $contents = ob_get_clean(); + + $this->assertEmpty( $contents ); + + // A failed login attempts 5 seconds ago. + // Should throw a notice, even though it's the current user, it will only be displayed if there's no other 2FA errors. + 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() - 5 ); + ob_start(); + Two_Factor_Core::maybe_show_last_login_failure_notice( $user ); + $contents = ob_get_clean(); + + $this->assertNotEmpty( $contents ); + $this->assertStringNotContainsString( '1 times', $contents ); + $this->assertStringContainsString( 'login without providing a valid two factor token', $contents ); + + // 5 failed login attempts 5 hours ago - User should be informed. + $five_hours_ago = time() - 5 * HOUR_IN_SECONDS; + 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, $five_hours_ago ); + ob_start(); + Two_Factor_Core::maybe_show_last_login_failure_notice( $user ); + $contents = ob_get_clean(); + + $this->assertNotEmpty( $contents ); + $this->assertStringContainsString( '5 times', $contents ); + $this->assertStringContainsString( human_time_diff( $five_hours_ago ), $contents ); + } }