You are here

login_security.module in Login Security 7

Login Security

Copyrighted by ilo@reversing.org Thanks to christefano for the module tips and strings

File

login_security.module
View source
<?php

/**
 * @file
 * Login Security
 *
 * Copyrighted by ilo@reversing.org
 * Thanks to christefano for the module tips and strings
 */
define('LOGIN_SECURITY_TRACK_TIME', 1);
define('LOGIN_SECURITY_USER_WRONG_COUNT', 0);
define('LOGIN_SECURITY_HOST_WRONG_COUNT', 0);
define('LOGIN_SECURITY_HOST_WRONG_COUNT_HARD', 0);
define('LOGIN_SECURITY_DISABLE_CORE_LOGIN_ERROR', 0);
define('LOGIN_SECURITY_NOTICE_ATTEMPTS_AVAILABLE', 0);
define('LOGIN_SECURITY_ACTIVITY_THRESHOLD', 0);
define('LOGIN_SECURITY_NOTICE_ATTEMPTS_MESSAGE', t("You have used @user_current_count out of @user_block_attempts login attempts. After all @user_block_attempts have been used, you will be unable to login."));
define('LOGIN_SECURITY_HOST_SOFT_BANNED', t("This host is not allowed to log in to @site. Please contact your site administrator."));
define('LOGIN_SECURITY_HOST_HARD_BANNED', t("The IP address @ip is banned at @site, and will not be able to access any of its content from now on. Please contact the site administrator."));
define('LOGIN_SECURITY_USER_BLOCKED', t("The user @username has been blocked due to failed login attempts."));
define('LOGIN_SECURITY_USER_BLOCKED_EMAIL_USER', '');
define('LOGIN_SECURITY_USER_BLOCKED_EMAIL_SUBJECT', t("Security action: The user @username has been blocked."));
define('LOGIN_SECURITY_USER_BLOCKED_EMAIL_BODY', t("The user @username (@edit_uri) has been blocked at @site due to the amount of failed login attempts. Please check the logs for more information."));
define('LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_USER', '');
define('LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_SUBJECT', t("Security information: Unexpected login activity has been detected at @site."));
define('LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_BODY', t("The configured threshold of @activity_threshold logins has been reached with a total of @tracking_current_count invalid login attempts. You should review your log information about login attempts at @site."));
define('LOGIN_SECURITY_THRESHOLD_NOTIFIED', FALSE);

/**
 * Implements hook_cron().
 */
function login_security_cron() {

  // Remove expired events.
  _login_security_remove_events();
}

/**
 * Implements hook_menu().
 */
function login_security_menu() {
  $items = array();

  // Administer >> Site configuration >> Login Security settings.
  $items['admin/config/people/login_security'] = array(
    'title' => 'Login Security',
    'description' => 'Configure security settings in the login form submission.',
    'access arguments' => array(
      'administer site configuration',
    ),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'login_security_admin_settings',
    ),
    'file' => 'login_security.admin.inc',
  );
  return $items;
}

/**
 * Implements hook_user_login().
 */
function login_security_user_login(&$edit, $account) {
  _login_security_remove_events($account->name, ip_address());
}

/**
 * Implements hook_user_update().
 */
function login_security_user_update(&$edit, &$account, $category = NULL) {

  // The update case can be launched by the user or by any administrator.
  // On update, remove only the unser information tracked.
  if ($account->status != 0) {

    // Don't remove tracking events if account is being blocked.
    _login_security_remove_events($account->name);
  }
}

/**
 * Implements hook_form_alter().
 */
function login_security_form_alter(&$form, &$form_state, $form_id) {
  switch ($form_id) {
    case 'user_login':
    case 'user_login_block':

      // Put login_security first or the capture of the previous login
      // timestamp won't work and core's validation will update to the current
      // login instance before login_security can read the old timestamp.
      $validate = array(
        'login_security_soft_block_validate',
        'login_security_set_login_timestamp',
      );
      if (isset($form['#validate']) && is_array($form['#validate'])) {
        $form['#validate'] = array_merge($validate, $form['#validate']);
      }
      else {
        $form['#validate'] = $validate;
      }
      $form['#validate'][] = 'login_security_validate';
      $form['#submit'][] = 'login_security_submit';
      break;
  }
}

/**
 * Save login attempt and save login/access timestamps.
 *
 * Previous incarnations of this code put it in hook_submit or hook_user, but
 * since Drupal core validation updates the login timestamp, we have to set the
 * message before it gets updated with the current login instance.
 */
