Patch #485974: rate limit login attempts.

From: damz <damz@damz-dev.local>


---
 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. <a href="@password">Have you forgotten your password?</a>', 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. <a href="@password">Have you forgotten your password?</a>', 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. <a href="@password">Have you forgotten your password?</a>', array('@password' => url('user/password'))));
+    }
+  }
+}
+
 class UserCancelTestCase extends DrupalWebTestCase {
   public static function getInfo() {
     return array(
