Patch #485974: rate limit login attempts. From: damz --- common.inc | 13 +++++++++++ user/user.module | 34 +++++++++++++++++++++++++++-- user/user.test | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git includes/common.inc includes/common.inc index 1fb535f..f5b3680 100644 --- includes/common.inc +++ includes/common.inc @@ -1270,6 +1270,19 @@ function flood_register_event($name) { } /** + * Make the flood control mechanism forget about an event for the current visitor (hostname/IP). + * + * @param $name + * The name of an event. + */ +function flood_clear_event($name) { + db_delete('flood') + ->condition('event', $name) + ->condition('hostname', ip_address()) + ->execute(); +} + +/** * Check if the current visitor (hostname/IP) is allowed to proceed with the specified event. * * The user is allowed to proceed if he did not trigger the specified event more diff --git modules/user/user.module modules/user/user.module index 75613bf..668548b 100644 --- modules/user/user.module +++ modules/user/user.module @@ -1635,8 +1635,26 @@ function user_login_authenticate_validate($form, &$form_state) { */ function user_login_final_validate($form, &$form_state) { if (empty($form_state['uid'])) { - form_set_error('name', t('Sorry, unrecognized username or password. Have you forgotten your password?', array('@password' => url('user/password')))); - watchdog('user', 'Login attempt failed for %user.', array('%user' => $form_state['values']['name'])); + // Register a global failed login event. + flood_register_event('failed_login_attempt'); + + if (!empty($form_state['flood_control'])) { + form_set_error('name', t('Sorry, too many failed password attempts, you are temporarily prohibited from logging in. Please try again later.')); + + if ($form_state['flood_control'] > 0) { + // Register a per-user failed login event. + flood_register_event('failed_login_attempt_' . $form_state['flood_control']); + } + } + else { + form_set_error('name', t('Sorry, unrecognized username or password. Have you forgotten your password?', array('@password' => url('user/password')))); + watchdog('user', 'Login attempt failed for %user.', array('%user' => $form_state['values']['name'])); + } + } + else { + // Clear past failures for this IP address so as no to block users + // who might log in and out more than once in an hour. + flood_clear_event('failed_login_attempt'); } } @@ -1651,8 +1669,20 @@ function user_authenticate(&$form_state) { $password = trim($form_state['values']['pass']); // Name and pass keys are required. if (!empty($form_state['values']['name']) && !empty($password)) { + // Don't allow any login from that IP if the hourly limit has been reached. + if (!flood_is_allowed('failed_login_attempt', variable_get('user_login_hourly_limit', 50))) { + $form_state['flood_control'] = -1; + return; + } + $account = db_query("SELECT * FROM {users} WHERE name = :name AND status = 1", array(':name' => $form_state['values']['name']))->fetchObject(); if ($account) { + // Don't allow any login from that IP if the hourly limit for this user has been reached. + if (!flood_is_allowed('failed_login_attempt_' . $account->uid, variable_get('user_login_per_name_hourly_limit', 3))) { + $form_state['flood_control'] = $account->uid; + return; + } + // Allow alternate password hashing schemes. require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'includes/password.inc'); if (user_check_password($password, $account)) { diff --git modules/user/user.test modules/user/user.test index a661258..7072ce4 100644 --- modules/user/user.test +++ modules/user/user.test @@ -159,6 +159,69 @@ class UserValidationTestCase extends DrupalWebTestCase { } } +class UserLoginTestCase extends DrupalWebTestCase { + public static function getInfo() { + return array( + 'name' => 'User login', + 'description' => 'Ensure that login works as expected.', + 'group' => 'User', + ); + } + + /** + * Test login flood control. + */ + function testLoginFloodControl() { + $correct_password = $this->drupalCreateUser(array()); + variable_set('user_login_hourly_limit', 4); + + $incorrect_password = clone $correct_password; + $incorrect_password->pass_raw = $this->randomName(); + + // Try 2 failed logins. + for ($i = 0; $i < 2; $i++) { + $this->assertFailedLogin($incorrect_password, FALSE); + } + + // Try one successful login, it should reset the flood control mechanism. + $this->drupalLogin($correct_password); + $this->drupalLogout(); + + // Try 4 more failed logins, they should not trigger the flood control mechanism. + for ($i = 0; $i < 4; $i++) { + $this->assertFailedLogin($incorrect_password, FALSE); + } + + // The next login trial should result in a flood error message. + $this->assertFailedLogin($incorrect_password, TRUE); + + // A login with the correct password should also result in a flood error message. + $this->assertFailedLogin($correct_password, TRUE); + } + + /** + * Make an unsuccessful login attempt. + * + * @param $user + * The user. + * @param $flood + * Whether to expect or not that the flood control mechanism will be triggered. + */ + function assertFailedLogin($user, $flood = FALSE) { + $edit = array( + 'name' => $user->name, + 'pass' => $user->pass_raw, + ); + $this->drupalPost('user', $edit, t('Log in')); + if ($flood) { + $this->assertRaw(t('Sorry, too many failed password attempts, you are temporarily prohibited from logging in. Please try again later.')); + } + else { + $this->assertRaw(t('Sorry, unrecognized username or password. Have you forgotten your password?', array('@password' => url('user/password')))); + } + } +} + class UserCancelTestCase extends DrupalWebTestCase { public static function getInfo() { return array(