function login_security_set_login_timestamp($form, &$form_state) {
  $account = db_select('users', 'u')
    ->fields('u', array(
    'login',
    'access',
  ))
    ->condition('name', $form_state['values']['name'])
    ->condition('status', 1)
    ->execute()
    ->fetchAssoc();

  // Save entry in security log, Username and IP Address.
  _login_security_add_event($form_state['values']['name'], ip_address());
  if (empty($account)) {
    return;
  }
  _login_security_login_timestamp($account['login']);
  _login_security_access_timestamp($account['access']);
}

/**
 * Returns account login timestamp.
 */
function _login_security_login_timestamp($login = NULL) {
  static $account_login;
  if (!isset($account_login) && is_numeric($login) && $login > 0) {
    $account_login = $login;
  }
  return $account_login;
}

/**
 * Returns account access timestamp.
 */
function _login_security_access_timestamp($access = NULL) {
  static $account_access;
  if (!isset($account_access) && is_numeric($access) && $access > 0) {
    $account_access = $access;
  }
  return $account_access;
}

/**
 * Temporarily deny validation to users with excess invalid login attempts.
 *
 * @url http://drupal.org/node/493164
 */
function login_security_soft_block_validate($form, &$form_state) {
  $variables = _login_security_get_variables_by_name($form_state['values']['name']);

  // Check for host login attempts: Soft.
  if ($variables['@soft_block_attempts'] >= 1) {
    if ($variables['@ip_current_count'] >= $variables['@soft_block_attempts']) {
      form_set_error('submit', login_security_t(variable_get('login_security_host_soft_banned', LOGIN_SECURITY_HOST_SOFT_BANNED), $variables));
    }
  }
}

/**
 * Implements hook_validate().
 *
 * This functions does more than just validating, but it's main intention is to
 * break the login form flow.
 */
function login_security_validate($form, &$form_state) {

  // Sanitize user input.
  $name = $form_state['values']['name'];

  // Null username should not be tracked.
  if (!strlen($name)) {
    return;
  }

  // Expire old tracked entries.
  _login_security_remove_events();

  // Populate variables to be used in any module message or login operation.
  $variables = _login_security_get_variables_by_name($name);

  // First, check if administrator should be notified of unexpected login
  // activity.
  // Only process if configured threshold > 1.
  // see: http://drupal.org/node/583092.
  if ($variables['@activity_threshold']) {

    // Check if threshold has been reached.
    if ($variables['@tracking_current_count'] > $variables['@activity_threshold']) {

      // Check if admin has been already alerted.
      if (!variable_get('login_security_threshold_notified', LOGIN_SECURITY_THRESHOLD_NOTIFIED)) {

        // Mark alert status as notified and send the email.
        watchdog('login_security', 'Ongoing attack detected: Suspicious activity detected in login form submissions. Too many invalid login attempts threshold reached: currently @tracking_current_count events are tracked, and threshold is configured for @activity_threshold attempts.', $variables, WATCHDOG_WARNING);
        variable_set('login_security_threshold_notified', TRUE);

        // Submit email only if required.
        $login_activity_email_user = variable_get('login_security_login_activity_email_user', LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_USER);
        if ($login_activity_email_user !== '') {
          $from = variable_get('site_mail', ini_get('sendmail_from'));
          $admin_mail = db_query_range("SELECT mail FROM {users} WHERE name = :name", 0, 1, array(
            ':name' => $login_activity_email_user,
          ))
            ->fetchField();
          $mail = drupal_mail('login_security', 'login_activity_notify', $admin_mail, language_default(), $variables, $from, TRUE);
        }
      }
    }
    elseif (variable_get('login_security_threshold_notified', TRUE) && $variables['@tracking_current_count'] < $variables['@activity_threshold'] / 3) {

      // Reset alert if currently tracked events is < threshold / 3.
      watchdog('login_security', 'Suspicious activity in login form submissions is no longer detected: currently @tracking_current_count events are being tracked, and threshold is configured for @activity_threshold maximum allowed attempts).', $variables, WATCHDOG_NOTICE);
      variable_set('login_security_threshold_notified', FALSE);
    }
  }

  // Check for host login attempts: Hard.
  if ($variables['@hard_block_attempts'] >= 1) {
    if ($variables['@ip_current_count'] >= $variables['@hard_block_attempts']) {

      // Block the host ip_address().
      login_user_block_ip($variables);
    }
  }

  // Check for user login attempts.
  if ($variables['@user_block_attempts'] >= 1) {
    if ($variables['@user_current_count'] >= $variables['@user_block_attempts']) {

      // Block the account $name.
      login_user_block_user_name($variables);
    }
  }

  // At this point, they're either logged in or not by Drupal core's abuse of
  // the validation hook to login users completely.
  global $user;

  // Login failed.
  $messages = drupal_get_messages('error', FALSE);
  if (!empty($messages['error'])) {
    $password_message = preg_grep("/<a href=\"\\/user\\/password\\?name={$name}\">Have you forgotten your password\\?<\\/a>/", $messages['error']);
    $block_message = preg_grep("/The username <em class=\"placeholder\">{$name}<\\/em> has not been activated or is blocked./", $messages['error']);
    if (!count($password_message) || !count($block_message)) {
      if (variable_get('login_security_disable_core_login_error', LOGIN_SECURITY_DISABLE_CORE_LOGIN_ERROR)) {

        // Resets the form error status so no form fields are highlighted in
        // red.
        $form_state['rebuild'] = TRUE;
        form_clear_error();

        // Removes "Sorry, unrecognized username or password. Have you
        // forgotten your password?" and "The username $name has not been
        // activated or is blocked.", and any other errors that might be
        // helpful to an attacker it should not reset the attempts message
        // because it is a warning, not an error.
        drupal_get_messages('error', TRUE);
      }

      // Should the user be advised about the remaining login attempts?
      $notice_user = variable_get('login_security_notice_attempts_available', LOGIN_SECURITY_NOTICE_ATTEMPTS_AVAILABLE);
      if ($notice_user == TRUE && $variables['@user_block_attempts'] > 0 && $variables['@user_block_attempts'] >= $variables['@user_current_count']) {
        $message_raw = variable_get('login_security_notice_attempts_message', LOGIN_SECURITY_NOTICE_ATTEMPTS_MESSAGE);

        // Simple flag that can be changed using hook_alter (see below).
        $display_block_attempts = TRUE;

        // Allow other module to change the flag, or even the message displayed,
        // with a custom logic.
        drupal_alter('login_security_display_block_attempts', $message_raw, $display_block_attempts, $variables['@user_current_count']);
        $message = array(
          'message' => $message_raw,
          'variables' => $variables,
        );

        // This loop is used instead of doing t() because t() can only
        // translate static strings, not variables.
        // Ignoring Coder because $variables is sanitized by
        // login_security_t().
        // See https://drupal.org/node/1743996#comment-6421246.
        // @ignore security_2
        $message = login_security_t($message['message'], $message['variables']);
        if ($display_block_attempts) {
          drupal_set_message($message, 'warning', TRUE);
        }
      }
    }
  }
}

