Commit 39d96944 authored by Juan Leyva's avatar Juan Leyva
Browse files

MDL-66776 notifications: New login session notification

parent 8af7bec8
......@@ -1262,6 +1262,7 @@ $string['messageprovider:errors'] = 'Important errors with the site';
$string['messageprovider:errors_help'] = 'These are important errors that an administrator should know about.';
$string['messageprovider:gradenotifications'] = 'Grade notifications';
$string['messageprovider:messagecontactrequests'] = 'Message contact requests notification';
$string['messageprovider:newlogin'] = 'New login notifications';
$string['messageprovider:notices'] = 'Notices about minor problems';
$string['messageprovider:notices_help'] = 'These are notices that an administrator might be interested in seeing.';
$string['messageprovider:infected'] = 'Antivirus failure notifications.';
......@@ -1384,6 +1385,18 @@ $string['new'] = 'New';
$string['newaccount'] = 'New account';
$string['newactivityname'] = 'New name for activity {$a}';
$string['newcourse'] = 'New course';
$string['newloginnotificationtitle'] = 'New sign in to your {$a} account';
$string['newloginnotificationbodysmall'] = 'Your {$a} account was just signed in to from a new device.';
$string['newloginnotificationbodyfull'] = '<p>Hi {$a->userfullname},</p>
<p>Your {$a->sitename} account was just signed in to from a new device.</p>
<ul>
<li>Your account: {$a->username} {$a->useremail}</li>
<li>{$a->logintime}</li>
<li>Device: {$a->logindevice}</li>
<li>IP: {$a->loginip}</li>
</ul>
<p>If this was you, then you don\'t need to do anything.</p>
<p>If you don\'t recognise this activity, please <a href="{$a->changepasswordlink}">change your password</a></p>';
$string['newpassword'] = 'New password';
$string['newpassword_help'] = 'Enter a new password or leave blank to keep current password.';
$string['newpasswordfromlost'] = '<strong>NOTICE:</strong> Your <strong>Current password</strong> will have been sent to you in the <strong>second</strong> of the two emails sent as part of the lost password recovery process. Make sure you have received your replacement password before continuing with this screen.';
......
......@@ -274,8 +274,10 @@ class manager {
* This is intended for installation scripts, unit tests and other
* special areas. Do NOT use for logout and session termination
* in normal requests!
*
* @param mixed $newsid only used after initialising a user session, is this a new user session?
*/
public static function init_empty_session() {
public static function init_empty_session(?bool $newsid = null) {
global $CFG;
if (isset($GLOBALS['SESSION']->notifications)) {
......@@ -283,6 +285,9 @@ class manager {
$notifications = $GLOBALS['SESSION']->notifications;
}
$GLOBALS['SESSION'] = new \stdClass();
if (isset($newsid)) {
$GLOBALS['SESSION']->isnewsessioncookie = $newsid;
}
$GLOBALS['USER'] = new \stdClass();
$GLOBALS['USER']->id = 0;
......@@ -419,7 +424,7 @@ class manager {
if (!$sid) {
// No session, very weird.
error_log('Missing session ID, session not started!');
self::init_empty_session();
self::init_empty_session($newsid);
return;
}
......@@ -547,7 +552,7 @@ class manager {
self::set_user($user);
self::add_session_record($user->id);
} else {
self::init_empty_session();
self::init_empty_session($newsid);
self::add_session_record(0);
}
......
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\task;
/**
* Adhoc task that send login notifications.
*
* @package core
* @copyright 2021 Moodle Pty Ltd.
* @author Juan Leyva <juan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class send_login_notifications extends adhoc_task {
use \core\task\logging_trait;
/**
* Run the adhoc task and preform the backup.
*/
public function execute() {
global $CFG, $DB, $SITE, $USER, $PAGE;
$customdata = $this->get_custom_data();
// First check the mobile app special case, to detect if the user is not using a new device after login from a different IP.
if (!empty($customdata->ismoodleapp)) {
$where = 'userid = ? AND timecreated >= ?';
if (!$DB->count_records_select('user_devices', $where, [$USER->id, $customdata->logintime])) {
// Do nothing, seems to be the same person doing login from a new IP using a known device.
return;
}
}
$this->log_start("Sending login notification to {$USER->username}");
$sitename = format_string($SITE->fullname);
$siteurl = $CFG->wwwroot;
$userfullname = fullname($USER);
$username = $USER->username;
$useremail = ($USER->username != $USER->email) ? $USER->email : '';
$logindevice = $customdata->ismoodleapp ? get_string('mobileapp', 'tool_mobile') : '';
$logindevice .= ' ' . $customdata->useragent;
$loginip = $customdata->loginip;
$logintime = userdate($customdata->logintime);
$changepasswordlink = (new \moodle_url('/user/preferences.php', ['userid' => $USER->id]))->out(false);
// Find a better final URL for changing password.
$userauth = get_auth_plugin($USER->auth);
if ($userauth->can_change_password()) {
if ($changepwurl = $userauth->change_password_url()) {
$changepasswordlink = $changepwurl;
} else {
$changepasswordlink = (new \moodle_url('/login/change_password.php'))->out(false);
}
}
$eventdata = new \core\message\message();
$eventdata->courseid = SITEID;
$eventdata->component = 'moodle';
$eventdata->name = 'newlogin';
$eventdata->userfrom = \core_user::get_noreply_user();
$eventdata->userto = $USER;
$eventdata->notification = 1;
$eventdata->subject = get_string('newloginnotificationtitle', 'moodle', $sitename);
$eventdata->fullmessageformat = FORMAT_HTML;
$info = compact('sitename', 'siteurl', 'userfullname', 'username', 'useremail',
'logindevice', 'logintime', 'loginip', 'changepasswordlink');
$eventdata->fullmessagehtml = get_string('newloginnotificationbodyfull', 'moodle', $info);
$eventdata->fullmessage = html_to_text($eventdata->fullmessagehtml);
$eventdata->smallmessage = get_string('newloginnotificationbodysmall', 'moodle', $username);
$userpicture = new \user_picture($USER);
$userpicture->size = 1; // Use f1 size.
$userpicture->includetoken = $USER->id; // Generate an out-of-session token for the user receiving the message.
$eventdata->customdata = ['notificationiconurl' => $userpicture->get_url($PAGE)->out(false)];
if (message_send($eventdata)) {
$this->log_finish("Notification successfully sent");
} else {
$this->log_finish("Failed to send notification");
}
}
}
......@@ -35,6 +35,13 @@ defined('MOODLE_INTERNAL') || die();
$messageproviders = array (
'newlogin' => array (
'defaults' => array(
'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
),
),
// Notices that an admin might be interested in
'notices' => array (
'capability' => 'moodle/site:config'
......
......@@ -1172,7 +1172,7 @@ function external_generate_token_for_current_user($service) {
* @since Moodle 3.2
*/
function external_log_token_request($token) {
global $DB;
global $DB, $USER;
$token->privatetoken = null;
......@@ -1185,6 +1185,25 @@ function external_log_token_request($token) {
$event = \core\event\webservice_token_sent::create($params);
$event->add_record_snapshot('external_tokens', $token);
$event->trigger();
// Check if we need to notify the user about the new login via token.
$loginip = getremoteaddr();
if ($USER->lastip != $loginip &&
((!WS_SERVER && !CLI_SCRIPT && NO_MOODLE_COOKIES) || PHPUNIT_TEST)) {
$logintime = time();
$useragent = \core_useragent::get_user_agent_string();
$ismoodleapp = \core_useragent::is_moodle_app();
// Schedule adhoc task to sent a login notification to the user.
$task = new \core\task\send_login_notifications();
$task->set_userid($USER->id);
$task->set_custom_data(compact('ismoodleapp', 'useragent', 'loginip', 'logintime'));
$task->set_component('core');
// We need sometime so the mobile app will send to Moodle the device information after login.
$task->set_next_run_time($logintime + (2 * MINSECS));
\core\task\manager::reschedule_or_queue_adhoc_task($task);
}
}
/**
......
......@@ -3351,7 +3351,7 @@ function get_user_key($script, $userid, $instance=null, $iprestriction=null, $va
* @return bool Always returns true
*/
function update_user_login_times() {
global $USER, $DB;
global $USER, $DB, $SESSION;
if (isguestuser()) {
// Do not update guest access times/ips for performance.
......@@ -3375,6 +3375,7 @@ function update_user_login_times() {
// Function user_accesstime_log() may not update immediately, better do it here.
$USER->lastaccess = $user->lastaccess = $now;
$SESSION->userpreviousip = $USER->lastip;
$USER->lastip = $user->lastip = getremoteaddr();
// Note: do not call user_update_user() here because this is part of the login process,
......@@ -4545,6 +4546,27 @@ function complete_user_login($user) {
);
$event->trigger();
// Check if the user is using a new browser or session (a new MoodleSession cookie is set in that case).
// If the user is accessing from the same IP, ignore everything (most of the time will be a new session in the same browser).
// Skip Web Service requests, CLI scripts, AJAX scripts, and request from the mobile app itself.
$loginip = getremoteaddr();
$isnewip = isset($SESSION->userpreviousip) && $SESSION->userpreviousip != $loginip;
$isvalidenv = (!WS_SERVER && !CLI_SCRIPT && !NO_MOODLE_COOKIES) || PHPUNIT_TEST;
if (!empty($SESSION->isnewsessioncookie) && $isnewip && $isvalidenv && !\core_useragent::is_moodle_app()) {
$logintime = time();
$ismoodleapp = false;
$useragent = \core_useragent::get_user_agent_string();
// Schedule adhoc task to sent a login notification to the user.
$task = new \core\task\send_login_notifications();
$task->set_userid($USER->id);
$task->set_custom_data(compact('ismoodleapp', 'useragent', 'loginip', 'logintime'));
$task->set_component('core');
\core\task\manager::queue_adhoc_task($task);
}
// Queue migrating the messaging data, if we need to.
if (!get_user_preferences('core_message_migrate_data', false, $USER->id)) {
// Check if there are any legacy messages to migrate.
......
......@@ -116,6 +116,7 @@ completely removed from Moodle core too.
* Final deprecation: The following functions along with associated tests have been removed:
- core_grades_external::get_grades
- core_grades_external::get_grade_item
* \core\session\manager::init_empty_session() has a new optional parameter $newsid to indicate whether this is a new user session
=== 3.11.4 ===
* A new option dontforcesvgdownload has been added to the $options parameter of the send_file() function.
......
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Contains tests for course related notifications.
*
* @package core
* @copyright 2021 Juan Leyva <juan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_login_notifications_testcase extends \advanced_testcase {
/**
* Test new login notification.
*/
public function test_login_notification() {
global $SESSION;
$this->resetAfterTest();
$loginuser = self::getDataGenerator()->create_user();
$this->setUser(0);
// Mock data for test.
$loginuser->lastip = '1.2.3.4.6'; // Different ip that current.
$SESSION->isnewsessioncookie = true; // New session cookie.
@complete_user_login($loginuser);
// Redirect messages to sink and stop buffer output from CLI task.
$sink = $this->redirectMessages();
ob_start();
$this->runAdhocTasks('\core\task\send_login_notifications');
$output = ob_get_contents();
ob_end_clean();
$messages = $sink->get_messages();
$sink->close();
// Send notification, new IP and new session.
$this->assertCount(1, $messages);
$this->assertEquals($loginuser->id, $messages[0]->useridto);
$this->assertEquals('newlogin', $messages[0]->eventtype);
}
/**
* Test new login notification is skipped because of same IP from last login.
*/
public function test_login_notification_skip_same_ip() {
global $SESSION;
$this->resetAfterTest();
$loginuser = self::getDataGenerator()->create_user();
$this->setUser(0);
// Mock data for test.
$SESSION->isnewsessioncookie = true; // New session cookie.
@complete_user_login($loginuser);
// Redirect messages to sink and stop buffer output from CLI task.
$sink = $this->redirectMessages();
ob_start();
$this->runAdhocTasks('\core\task\send_login_notifications');
$output = ob_get_contents();
ob_end_clean();
$messages = $sink->get_messages();
$sink->close();
// Skip notification when we have the same previous IP even if the browser used to connect is new.
$this->assertCount(0, $messages);
}
/**
* Test new login notification is skipped because of same browser from last login.
*/
public function test_login_notification_skip_same_browser() {
global $SESSION;
$this->resetAfterTest();
$loginuser = self::getDataGenerator()->create_user();
$this->setUser(0);
// Mock data for test.
$loginuser->lastip = '1.2.3.4.6'; // Different ip that current.
$SESSION->isnewsessioncookie = false;
@complete_user_login($loginuser);
// Redirect messages to sink and stop buffer output from CLI task.
$sink = $this->redirectMessages();
ob_start();
$this->runAdhocTasks('\core\task\send_login_notifications');
$output = ob_get_contents();
ob_end_clean();
$messages = $sink->get_messages();
$sink->close();
// Skip notification, different ip but same browser (probably, mobile phone browser).
$this->assertCount(0, $messages);
}
/**
* Test new login notification is skipped because of auto-login from the mobile app (skip duplicated notifications).
*/
public function test_login_notification_skip_mobileapp() {
global $SESSION;
$this->resetAfterTest();
$loginuser = self::getDataGenerator()->create_user();
$this->setUser(0);
// Mock data for test.
$loginuser->lastip = '1.2.3.4.6'; // Different ip that current.
$SESSION->isnewsessioncookie = true; // New session cookie.
core_useragent::instance(true, 'MoodleMobile'); // Force fake mobile app user agent.
@complete_user_login($loginuser);
// Redirect messages to sink and stop buffer output from CLI task.
$sink = $this->redirectMessages();
ob_start();
$this->runAdhocTasks('\core\task\send_login_notifications');
$output = ob_get_contents();
ob_end_clean();
$messages = $sink->get_messages();
$sink->close();
$this->assertCount(0, $messages);
}
/**
* Test new mobile app login notification.
*/
public function test_mobile_app_login_notification() {
global $USER, $DB, $SESSION;
$this->resetAfterTest();
$loginuser = self::getDataGenerator()->create_user();
$this->setUser($loginuser);
// Mock data for test.
$USER->lastip = '1.2.3.4.6'; // Different ip that current.
$service = $DB->get_record('external_services', array('shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE));
$token = external_generate_token_for_current_user($service);
core_useragent::instance(true, 'MoodleMobile'); // Force fake mobile app user agent.
// Simulate we are using an new device.
$fakedevice = (object) [
'userid' => $USER->id,
'appid' => 'com.moodle.moodlemobile',
'name' => 'occam',
'model' => 'Nexus 4',
'platform' => 'Android',
'version' => '4.2.2',
'pushid' => 'kishUhd',
'uuid' => 'KIhud7s',
'timecreated' => time() + MINSECS,
'timemodified' => time() + MINSECS
];
$DB->insert_record('user_devices', $fakedevice);
external_log_token_request($token);
// Redirect messages to sink and stop buffer output from CLI task.
$sink = $this->redirectMessages();
ob_start();
$this->runAdhocTasks('\core\task\send_login_notifications');
$output = ob_get_contents();
ob_end_clean();
$messages = $sink->get_messages();
$sink->close();
// We sent a login notification because we are using a new device and different IP.
$this->assertCount(1, $messages);
$this->assertEquals($loginuser->id, $messages[0]->useridto);
$this->assertEquals('newlogin', $messages[0]->eventtype);
}
/**
* Test new mobile app login notification skipped becase of same last ip.
*/
public function test_mobile_app_login_notification_skip_same_ip() {
global $USER, $DB, $SESSION;
$this->resetAfterTest();
$loginuser = self::getDataGenerator()->create_user();
$this->setUser($loginuser);
// Mock data for test.
$USER->lastip = '0.0.0.0';
$service = $DB->get_record('external_services', array('shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE));
$token = external_generate_token_for_current_user($service);
core_useragent::instance(true, 'MoodleMobile'); // Force fake mobile app user agent.
// Simulate we are using an new device.
$fakedevice = (object) [
'userid' => $USER->id,
'appid' => 'com.moodle.moodlemobile',
'name' => 'occam',
'model' => 'Nexus 4',
'platform' => 'Android',
'version' => '4.2.2',
'pushid' => 'kishUhd',
'uuid' => 'KIhud7s',
'timecreated' => time() + MINSECS,
'timemodified' => time() + MINSECS
];
$DB->insert_record('user_devices', $fakedevice);
external_log_token_request($token);
// Redirect messages to sink and stop buffer output from CLI task.
$sink = $this->redirectMessages();
ob_start();
$this->runAdhocTasks('\core\task\send_login_notifications');
$output = ob_get_contents();
ob_end_clean();
$messages = $sink->get_messages();
$sink->close();
// While using the same IP avoid sending new login notifications even if we are using a new device.
$this->assertCount(0, $messages);
}
/**
* Test new mobile app login notification skipped becase of same device.
*/
public function test_mobile_app_login_notification_skip_same_device() {
global $USER, $DB, $SESSION;
$this->resetAfterTest();
$loginuser = self::getDataGenerator()->create_user();
$this->setUser($loginuser);
// Mock data for test.
$USER->lastip = '1.2.3.4.6'; // New ip.
$service = $DB->get_record('external_services', array('shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE));
$token = external_generate_token_for_current_user($service);
core_useragent::instance(true, 'MoodleMobile'); // Force fake mobile app user agent.
external_log_token_request($token);
// Redirect messages to sink and stop buffer output from CLI task.
$sink = $this->redirectMessages();
ob_start();
$this->runAdhocTasks('\core\task\send_login_notifications');
$output = ob_get_contents();
ob_end_clean();
$messages = $sink->get_messages();
$sink->close();
// While using the same device avoid sending new login notifications even if the IP changes.
$this->assertCount(0, $messages);
}
}
......@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2021110200.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2021110200.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '4.0dev+ (Build: 20211102)'; // Human-friendly version name
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment