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 );
+ }
}