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
Show file tree
Hide file tree
Changes from 14 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
184 changes: 184 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 All @@ -49,6 +63,13 @@ class Two_Factor_Core {
*/
const USER_SETTINGS_ACTION_NONCE_QUERY_ARG = '_two_factor_action_nonce';

/**
* Namespace for plugin rest api endpoints.
*
* @var string
*/
const REST_NAMESPACE = 'two-factor/1.0';

/**
* Keep track of all the password-based authentication sessions that
* need to invalidated before the second factor authentication.
Expand Down Expand Up @@ -593,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.
*/
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 +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 );
}
?>

Expand Down Expand Up @@ -857,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 ) {
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', 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.
*
Expand Down Expand Up @@ -903,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: 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 +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'] ) {
Expand Down Expand Up @@ -1076,6 +1225,41 @@ public static function user_two_factor_options( $user ) {
do_action( 'show_user_security_settings', $user );
}

/**
* Enable a provider for a user.
*
* @param int $user_id The ID of the user.
* @param string $new_provider The name of the provider class.
*
* @return bool True if the provider was enabled, false otherwise.
*/
public static function enable_provider_for_user( $user_id, $new_provider ) {
$available_providers = self::get_providers();

if ( ! array_key_exists( $new_provider, $available_providers ) ) {
return false;
}

$user = get_userdata( $user_id );
$enabled_providers = self::get_enabled_providers_for_user( $user );

if ( in_array( $new_provider, $enabled_providers ) ) {
return true;
}

$enabled_providers[] = $new_provider;
$enabled = update_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, $enabled_providers );

// Primary provider must be enabled.
$has_primary = is_object( self::get_primary_provider_for_user( $user_id ) );

if ( ! $has_primary ) {
$has_primary = update_user_meta( $user_id, self::PROVIDER_USER_META_KEY, $new_provider );
}

return $enabled && $has_primary;
}

/**
* Update the user meta value.
*
Expand Down
Loading