Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rate limiting to two factor attempts. #510

Merged
merged 17 commits into from
Feb 13, 2023
Merged
Changes from 6 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
136 changes: 136 additions & 0 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -593,6 +607,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.
*/
dd32 marked this conversation as resolved.
Show resolved Hide resolved
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.
*
Expand Down Expand Up @@ -628,6 +667,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 );
}
?>

Expand Down Expand Up @@ -857,6 +898,69 @@ 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 ) {
dd32 marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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', 5 );
dd32 marked this conversation as resolved.
Show resolved Hide resolved

$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;

// Limit to 1 hour maximum time delay.
$rate_limit = min( HOUR_IN_SECONDS, $rate_limit );
dd32 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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 );
}

/**
* Enforce a time delay between user two factor login attempts.
dd32 marked this conversation as resolved.
Show resolved Hide resolved
*
* @since 0.1-dev
dd32 marked this conversation as resolved.
Show resolved Hide resolved
*
* @param WP_User $user The User.
* @return bool True if rate limit is okay, false if not.
*/
public static function check_user_rate_limit( $user ) {
dd32 marked this conversation as resolved.
Show resolved Hide resolved
$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.
*
Expand Down Expand Up @@ -903,10 +1007,40 @@ public static function login_form_validate_2fa() {
exit;
}

// Rate limit two factor authentication attempts.
if ( true !== self::check_user_rate_limit( $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: Whoa there, slow down! You can try again in %s.', 'two-factor' ),
dd32 marked this conversation as resolved.
Show resolved Hide resolved
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' ) );
Expand All @@ -917,6 +1051,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'] ) {
Expand Down