Skip to content

Commit

Permalink
Add rate limiting to two factor attempts. (#510)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
dd32 authored Feb 13, 2023
1 parent 5d91ba6 commit 4c9607b
Showing 2 changed files with 232 additions and 0 deletions.
142 changes: 142 additions & 0 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
@@ -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 '<div id="login_notice" class="message"><strong>';
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 '</strong></div>';
}
}

/**
* 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 '<div id="login_error"><strong>' . esc_html( $error_msg ) . '</strong><br /></div>';
} 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'] ) {
90 changes: 90 additions & 0 deletions tests/class-two-factor-core.php
Original file line number Diff line number Diff line change
@@ -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 );
}
}

0 comments on commit 4c9607b

Please sign in to comment.