You are here

login_security.module in Login Security 8

Login Security module hooks.

File

login_security.module
View source
<?php

/**
 * @file
 * Login Security module hooks.
 */
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;

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

  // Remove expired events.
  _login_security_remove_events();
}

/**
 * Implements hook_user_login().
 */
function login_security_user_login(UserInterface $account) {
  $ip_address = Drupal::request()
    ->getClientIp();
  _login_security_remove_events($account
    ->getAccountName(), $ip_address);
}

/**
 * Implements hook_ENTITY_TYPE_update().
 */
function login_security_user_update(UserInterface $account) {

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

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

/**
 * Implements hook_form_alter().
 */
function login_security_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
  if ($form_id == 'user_login_form') {

    // 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 = [
      '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';
  }
}

/**
 * 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(array $form, FormStateInterface $form_state) {
  $account = \Drupal::database()
    ->select('users_field_data', 'u')
    ->fields('u', [
    'login',
    'access',
  ])
    ->condition('name', $form_state
    ->getValue('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.
  $ip_address = \Drupal::request()
    ->getClientIp();
  _login_security_add_event($form_state
    ->getValue('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(array $form, FormStateInterface $form_state) {
  $config = \Drupal::config('login_security.settings');
  $variables = _login_security_get_variables_by_name($form_state
    ->getValue('name'));

  // Check for host login attempts: Soft.
  if ($variables['@soft_block_attempts'] >= 1) {
    if ($variables['@ip_current_count'] >= $variables['@soft_block_attempts']) {
      $form_state
        ->setErrorByName('submit', SafeMarkup::format($config
        ->get('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(array $form, FormStateInterface $form_state) {
  $conf = \Drupal::config('login_security.settings');

  // Sanitize user input.
  $name = $form_state
    ->getValue('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']) {
    $threshold = \Drupal::state()
      ->get('login_security.threshold_notified', FALSE);

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

      // Check if admin has been already alerted.
      if (!$threshold) {

        // Mark alert status as notified and send the email.
        \Drupal::logger('login_security')
          ->warning('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);
        \Drupal::state()
          ->set('login_security.threshold_notified', TRUE);

        // Send notification only if required.
        $email_to = $conf
          ->get('login_activity_notification_emails');
        if ($email_to !== '') {
          $from = \Drupal::config('system.site')
            ->get('mail');
          $language = \Drupal::languageManager()
            ->getDefaultLanguage();
          \Drupal::service('plugin.manager.mail')
            ->mail('login_security', 'login_activity_notify', $email_to, $language, $variables, $from, TRUE);
        }
      }
    }
    elseif (\Drupal::state()
      ->get('login_security.threshold_notified', FALSE) && $variables['@tracking_current_count'] < $variables['@activity_threshold'] / 3) {

      // Reset alert if currently tracked events is < threshold / 3.
      \Drupal::logger('login_security')
        ->notice('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);
      \Drupal::state()
        ->set('login_security.threshold_notified', TRUE);
    }
  }

  // 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, $form_state);
    }
  }

  // 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, $form_state);
    }
  }

  // At this point, they're either logged in or not by Drupal core's abuse of
  // the validation hook to login users completely.
  if ($form_state
    ->hasAnyErrors()) {
    $errors = $form_state
      ->getErrors();
    $password_message = preg_grep("/<a href=\"\\/user\\/password\\?name={$name}\">Have you forgotten your password\\?<\\/a>/", $errors);
    $block_message = preg_grep("/The username <em class=\"placeholder\">{$name}<\\/em> has not been activated or is blocked./", $errors);
    if (!count($password_message) || !count($block_message)) {
      if ($conf
        ->get('disable_core_login_error')) {

        // Resets the form error status so no form fields are highlighted in
        // red.
        $form_state
          ->setRebuild();
        $form_state
          ->clearErrors();

        // Removes "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 = $conf
        ->get('notice_attempts_available');
      if ($notice_user == TRUE && $variables['@user_block_attempts'] > 0 && $variables['@user_block_attempts'] >= $variables['@user_current_count']) {
        $message_raw = $conf
          ->get('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::moduleHandler()
          ->alter('login_security_display_block_attempts', $message_raw, $display_block_attempts, $variables['@user_current_count']);
        $message = [
          '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 = SafeMarkup::format($message['message'], $message['variables']);
        if ($display_block_attempts) {
          drupal_set_message($message, 'warning', TRUE);
        }
      }
    }
  }
}

/**
 * Implements hook_submit().
 */
