<?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();
  if (empty($account)) {
    return;
  }

  _login_security_login_timestamp($account['login']);
  _login_security_access_timestamp($account['access']);

  // Save entry in security log, Username and IP Address.
  _login_security_add_event($form_state['values']['name'], ip_address());
}

/**
 * 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']) {
        // 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
        drupal_set_message(login_security_t(variable_get('login_security_notice_attempts_message', LOGIN_SECURITY_NOTICE_ATTEMPTS_MESSAGE), $variables), '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);
    }
    // All is normal.
    else {
      $status = NAGIOS_STATUS_OK;
      $text = '';
    }
  }
  // Status unknown if the variable set to bogus.
  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;
}