/**
 * Implements hook_sumbit().
 */
function login_security_submit($form, &$form_state) {

  // The submit handler shouldn't be called unless the authentication succeeded.
  if (user_is_logged_in()) {
    $login = _login_security_login_timestamp();
    if (variable_get('login_security_last_login_timestamp', 0) && $login > 0) {
      drupal_set_message(t('Your last login was @stamp.', array(
        '@stamp' => format_date($login, 'large'),
      )), 'status');
    }
    $access = _login_security_access_timestamp();
    if (variable_get('login_security_last_access_timestamp', 0) && $access > 0) {
      drupal_set_message(t('Your last page access (site activity) was @stamp.', array(
        '@stamp' => format_date($access, 'large'),
      )), 'status');
    }
  }
}

/**
 * Remove tracked events or expire old ones.
 *
 * @param string $name
 *   If specified, events for this user name will be removed.
 *
 * @param string $host
 *   If specified, IP Address of the name-ip pair to be removed.
 */
function _login_security_remove_events($name = NULL, $host = NULL) {

  // Remove selected events.
  if (!empty($name)) {
    if (!empty($host)) {
      $result = db_delete('login_security_track')
        ->condition('name', $name)
        ->condition('host', $host)
        ->execute();
    }
    else {
      $result = db_delete('login_security_track')
        ->condition('name', $name)
        ->execute();
    }
  }
  else {

    // Calculate protection time window and remove expired events.
    $time = REQUEST_TIME - variable_get('login_security_track_time', LOGIN_SECURITY_TRACK_TIME) * 3600;
    _login_security_remove_all_events($time);
  }
}

/**
 * Remove all tracked events up to a date..
 *
 * @param int $time
 *   if specified, events up to this timestamp will be deleted. If not
 *   specified, all elements up to current timestamp will be deleted.
 */
function _login_security_remove_all_events($time = NULL) {

  // Remove selected events.
  if (empty($time)) {
    $time = REQUEST_TIME;
  }
  $result = db_delete('login_security_track')
    ->condition('timestamp', $time, '<')
    ->execute();
}

