<?php
// $Id: login_security.module,v 1.12.2.25 2010/09/21 02:45:28 deekayen Exp $

/**
 * @file
 * Login Security
 *
 * GPL published.. if you don't have a copy of the license, search for it, it's free
 * Copyrighted by ilo@reversing.org
 * Thanks to christefano for the module tips and strings
 */

define('LOGIN_SECURITY_TRACK_TIME', 1);
define('LOGIN_SECURITY_BASE_TIME', 0);
define('LOGIN_SECURITY_DELAY_INCREASE', 0);
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 <em>%ip</em> 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 <em>%username</em> 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 tota of %tracking_current_count invalid login attempts. You should review your log information about login attempts at %site."));
define('LOGIN_SECURITY_THRESHOLD_NOTIFIED', FALSE);

/**
 * Implementation of hook_cron().
 */
function login_security_cron() {
  // Remove expired events
  _login_security_remove_events();
  return;
}

/**
 * Implementation of hook_menu().
 */
function login_security_menu() {
  $items = array();
  
  // Administer >> Site configuration >> Login Security settings
  $items['admin/settings/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;
}

/**
 * Implementation of hook_user().
 */
function login_security_user($op, &$edit, &$account, $category = NULL) {
  switch ($op) {
    case 'login':
      // On success login remove any temporary protection for the IP address and the username
      _login_security_remove_events($edit['name'], ip_address());
      break;
    case 'update':
      // The update case can be launched by the user or by any administrator
      // On update, remove only the unser information tracked.
      if ($edit['status'] != 0) {
        // Don't remove tracking events if account is being blocked
        _login_security_remove_events($account->name);
      }
      break;
      // Cron will clean the forgotten tracking entries, including the deleted users.
  }
}

/**
 * Implementation of 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.
      $form['#validate'] = array_merge(array('login_security_soft_block_validate', 'login_security_set_login_timestamp'), $form['#validate']);
      $form['#validate'][] = 'login_security_validate';
      break;
      }
  }

 /**
 * 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.
 *
 * Also we save the login attempt event here.
 */
function login_security_set_login_timestamp($form, &$form_state) {
  $account = user_load(array('name' => $form_state['values']['name'], 'pass' => trim($form_state['values']['pass']), 'status' => 1));
  if (variable_get('login_security_last_login_timestamp', 0) && $account->login > 0) {
    drupal_set_message(t('Your last login was !stamp', array('!stamp' => format_date($account->login, 'large'))), 'status');
  }
  if (variable_get('login_security_last_access_timestamp', 0) && $account->access > 0) {
    drupal_set_message(t('Your last page access (site activity) was !stamp', array('!stamp' => format_date($account->access, 'large'))), 'status');
  }
    // Save entry in security log, Username and IP Address
  _login_security_add_event(check_plain($form_state['values']['name']), check_plain(ip_address()));
}

/**
 * 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 = $variables = _login_security_get_variables_by_name(check_plain($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));
      }
    }
  }

/**
 * Implementation of form validate. This functions does more than just validating, but it's main
 * Intention is to break the login form flow.
 *
 * @param $form_item
 *   The status of the name field in the form field after being submitted by the user.
 *
 */
function login_security_validate($form, &$form_state) {
  // Sanitize user input
  $name = check_plain($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_result(db_query("SELECT mail FROM {users} WHERE name = '%s'", $login_activity_email_user));
          $subject = login_security_t(variable_get('login_security_login_activity_email_subject', LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_SUBJECT), $variables);
          $body = login_security_t(variable_get('login_security_login_activity_email_body', LOGIN_SECURITY_LOGIN_ACTIVITY_EMAIL_BODY), $variables);
          $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);
    }
  }

  // then, start with login Delay
  if ($delay = variable_get('login_security_delay_base_time', LOGIN_SECURITY_BASE_TIME)) {
    $secs = (variable_get('login_security_delay_increase', LOGIN_SECURITY_DELAY_INCREASE) == 1) ? intval($variables['%user_ip_current_count']-1) * intval($delay) : intval($delay);
    //Included 0 just in case of 'max_execution_time' being lower than 3
    $sleep_time  = max(0, min(ini_get('max_execution_time') - 3, $secs));
    @sleep($sleep_time);
  }

  // Check for host login attempts: Hard
  if ($variables['%hard_block_attempts'] >= 1) {
    if ($variables['%ip_current_count'] >= $variables['%hard_block_attempts']) {
      // block the host check_plain(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
  if ($user->uid == 0) {
    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_set_error(NULL, '', TRUE);

      // removes "Sorry, unrecognized username or password. Have you forgotten your password?"
      // 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
      unset($_SESSION['messages']['error']);
    }

    // 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 instead of doing t() because t() can only translate static strings, not variables.
      drupal_set_message(login_security_t(variable_get('login_security_notice_attempts_message', LOGIN_SECURITY_NOTICE_ATTEMPTS_MESSAGE), $variables), 'warning');
      }
    }
  }