function login_security_submit(array $form, FormStateInterface $form_state) {
  $user = \Drupal::currentUser();
  $conf = \Drupal::config('login_security.settings');

  // The submit handler shouldn't be called unless the authentication succeeded.
  if (is_object($user)) {
    $login = _login_security_login_timestamp();
    if ($conf
      ->get('last_login_timestamp') && $login > 0) {
      drupal_set_message(t('Your last login was @stamp.', [
        '@stamp' => format_date($login, 'long'),
      ]), 'status');
    }
    $access = _login_security_access_timestamp();
    if ($conf
      ->get('last_access_timestamp') && $access > 0) {
      drupal_set_message(t('Your last page access (site activity) was @stamp.', [
        '@stamp' => format_date($access, 'long'),
      ]), '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) {
  $conf = \Drupal::config('login_security.settings');

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

    // Calculate protection time window and remove expired events.
    $time = REQUEST_TIME - $conf
      ->get('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.
 *
 * @return int
 *   The number of events deleted.
 */
function _login_security_remove_all_events($time = NULL) {

  // Remove selected events.
  if (empty($time)) {
    $time = REQUEST_TIME;
  }
  return 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 mining of advanced brute forcing like
  // multiple IP or X-Forwarded-for usage and automated track data cleanup.
  db_insert('login_security_track')
    ->fields([
    'host' => $ip,
    'name' => $name,
    'timestamp' => REQUEST_TIME,
  ])
    ->execute();
}

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

  // There is no need to check if the host has been banned, we can't get here
  // twice.
  $conf = \Drupal::config('login_security.settings');
  $ip = $variables['@ip'];
  \Drupal::database()
    ->merge('ban_ip')
    ->key([
    'ip' => $ip,
  ])
    ->fields([
    'ip' => $ip,
  ])
    ->execute();
  \Drupal::logger('login_security')
    ->notice('Banned IP address @ip due to security configuration.', $variables);
  $form_state
    ->setErrorByName('void', SafeMarkup::format($conf
    ->get('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, FormStateInterface $form_state) {
  $conf = \Drupal::config('login_security.settings');

  // 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.
    $uid = $variables['@uid'];
    $account = User::load($uid);

    // Block account if is active.
    if ($account->status->value == 1) {
      $account->status
        ->setValue(0);
      $account
        ->save();

      // Remove user from site now.
      if (\Drupal::currentUser()
        ->isAuthenticated()) {
        user_logout();
      }

      // The watchdog alert is set to 'user' so it will show with other blocked
      // user messages.
      \Drupal::logger('login_security')
        ->notice('Blocked user @username due to security configuration.', $variables);

      // Also notify the user that account has been blocked.
      $form_state
        ->setErrorByName('void', SafeMarkup::format($conf
        ->get('user_blocked'), $variables));

      // Send notification.
      $to = $conf
        ->get('user_blocked_notification_emails');
      if ($to !== '') {
        $from = \Drupal::config('system.site')
          ->get('mail');
        $language = \Drupal::languageManager()
          ->getDefaultLanguage();
        return \Drupal::service('plugin.manager.mail')
          ->mail('login_security', 'block_user_notify', $to, $language, $variables, $from, TRUE);
      }
    }
  }
}

/**
 * Helper function to get the variable array for the messages.
 */
function _login_security_get_variables_by_name($name) {
  global $base_url;
  $config = \Drupal::config('login_security.settings');
  $account = user_load_by_name($name);

  // https://drupal.org/node/1744704
  if (empty($account)) {
    $account = User::load(0);
  }
  $ipaddress = \Drupal::request()
    ->getClientIp();
  $variables = [
    '@date' => format_date(REQUEST_TIME),
    '@ip' => $ipaddress,
    '@username' => $account
      ->getAccountName(),
    '@email' => $account
      ->getEmail(),
    '@uid' => $account->uid->value,
    '@site' => \Drupal::config('system.site')
      ->get('name'),
    '@uri' => $base_url,
    '@edit_uri' => Url::fromRoute('entity.user.edit_form', [
      'user' => $account->uid->value,
    ], [
      'absolute' => TRUE,
    ])
      ->toString(),
    '@hard_block_attempts' => $config
      ->get('host_wrong_count_hard'),
    '@soft_block_attempts' => $config
      ->get('host_wrong_count'),
    '@user_block_attempts' => $config
      ->get('user_wrong_count'),
    '@user_ip_current_count' => \Drupal::database()
      ->select('login_security_track', 'lst')
      ->fields('lst', [
      'id',
    ])
      ->condition('name', $name)
      ->condition('host', $ipaddress)
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@ip_current_count' => \Drupal::database()
      ->select('login_security_track', 'lst')
      ->fields('lst', [
      'id',
    ])
      ->condition('host', $ipaddress)
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@user_current_count' => \Drupal::database()
      ->select('login_security_track', 'lst')
      ->fields('lst', [
      'id',
    ])
      ->condition('name', $name)
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@tracking_time' => $config
      ->get('track_time'),
    '@tracking_current_count' => \Drupal::database()
      ->select('login_security_track', 'lst')
      ->fields('lst', [
      'id',
    ])
      ->countQuery()
      ->execute()
      ->fetchField(),
    '@activity_threshold' => $config
      ->get('activity_threshold'),
  ];
  return $variables;
}

/**
 * Implements hook_mail().
 */
function login_security_mail($key, &$message, $variables) {
  $conf = \Drupal::config('login_security.settings');
  switch ($key) {
    case 'block_user_notify':
      $message['subject'] = SafeMarkup::format($conf
        ->get('user_blocked_email_subject'), $variables);
      $message['body'][] = SafeMarkup::format($conf
        ->get('user_blocked_email_body'), $variables);
      break;
    case 'login_activity_notify':
      $message['subject'] = SafeMarkup::format($conf
        ->get('login_activity_email_subject'), $variables);
      $message['body'][] = SafeMarkup::format($conf
        ->get('login_activity_email_body'), $variables);
      break;
  }
}

/**
 * Implements hook_help().
 */
function login_security_help($route_name, RouteMatchInterface $route_match) {
  switch ($route_name) {
    case 'help.page.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.', [
        '!README' => 'http://drupalcode.org/project/login_security.git/blob/refs/heads/6.x-1.x:/README.txt',
      ]) . '</p>';
  }
}

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_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_submit().
login_security_user_login Implements hook_user_login().
login_security_user_update Implements hook_ENTITY_TYPE_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.