/**
 * Save the login attempt in the tracking database: user name nd ip address.
 *
 * @param string $name
 *   user name to be tracked.
 *
 * @param string $ip
 *   IP Address of the pair.
 */
function _login_security_add_event($name, $ip) {

  // Each attempt is kept for future minning of advanced bruteforcing like
  // multiple IP or X-Forwarded-for usage and automated track data cleanup.
  $event = new stdClass();
  $event->host = $ip;
  $event->name = $name;
  $event->timestamp = REQUEST_TIME;
  drupal_write_record('login_security_track', $event);
}

/**
 * Create a Deny entry for the IP address.
 *
 * If IP address is not especified then block current IP.
 */
function login_user_block_ip($variables) {

  // There is no need to check if the host has been banned, we can't get here
  // twice.
  $block = new stdClass();
  $block->ip = $variables['@ip'];
  drupal_write_record('blocked_ips', $block);
  watchdog('login_security', 'Banned IP address @ip due to security configuration.', $variables, WATCHDOG_NOTICE, l(t('view/delete blocked IPs'), "admin/config/people/ip-blocking"));

  // Using form_set_error because it may disrupt current form submission.
  form_set_error('void', login_security_t(variable_get('login_security_host_hard_banned', LOGIN_SECURITY_HOST_HARD_BANNED), $variables));
}

/**
 * Block a user by user name. If no user id then block current user.
 */
function login_user_block_user_name($variables) {

  // If the user exists.
  if ($variables['@uid'] > 1) {

    // Modifying the user table is not an option so it disables the user hooks.
    // Need to do firing the hook so user_notifications can be used.
    // db_query("UPDATE {users} SET status = 0 WHERE uid = %d", $uid);
    $uid = $variables['@uid'];
    $account = user_load($uid);

    // Block account if is active.
    if ($account->status == 1) {
      user_save($account, array(
        'status' => 0,
      ), NULL);

      // Remove user from site now.
      drupal_session_destroy_uid($uid);

      // The watchdog alert is set to 'user' so it will show with other blocked
      // user messages.
      watchdog('user', 'Blocked user @username due to security configuration.', $variables, WATCHDOG_NOTICE, l(t('edit user'), "user/{$variables['@uid']}/edit", array(
        'query' => array(
          'destination' => 'admin/user/user',
        ),
      )));

      // Also notify the user that account has been blocked.
      form_set_error('void', login_security_t(variable_get('login_security_user_blocked', LOGIN_SECURITY_USER_BLOCKED), $variables));

      // Send admin email.
      $user_blocked_email_user = variable_get('login_security_user_blocked_email_user', LOGIN_SECURITY_USER_BLOCKED_EMAIL_USER);
      if ($user_blocked_email_user !== '') {
        $from = variable_get('site_mail', ini_get('sendmail_from'));
        $admin_mail = db_select('users', 'u')
          ->fields('u', array(
          'mail',
        ))
          ->condition('name', $user_blocked_email_user)
          ->execute()
          ->fetchField();
        return drupal_mail('login_security', 'block_user_notify', $admin_mail, language_default(), $variables, $from, TRUE);
      }
    }
  }
}

/**
 * Helper function to get the variable array for the messages.
 */
function _login_security_get_variables_by_name($name) {
  $account = user_load_by_name($name);

  // https://drupal.org/node/1744704
  if (empty($account)) {
    $account = user_load(0);
  }
  $ipaddress = ip_address();
  global $base_url;
  $variables = array(
    '@date' => format_date(REQUEST_TIME),
    '@ip' => $ipaddress,
    '@username' => $account->name,
    '@email' => $account->mail,
    '@uid' => $account->uid,
    '@site' => variable_get('site_name', 'drupal'),
    '@uri' => $base_url,
    '@edit_uri' => url('user/' . $account->uid . '/edit', array(
      'absolute' => TRUE,
    )),
    '@hard_block_attempts' => variable_get('login_security_host_wrong_count_hard', LOGIN_SECURITY_HOST_WRONG_COUNT_HARD),
    '@soft_block_attempts' => variable_get('login_security_host_wrong_count', LOGIN_SECURITY_USER_WRONG_COUNT),
    '@user_block_attempts' => variable_get('login_security_user_wrong_count', LOGIN_SECURITY_USER_WRONG_COUNT),
    '@user_ip_current_count' => db_select('login_security_track', 'lst')
      ->fields('lst', array(
      'id',
    ))
      ->condition('name', $name)
      ->condition('host', $ipaddress)
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@ip_current_count' => db_select('login_security_track', 'lst')
      ->fields('lst', array(
      'id',
    ))
      ->condition('host', $ipaddress)
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@user_current_count' => db_select('login_security_track', 'lst')
      ->fields('lst', array(
      'id',
    ))
      ->condition('name', $name)
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@tracking_time' => variable_get('login_security_track_time', LOGIN_SECURITY_TRACK_TIME),
    '@tracking_current_count' => db_select('login_security_track', 'lst')
      ->fields('lst', array(
      'id',
    ))
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@activity_threshold' => variable_get('login_security_activity_threshold', LOGIN_SECURITY_ACTIVITY_THRESHOLD),
  );
  return $variables;
}

