Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Scheduled task class.
  19   *
  20   * @package    core
  21   * @copyright  2013 onwards Martin Dougiamas  http://dougiamas.com
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  namespace core\task;
  25  
  26  /**
  27   * Simple task to send notifications about failed login attempts.
  28   */
  29  class send_failed_login_notifications_task extends scheduled_task {
  30  
  31      /** The maximum time period to look back (30 days = 30 * 24 * 3600) */
  32      const NOTIFY_MAXIMUM_TIME = 2592000;
  33  
  34      /**
  35       * Get a descriptive name for this task (shown to admins).
  36       *
  37       * @return string
  38       */
  39      public function get_name() {
  40          return get_string('tasksendfailedloginnotifications', 'admin');
  41      }
  42  
  43      /**
  44       * Do the job.
  45       * Throw exceptions on errors (the job will be retried).
  46       */
  47      public function execute() {
  48          global $CFG, $DB;
  49  
  50          if (empty($CFG->notifyloginfailures)) {
  51              return;
  52          }
  53  
  54          $recip = get_users_from_config($CFG->notifyloginfailures, 'moodle/site:config');
  55  
  56          // Do not look back more than 1 month to avoid crashes due to huge number of records.
  57          $maximumlastnotifytime = time() - self::NOTIFY_MAXIMUM_TIME;
  58          if (empty($CFG->lastnotifyfailure) || ($CFG->lastnotifyfailure < $maximumlastnotifytime)) {
  59              $CFG->lastnotifyfailure = $maximumlastnotifytime;
  60          }
  61  
  62          // If it has been less than an hour, or if there are no recipients, don't execute.
  63          if (((time() - HOURSECS) < $CFG->lastnotifyfailure) || !is_array($recip) || count($recip) <= 0) {
  64              return;
  65          }
  66  
  67          // We need to deal with the threshold stuff first.
  68          if (empty($CFG->notifyloginthreshold)) {
  69              $CFG->notifyloginthreshold = 10; // Default to something sensible.
  70          }
  71  
  72          // Get all the IPs with more than notifyloginthreshold failures since lastnotifyfailure
  73          // and insert them into the cache_flags temp table.
  74          $logmang = get_log_manager();
  75          /** @var \core\log\sql_internal_table_reader[] $readers */
  76          $readers = $logmang->get_readers('\core\log\sql_internal_table_reader');
  77          $reader = reset($readers);
  78          $readername = key($readers);
  79          if (empty($reader) || empty($readername)) {
  80              // No readers, no processing.
  81              return true;
  82          }
  83          $logtable = $reader->get_internal_log_table_name();
  84  
  85          $sql = "SELECT ip, COUNT(*)
  86                    FROM {" . $logtable . "}
  87                   WHERE eventname = ?
  88                         AND timecreated > ?
  89                 GROUP BY ip
  90                   HAVING COUNT(*) >= ?";
  91          $params = array('\core\event\user_login_failed', $CFG->lastnotifyfailure, $CFG->notifyloginthreshold);
  92          $rs = $DB->get_recordset_sql($sql, $params);
  93          foreach ($rs as $iprec) {
  94              if (!empty($iprec->ip)) {
  95                  set_cache_flag('login_failure_by_ip', $iprec->ip, '1', 0);
  96              }
  97          }
  98          $rs->close();
  99  
 100          // Get all the INFOs with more than notifyloginthreshold failures since lastnotifyfailure
 101          // and insert them into the cache_flags temp table.
 102          $sql = "SELECT userid, count(*)
 103                    FROM {" . $logtable . "}
 104                   WHERE eventname = ?
 105                         AND timecreated > ?
 106                GROUP BY userid
 107                  HAVING count(*) >= ?";
 108          $params = array('\core\event\user_login_failed', $CFG->lastnotifyfailure, $CFG->notifyloginthreshold);
 109          $rs = $DB->get_recordset_sql($sql, $params);
 110          foreach ($rs as $inforec) {
 111              if (!empty($inforec->info)) {
 112                  set_cache_flag('login_failure_by_id', $inforec->userid, '1', 0);
 113              }
 114          }
 115          $rs->close();
 116  
 117          // Now, select all the login error logged records belonging to the ips and infos
 118          // since lastnotifyfailure, that we have stored in the cache_flags table.
 119          $userfieldsapi = \core_user\fields::for_name();
 120          $namefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
 121          $sql = "SELECT * FROM (
 122                          SELECT l.*, u.username, $namefields
 123                            FROM {" . $logtable . "} l
 124                            JOIN {cache_flags} cf ON l.ip = cf.name
 125                       LEFT JOIN {user} u         ON l.userid = u.id
 126                           WHERE l.eventname = ?
 127                                 AND l.timecreated > ?
 128                                 AND cf.flagtype = 'login_failure_by_ip'
 129                      UNION ALL
 130                          SELECT l.*, u.username, $namefields
 131                            FROM {" . $logtable . "} l
 132                            JOIN {cache_flags} cf ON l.userid = " . $DB->sql_cast_char2int('cf.name') . "
 133                       LEFT JOIN {user} u         ON l.userid = u.id
 134                           WHERE l.eventname = ?
 135                                 AND l.timecreated > ?
 136                                 AND cf.flagtype = 'login_failure_by_info') t
 137               ORDER BY t.timecreated DESC";
 138          $params = array('\core\event\user_login_failed', $CFG->lastnotifyfailure, '\core\event\user_login_failed', $CFG->lastnotifyfailure);
 139  
 140          // Init some variables.
 141          $count = 0;
 142          $messages = '';
 143          // Iterate over the logs recordset.
 144          $rs = $DB->get_recordset_sql($sql, $params);
 145          foreach ($rs as $log) {
 146              $a = new \stdClass();
 147              $a->time = userdate($log->timecreated);
 148              if (empty($log->username)) {
 149                  // Entries with no valid username. We get attempted username from the event's other field.
 150                  $other = \tool_log\local\privacy\helper::decode_other($log->other);
 151                  $a->info = empty($other['username']) ? '' : $other['username'];
 152                  $a->name = get_string('unknownuser');
 153              } else {
 154                  $a->info = $log->username;
 155                  $a->name = fullname($log);
 156              }
 157              $a->ip = $log->ip;
 158              $messages .= get_string('notifyloginfailuresmessage', '', $a)."\n";
 159              $count++;
 160          }
 161          $rs->close();
 162  
 163          // If we have something useful to report.
 164          if ($count > 0) {
 165              $site = get_site();
 166              $subject = get_string('notifyloginfailuressubject', '', format_string($site->fullname));
 167              // Calculate the complete body of notification (start + messages + end).
 168              $params = array('id' => 0, 'modid' => 'site_errors', 'chooselog' => '1', 'logreader' => $readername);
 169              $url = new \moodle_url('/report/log/index.php', $params);
 170              $body = get_string('notifyloginfailuresmessagestart', '', $CFG->wwwroot) .
 171                      (($CFG->lastnotifyfailure != 0) ? '('.userdate($CFG->lastnotifyfailure).')' : '')."\n\n" .
 172                      $messages .
 173                      "\n\n".get_string('notifyloginfailuresmessageend', '',  $url->out(false).' ')."\n\n";
 174  
 175              // For each destination, send mail.
 176              mtrace('Emailing admins about '. $count .' failed login attempts');
 177              foreach ($recip as $admin) {
 178                  // Emailing the admins directly rather than putting these through the messaging system.
 179                  email_to_user($admin, \core_user::get_noreply_user(), $subject, $body);
 180              }
 181          }
 182  
 183          // Update lastnotifyfailure with current time.
 184          set_config('lastnotifyfailure', time());
 185  
 186          // Finally, delete all the temp records we have created in cache_flags.
 187          $DB->delete_records_select('cache_flags', "flagtype IN ('login_failure_by_ip', 'login_failure_by_info')");
 188  
 189      }
 190  }