/**
 * Remove tracked events or expire old ones.
 *
 * @param $name
 *   if specified, events for this user name will be removed.
 *
 * @param $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)) {
      db_query("DELETE FROM {login_security_track} WHERE name = '%s' AND host = '%s'", check_plain($name), check_plain($host));
    }
    else {
      db_query("DELETE FROM {login_security_track} WHERE name = '%s'", check_plain($name));
    }
  }
  else {
    // Calculate protection time window and remove expired events
    $time = time() - (variable_get('login_security_track_time', LOGIN_SECURITY_TRACK_TIME) * 3600);
    _login_security_remove_all_events($time);
  }
  return;
}

/**
 * Remove all tracked events up to a date..
 *
 * @param $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 = time();
  }
  db_query("DELETE FROM {login_security_track} WHERE timestamp < %d", $time);
  return;
}

/**
 * Save the login attempt in the tracking database: user name nd ip address.
 *
 * @param $name
 *   user name to be tracked.
 *
 * @param $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 = 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.
 *
 * @param $ip
 *   Optional. Add a deny rule in the access control to this IP Address.
 */
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->mask = $variables['%ip'];
  $block->type = 'host';
  $block->status = 0;
  drupal_write_record('access', $block);
  watchdog('login_security', 'Banned IP address %ip due to security configuration.', $variables, WATCHDOG_NOTICE, l(t('edit rule'), "admin/user/rules/edit/{$block->aid}", array('query' => array('destination' => 'admin/user/rules'))));
  //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.
 *
 * @param $name
 *   Optional. The unique string identifying the 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(array("uid" => $uid));

    // Block account if is active.
    if ($account->status == 1) {
      user_save($account, array('status' => 0), NULL);
      // remove user from site now.
      sess_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_result(db_query("SELECT mail FROM {users} WHERE name = '%s'", $user_blocked_email_user));
        $subject = login_security_t(variable_get('login_security_user_blocked_email_subject', LOGIN_SECURITY_USER_BLOCKED_EMAIL_SUBJECT), $variables);
        $body = login_security_t(variable_get('login_security_user_blocked_email_body', LOGIN_SECURITY_USER_BLOCKED_EMAIL_BODY), $variables);
        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(array("name" => $name));
  $ipaddress = check_plain(ip_address());
  global $base_url;
  $variables = array(
    '%date' => format_date(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_result(db_query("SELECT COUNT(id) FROM {login_security_track} WHERE name = '%s' AND host = '%s'", $name, $ipaddress)),
    '%ip_current_count' => db_result(db_query("SELECT COUNT(id) FROM {login_security_track} WHERE host = '%s'", $ipaddress)),
    '%user_current_count' => db_result(db_query("SELECT COUNT(id) FROM {login_security_track} WHERE name = '%s'", $name)),
    '%tracking_time' => variable_get('login_security_track_time', LOGIN_SECURITY_TRACK_TIME),
    '%tracking_current_count' => db_result(db_query("SELECT COUNT(id) FROM {login_security_track}")),
    '%activity_threshold' => variable_get('login_security_activity_threshold', LOGIN_SECURITY_ACTIVITY_THRESHOLD),
  );
  return $variables;
}

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;
  }
}

/**
 * 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] = theme('placeholder', $value);
  }
  return strtr($message, $variables);
}

/**
 * Implementation of 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 !README file for further information about how all these settings will affect your Drupal login form submissions.', array('!README' => l(t('README'), 'http://drupalcode.org/viewvc/drupal/contributions/modules/login_security/README.txt?view=markup&pathrev=DRUPAL-6--1'))) . '</p>';
  }
}