/**
 * Implements hook_mail().
 */
function login_security_mail($key, &$message, $variables) {
  switch ($key) {
    case 'block_user_notify':
      $message['subject'] = login_security_t(variable_get('login_security_user_blocked_email_subject', LOGIN_SECURITY_USER_BLOCKED_EMAIL_SUBJECT), $variables);
      $message['body'][] = login_security_t(variable_get('login_security_user_blocked_email_body', LOGIN_SECURITY_USER_BLOCKED_EMAIL_BODY), $variables);
      break;
    case 'login_activity_notify':
      $message['subject'] = login_security_t(variable_get('login_security_login_activity_email_subject', LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_SUBJECT), $variables);
      $message['body'][] = login_security_t(variable_get('login_security_login_activity_email_body', LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_BODY), $variables);
      break;
  }
}

/**
 * The performs drupal_placeholder() on variables in an array.
 *
 * This option is instead of doing t() because t() can only translate static
 * strings, not variables.
 */
function login_security_t($message, $variables = array()) {
  foreach ($variables as $key => $value) {
    $variables[$key] = drupal_placeholder($value);
  }
  return strtr($message, $variables);
}

/**
 * Implements hook_help().
 */
function login_security_help($path, $arg = NULL) {
  switch ($path) {
    case 'admin/settings/login_security':
      return '<p>' . t('Make sure you have reviewed the <a href="!README">README file</a> for further information about how all these settings will affect your Drupal login form submissions.', array(
        '!README' => 'http://drupalcode.org/project/login_security.git/blob/refs/heads/6.x-1.x:/README.txt',
      )) . '</p>';
  }
}

/**
 * Implements hook_nagios().
 */
function login_security_nagios() {
  $return = array(
    'key' => 'login_security',
  );

  // Get the token variables.
  $variables = _login_security_get_variables_by_name('anonymous');

  // Check the threshold_notified flag for the module status.
  $condition = variable_get('login_security_threshold_notified', 'bogus');
  if ($condition !== 'bogus') {

    // Attack is happening.
    if ($condition) {
      $status = NAGIOS_STATUS_CRITICAL;
      $text = login_security_t(LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_SUBJECT, $variables);
    }
    else {
      $status = NAGIOS_STATUS_OK;
      $text = '';
    }
  }
  else {
    $status = NAGIOS_STATUS_UNKNOWN;
    $text = t("Please check if the drupal variable login_security_threshold_notified exists.");
  }

  // Build the return data.
  $return['login_security'] = array(
    'status' => $status,
    'type' => 'state',
    'text' => $text,
  );
  return $return;
}

Functions

Namesort descending Description
login_security_cron Implements hook_cron().
login_security_form_alter Implements hook_form_alter().
login_security_help Implements hook_help().
login_security_mail Implements hook_mail().
login_security_menu Implements hook_menu().
login_security_nagios Implements hook_nagios().
login_security_set_login_timestamp Save login attempt and save login/access timestamps.
login_security_soft_block_validate Temporarily deny validation to users with excess invalid login attempts.
login_security_submit Implements hook_sumbit().
login_security_t The performs drupal_placeholder() on variables in an array.
login_security_user_login Implements hook_user_login().
login_security_user_update Implements hook_user_update().
login_security_validate Implements hook_validate().
login_user_block_ip Create a Deny entry for the IP address.
login_user_block_user_name Block a user by user name. If no user id then block current user.
_login_security_access_timestamp Returns account access timestamp.
_login_security_add_event Save the login attempt in the tracking database: user name nd ip address.
_login_security_get_variables_by_name Helper function to get the variable array for the messages.
_login_security_login_timestamp Returns account login timestamp.
_login_security_remove_all_events Remove all tracked events up to a date..
_login_security_remove_events Remove tracked events or expire old ones.

Constants