4.7 Drupal flood 行为溢出控制机制详解

Drupal项目中可能会时常遇到登录时尝试次数过多被提示如下的情况:

Sorry, there have been more than 5 failed login attempts for this account. It is temporarily blocked. Try again later or request a new password.

就是你尝试过多的登录该用户名的次数了,这个用户名被临时禁止登录了,待会你再试试或者请求新密码。当然只是当前访问的IP被禁止用该用户名登录,其它IP去登录还是正常的。默认对IP+用户名的限制是5次,超过了就会被禁止一定时间,除非去手动清除flood表里的记录。

那么这个flood到底是个什么东西呢?drupal也没给我们提供任何后端配置页面。它除了对登录行为做控制,还能做些什么呢?下面就给大家介绍下:

Drupal提供的flood机制,通俗的理解就是:在一定的时间内,某个对象只能做多少次某个行为。我起了个中文名叫:行为溢出控制。

Drupal默认提供了两个行为溢出控制:user login和contact。我们以用户登录为例,通过搜索上面的部分error提示文字,可以定位到用户登录行为使用flood的位置:

function user_login_final_validate($form, &$form_state) {
  if (empty($form_state['uid'])) {
    // Always register an IP-based failed login event.
    flood_register_event('failed_login_attempt_ip', variable_get('user_failed_login_ip_window', 3600));
    // Register a per-user failed login event.
    if (isset($form_state['flood_control_user_identifier'])) {
      flood_register_event('failed_login_attempt_user', variable_get('user_failed_login_user_window', 21600), $form_state['flood_control_user_identifier']);
    }

    if (isset($form_state['flood_control_triggered'])) {
      if ($form_state['flood_control_triggered'] == 'user') {
        form_set_error('name', format_plural(variable_get('user_failed_login_user_limit', 5), 'Sorry, there has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', 'Sorry, there have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
      }
      else {
        // We did not find a uid, so the limit is IP-based.
        form_set_error('name', t('Sorry, too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
      }
    }
    else {
      // Use $form_state['input']['name'] here to guarantee that we send
      // exactly what the user typed in. $form_state['values']['name'] may have
      // been modified by validation handlers that ran earlier than this one.
      $query = isset($form_state['input']['name']) ? array('name' => $form_state['input']['name']) : array();
      form_set_error('name', t('Sorry, unrecognized username or password. <a href="@password">Have you forgotten your password?</a>', array('@password' => url('user/password', array('query' => $query)))));
      watchdog('user', 'Login attempt failed for %user.', array('%user' => $form_state['values']['name']));
    }
  }
  elseif (isset($form_state['flood_control_user_identifier'])) {
    // Clear past failures for this user so as not to block a user who might
    // log in and out more than once in an hour.
    flood_clear_event('failed_login_attempt_user', $form_state['flood_control_user_identifier']);
  }
}

这个函数是登录表单提交时的最终验证步骤的调用方法, 我们可以很明显的看到有两个跟flood有关的函数调用:flood_register_event 和 flood_clear_event。下面我们把函数体贴出来看下:

/**
 * Registers an event for the current visitor to the flood control mechanism.
 *
 * @param $name
 *   The name of an event.
 * @param $window
 *   Optional number of seconds before this event expires. Defaults to 3600 (1
 *   hour). Typically uses the same value as the flood_is_allowed() $window
 *   parameter. Expired events are purged on cron run to prevent the flood table
 *   from growing indefinitely.
 * @param $identifier
 *   Optional identifier (defaults to the current user's IP address).
 */
function flood_register_event($name, $window = 3600, $identifier = NULL) {
  if (!isset($identifier)) {
    $identifier = ip_address();
  }
  db_insert('flood')
    ->fields(array(
      'event' => $name,
      'identifier' => $identifier,
      'timestamp' => REQUEST_TIME,
      'expiration' => REQUEST_TIME + $window,
    ))
    ->execute();
}

flood_register_event是用于注册flood事件,该函数的第一个参数是事件名称,登录这里用到了两个事件,1.failed_login_attempt_ip 2. failed_login_attempt_user。 意思是分别对ip和用户名的登录行为做溢出控制。

第二个参数是窗口时间,可以理解为该事件类型的窗口时间,即我们要控制多长时间内的操作频次。

第三个参数是对象标识,比如通常我们控制的是ip地址的操作频率,在登录行为这里的控制就是如果客户端IP一直在尝试登录但是失败了一定次数,这个ip就会被禁止登录行为一段时间,默认是50次。用户登录这里还用到了IP+用户名的标识(可以通过修改变量:user_failed_login_identifier_uid_only的值变成对用户名的全局控制,但是这个控制方式副作用太大,所以一般不采用),就是如果客户端一直尝试某个用户名的登录失败,该用户名+IP为组合的标识也会被封禁一段时间,意思就是在一定时间内这个ip不能再用这个用户名来尝试登录了。

还有一个函数是:flood_clear_event

/**
 * Makes the flood control mechanism forget an event for the current visitor.
 *
 * @param $name
 *   The name of an event.
 * @param $identifier
 *   Optional identifier (defaults to the current user's IP address).
 */
function flood_clear_event($name, $identifier = NULL) {
  if (!isset($identifier)) {
    $identifier = ip_address();
  }
  db_delete('flood')
    ->condition('event', $name)
    ->condition('identifier', $identifier)
    ->execute();
}

顾名思义,就是清除溢出记录,根据参数来看,清除的是某个标识的某个事件的溢出记录。用户登录这里是在用户登录成功后,将该用户名的标识的登录事件清空,意思是你登录成功了说明你是正常用户,就不给你累计异常了。

有了注册和清除,那么还缺判断,就是我们应该需要在登录时判断到底当前的标识对象有没有超过溢出控制频次?

通过对用户登录过程的验证调用顺序,我们看到,第二步登录验证的方法:user_login_authenticate_validate,这个函数里使用了flood_is_allowed函数来实现判断。

/**
 * A validate handler on the login form. Check supplied username/password
 * against local users table. If successful, $form_state['uid']
 * is set to the matching user ID.
 */
function user_login_authenticate_validate($form, &$form_state) {
  $password = trim($form_state['values']['pass']);
  if (!empty($form_state['values']['name']) && strlen(trim($password)) > 0) {
    // Do not allow any login from the current user's IP if the limit has been
    // reached. Default is 50 failed attempts allowed in one hour. This is
    // independent of the per-user limit to catch attempts from one IP to log
    // in to many different user accounts.  We have a reasonably high limit
    // since there may be only one apparent IP for all users at an institution.
    if (!flood_is_allowed('failed_login_attempt_ip', variable_get('user_failed_login_ip_limit', 50), variable_get('user_failed_login_ip_window', 3600))) {
      $form_state['flood_control_triggered'] = 'ip';
      return;
    }
    $account = db_query("SELECT * FROM {users} WHERE name = :name AND status = 1", array(':name' => $form_state['values']['name']))->fetchObject();
    if ($account) {
      if (variable_get('user_failed_login_identifier_uid_only', FALSE)) {
        // Register flood events based on the uid only, so they apply for any
        // IP address. This is the most secure option.
        $identifier = $account->uid;
      }
      else {
        // The default identifier is a combination of uid and IP address. This
        // is less secure but more resistant to denial-of-service attacks that
        // could lock out all users with public user names.
        $identifier = $account->uid . '-' . ip_address();
      }
      $form_state['flood_control_user_identifier'] = $identifier;

      // Don't allow login if the limit for this user has been reached.
      // Default is to allow 5 failed attempts every 6 hours.
      if (!flood_is_allowed('failed_login_attempt_user', variable_get('user_failed_login_user_limit', 5), variable_get('user_failed_login_user_window', 21600), $identifier)) {
        $form_state['flood_control_triggered'] = 'user';
        return;
      }
    }
    // We are not limited by flood control, so try to authenticate.
    // Set $form_state['uid'] as a flag for user_login_final_validate().
    $form_state['uid'] = user_authenticate($form_state['values']['name'], $password);
  }
}
/**
 * Checks whether a user is allowed to proceed with the specified event.
 *
 * Events can have thresholds saying that each user can only do that event
 * a certain number of times in a time window. This function verifies that the
 * current user has not exceeded this threshold.
 *
 * @param $name
 *   The unique name of the event.
 * @param $threshold
 *   The maximum number of times each user can do this event per time window.
 * @param $window
 *   Number of seconds in the time window for this event (default is 3600
 *   seconds, or 1 hour).
 * @param $identifier
 *   Unique identifier of the current user. Defaults to their IP address.
 *
 * @return
 *   TRUE if the user is allowed to proceed. FALSE if they have exceeded the
 *   threshold and should not be allowed to proceed.
 */
function flood_is_allowed($name, $threshold, $window = 3600, $identifier = NULL) {
  if (!isset($identifier)) {
    $identifier = ip_address();
  }
  $number = db_query("SELECT COUNT(*) FROM {flood} WHERE event = :event AND identifier = :identifier AND timestamp > :timestamp", array(
    ':event' => $name,
    ':identifier' => $identifier,
    ':timestamp' => REQUEST_TIME - $window))
    ->fetchField();
  return ($number < $threshold);
}

主要看下第二个参数 threshold,之前没有出现过,这个单词译为:阈值。顾名思义就是某个标识在某个事件的窗口时间内可进行该事件行为的最大频次。

像窗口时间和阈值,每个事件有是有单独的变量控制的,比如用户登录的窗口时间(以ip标识为例):variable_get('user_failed_login_ip_window', 3600),阈值:variable_get('user_failed_login_ip_limit', 50),含义就是:在1小时内,每个ip只能进行50次登录行为。超过这个阈值就会被封禁。

那么封禁多长时间呢?这个不是固定的时间,大家读一下flood_is_allowed函数体就了解了,就是去看下在当前时间往前的窗口时间内,累计的溢出记录的数量有没有达到阈值,若达到了就阻止接下来的行为,若没有达到,就通过,继续往下执行。

那至于你啥时候被解禁,还得看你操作的时间分布,不过假如是一口气操作的,那基本上就是在一个窗口时间后才能继续尝试。比如ip登录默认就是在1小时后了。

所以说总结下用户登录这里的溢出控制流程就是(以ip标识为例): 在登录验证的流程里,先使用flood_is_allowed来判断当前ip在过去1小时内的登录行为是否累计达到了50次,若已达到则禁止登录,若未达到,则使用flood_register_event新增一条溢出记录,然后放行。


Druapl的这个功能隐藏的还挺深,没有被深入挖掘出来,在官方网站搜索注意到有两个第三方模块提供了登录和contact的窗口时间和频次的配置,以及溢出记录的清除管理。flood_unblock模块 和 flood_control模块.

image

image

那么通过对flood的深入了解,我们应该可以想到可以在其它地方也能用到它,比如我们对常见的ip攻击位置做一下封禁机制,比如想对注册做一下防范,就可以设置比如1小时内一个ip只能注册10个用户。还可以对短信验证码发送做判断,比如1小时内一个ip只能发送10次验证码。

我们可以参考flood_unblock和flood_control,自己做一个后端配置各种事件的窗口时间和阈值,还有管理flood记录的页面,可以取消封禁。

总结一下:flood机制主要是用来对ip或者用户进行敏感行为的频次控制。

扩展延伸:drupal提供的flood机制有一个明显的问题就是配置扩展不方便,新增一个事件的控制,必须是到该事件相应的关键函数里去调用,这也是为啥没有能够提供后端配置管理功能的原因,没法在后台统一管理。而如果能改成对url统一进行判断就很方便了,跟web应用防火墙提供的配置很像,比如阿里云应用防火墙提供的cc攻击防范规则自定义功能,但是前提是你的行为是通过接口方式提供的,比如登录注册都是前端调用你提供的ajax url,而不是直接用的drupal表单提交。


评论 (写第一个评论)