diff --git a/README.md b/README.md index b50ee46..01db01a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,21 @@ ![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/catalyst/moodle-auth_outage/ci.yml?branch=MOODLE_39_STABLE) # Moodle Outage manager plugin -* [Version Support](#version-support) -* [What is this?](#what-is-this) -* [Moodle Requirements](#moodle-requirements) -* [Screenshots](#screenshots) -* [Installation](#installation) -* [Theme configuration](#theme-configuration) -* [How to use](#how-to-use) -* [Quick Guide](#quick-guide) -* [Why is it an auth plugin?](#why-it-is-an-auth-plugin) -* [Feedback and issues](#feedback-and-issues) +- [Moodle Outage manager plugin](#moodle-outage-manager-plugin) + - [What is this?](#what-is-this) + - [Moodle Requirements](#moodle-requirements) + - [Branches](#branches) + - [Screenshots](#screenshots) + - [Installation](#installation) + - [Theme configuration](#theme-configuration) + - [Custom Theme Additional SCSS](#custom-theme-additional-scss) + - [How to use](#how-to-use) + - [Quick Guide](#quick-guide) + - [Why it is an auth plugin?](#why-it-is-an-auth-plugin) + - [Tester restriction options](#tester-restriction-options) + - [IP restriction](#ip-restriction) + - [Access key](#access-key) + - [Feedback and issues](#feedback-and-issues) What is this? ------------- @@ -178,6 +183,16 @@ Why it is an auth plugin? One of the graduated stages this plugin introduces is a 'tester only' mode which disables login for most normal users. This is conceptually similar to the maintenance mode but enables testers to login and confirm the state after an upgrade without needing full admin privileges. +Tester restriction options +------------ +Two options are available to restrict the site to only let testers in during the tester phase. +Note: these restrictions build on each other; If both are enabled, users must meet both criteria to be allowed in. + +## IP restriction +Only allow users from a certain IP or range of ips to enter. +## Access key +Users provide an access key in the URL params on first page load, which is then stored as a cookie for 24 hours. If the access key matches the one setup for the outage, they are allowed in. + Feedback and issues ------------------- diff --git a/bootstrap.php b/bootstrap.php index c1f83d6..8417edf 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -76,13 +76,15 @@ $url = $path.'/auth/outage/info.php'; $outageinfo = strpos($_SERVER['REQUEST_URI'], $url) === 0 ? true : false; } + $allowed = !file_exists($CFG->dataroot.'/climaintenance.php') // Not in maintenance mode. || (defined('ABORT_AFTER_CONFIG') && ABORT_AFTER_CONFIG) // Only config requested. || (defined('CLI_SCRIPT') && CLI_SCRIPT) // Allow CLI scripts. || $outageinfo // Allow outage info requests. || (defined('NO_AUTH_OUTAGE') && NO_AUTH_OUTAGE); // Allow any page should not be blocked by maintenance mode. if (!$allowed) { - // Call the climaintenance.php which will check for allowed IPs. + // Call the climaintenance.php which will check for the conditions + // that have been baked into it from the frontend (ip, accesskey, etc...). $CFG->dirroot = dirname(dirname(dirname(__FILE__))); // It is not defined yet but the script below needs it. require($CFG->dataroot.'/climaintenance.php'); // This call may terminate the script here or not. } diff --git a/classes/form/outage/edit.php b/classes/form/outage/edit.php index 78f7108..1323df6 100644 --- a/classes/form/outage/edit.php +++ b/classes/form/outage/edit.php @@ -84,6 +84,13 @@ public function definition() { $mform->addElement('static', 'usagehints', '', get_string('textplaceholdershint', 'auth_outage')); $mform->addElement('static', 'warningreenablemaintenancemode', ''); + $mform->addElement('advcheckbox', 'useaccesskey', get_string('useaccesskey', 'auth_outage'), get_string('useaccesskey:desc', 'auth_outage'), 0); + + $mform->addElement('text', 'accesskey', get_string('accesskey', 'auth_outage')); + $mform->setType('accesskey', PARAM_TEXT); + $mform->disabledIf('accesskey', 'useaccesskey'); + $mform->addHelpButton('accesskey', 'accesskey', 'auth_outage'); + $this->add_action_buttons(); } @@ -137,6 +144,7 @@ public function get_data() { 'warntime' => $data->starttime - $data->warningduration, 'title' => $data->title, 'description' => $data->description['text'], + 'accesskey' => $data->useaccesskey ? $data->accesskey : null, ]; return new outage($outagedata); } @@ -160,6 +168,8 @@ public function set_data($outage) { 'warningduration' => $outage->get_warning_duration(), 'title' => $outage->title, 'description' => ['text' => $outage->description, 'format' => '1'], + 'accesskey' => $outage->accesskey, + 'useaccesskey' => !empty($outage->accesskey), ]); // If the default_autostart is configured in config, then force autostart to be the default value. diff --git a/classes/local/outage.php b/classes/local/outage.php index f9907e4..bc65761 100644 --- a/classes/local/outage.php +++ b/classes/local/outage.php @@ -117,6 +117,11 @@ class outage { */ public $lastmodified = null; + /** + * @var string|null access key, or null if not enabled. + */ + public $accesskey = null; + /** * outage constructor. * @param stdClass|array|null $data The data for the outage. diff --git a/classes/local/outagelib.php b/classes/local/outagelib.php index 9b532b2..c4b2f5f 100644 --- a/classes/local/outagelib.php +++ b/classes/local/outagelib.php @@ -290,30 +290,48 @@ private static function injection_allowed() { * @param int $starttime Outage start time. * @param int $stoptime Outage stop time. * @param string $allowedips List of IPs allowed. + * @param string|null $accesskey access key, or null if no access key set. * * @return string * @throws invalid_parameter_exception */ - public static function create_climaintenancephp_code($starttime, $stoptime, $allowedips) { + public static function create_climaintenancephp_code($starttime, $stoptime, $allowedips, $accesskey = null) { + global $CFG; if (!is_int($starttime) || !is_int($stoptime)) { throw new invalid_parameter_exception('Make sure $startime and $stoptime are integers.'); } - if (!is_string($allowedips) || (trim($allowedips) == '')) { - throw new invalid_parameter_exception('$allowedips must be a valid string.'); - } // I know Moodle validation would clean up this field, but just in case, let's ensure no // single-quotes (and double for the sake of it) are present otherwise it would break the code. $allowedips = addslashes($allowedips); + $cookiesecure = is_moodle_cookie_secure(); + $cookiehttponly = (bool) $CFG->cookiehttponly; + $code = <<<'EOT' = {{STARTTIME}}) && (time() < {{STOPTIME}})) { - define('MOODLE_INTERNAL', true); + if (!defined('MOODLE_INTERNAL')) { + define('MOODLE_INTERNAL', true); + } require_once($CFG->dirroot.'/lib/moodlelib.php'); if (file_exists($CFG->dirroot.'/lib/classes/ip_utils.php')) { require_once($CFG->dirroot.'/lib/classes/ip_utils.php'); } - if (!remoteip_in_list('{{ALLOWEDIPS}}')) { + // Put access key as a cookie if given. This stops the need to put it as a url param on every request. + $urlaccesskey = optional_param('accesskey', null, PARAM_TEXT); + + if (!empty($urlaccesskey)) { + setcookie('auth_outage_accesskey', $urlaccesskey, time() + 86400, '/', '', {{COOKIESECURE}}, {{COOKIEHTTPONLY}}); + } + + // Use url access key if given, else the cookie, else null. + $useraccesskey = $urlaccesskey ?: $_COOKIE['auth_outage_accesskey'] ?? null; + + $ipblocked = !remoteip_in_list('{{ALLOWEDIPS}}'); + $accesskeyblocked = $useraccesskey != '{{ACCESSKEY}}'; + $blocked = ({{USEACCESSKEY}} && $accesskeyblocked) || ({{USEALLOWEDIPS}} && $ipblocked); + + if ($blocked) { header($_SERVER['SERVER_PROTOCOL'] . ' 503 Moodle under maintenance'); header('Status: 503 Moodle under maintenance'); header('Retry-After: 300'); @@ -329,7 +347,23 @@ public static function create_climaintenancephp_code($starttime, $stoptime, $all if ((defined('AJAX_SCRIPT') && AJAX_SCRIPT) || (defined('WS_SERVER') && WS_SERVER)) { exit(0); } - echo ''; + + if ({{USEALLOWEDIPS}} && $ipblocked) { + echo ''; + } + + if ({{USEALLOWEDIPS}} && !$ipblocked) { + echo ''; + } + + if ({{USEACCESSKEY}} && $accesskeyblocked) { + echo ''; + } + + if ({{USEACCESSKEY}} && !$accesskeyblocked) { + echo ''; + } + if (file_exists($CFG->dataroot.'/climaintenance.template.html')) { require($CFG->dataroot.'/climaintenance.template.html'); exit(0); @@ -339,8 +373,10 @@ public static function create_climaintenancephp_code($starttime, $stoptime, $all } } EOT; - $search = ['{{STARTTIME}}', '{{STOPTIME}}', '{{ALLOWEDIPS}}', '{{YOURIP}}']; - $replace = [$starttime, $stoptime, $allowedips, getremoteaddr('n/a')]; + $search = ['{{STARTTIME}}', '{{STOPTIME}}', '{{USEALLOWEDIPS}}', '{{ALLOWEDIPS}}', '{{USEACCESSKEY}}', '{{ACCESSKEY}}', '{{YOURIP}}', '{{COOKIESECURE}}', '{{COOKIEHTTPONLY}}']; + // Note that var_export is required because (string) false == '', not 'false'. + $replace = [$starttime, $stoptime, var_export(!empty($allowedips), true), $allowedips, var_export(!empty($accesskey), true), + $accesskey, getremoteaddr('n/a'), var_export($cookiesecure, true), var_export($cookiehttponly, true)]; return str_replace($search, $replace, $code); } @@ -362,13 +398,15 @@ public static function update_climaintenance_code($outage) { $config = self::get_config(); $allowedips = trim($config->allowedips); + $accesskey = $outage->accesskey ?? null; - if (is_null($outage) || ($allowedips == '')) { + // If no outage, or allowed ips is null and access key is null (i.e. no blocking required). + if (is_null($outage) || ($allowedips == '' && empty($accesskey))) { if (file_exists($file)) { unlink($file); } } else { - $code = self::create_climaintenancephp_code($outage->starttime, $outage->stoptime, $allowedips); + $code = self::create_climaintenancephp_code($outage->starttime, $outage->stoptime, $allowedips, $outage->accesskey); $dir = dirname($file); if (!file_exists($dir) || !is_dir($dir)) { diff --git a/db/install.xml b/db/install.xml index fd3696c..09d8278 100644 --- a/db/install.xml +++ b/db/install.xml @@ -17,12 +17,14 @@ + + diff --git a/db/upgrade.php b/db/upgrade.php index 61b31f5..3332947 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -47,5 +47,20 @@ function xmldb_auth_outage_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2016092200, 'auth', 'outage'); } + if ($oldversion < 2024080200) { + + // Define field accesskey to be added to auth_outage. + $table = new xmldb_table('auth_outage'); + $field = new xmldb_field('accesskey', XMLDB_TYPE_CHAR, '16', null, null, null, null, 'finished'); + + // Conditionally launch add field accesskey. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Outage savepoint reached. + upgrade_plugin_savepoint(true, 2024080200, 'auth', 'outage'); + } + return true; } diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 2545145..e5f0a65 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -103,8 +103,8 @@ $string['infostaticpage'] = 'static page'; $string['infopagestaticgenerated'] = 'This warning was generated on {$a->time}.'; $string['ips_combine'] = 'The IPs listed above will be combined with the IPs listed below.'; -$string['allowedipsempty'] = 'When the allowed IPs list is empty we will not block anyone. You can add your own IP address ({$a->ip}) and block all other IPs.'; -$string['allowedipshasmyip'] = 'Your IP ({$a->ip}) is in the list and you will not be blocked out during an Outage.'; +$string['allowedipsempty'] = 'No one will be blocked by IP because the list is empty. You can add your own IP address ({$a->ip}) and block all other IPs. IP blocking is in addition to access key blocking (if setup in outage)'; +$string['allowedipshasmyip'] = 'Your IP ({$a->ip}) is in the list and you will not be blocked out during an Outage. However, if an outage key is enabled, you will still need to provide the outage key.'; $string['allowedipshasntmyip'] = 'Your IP ({$a->ip}) is not in the list and you will be blocked out during an outage.'; $string['allowedipsnoconfig'] = 'Your config.php does not have the extra setup to allow blocking via IP.
Please refer to our README.md file for more information.'; $string['logformaintmodeconfig'] = 'Update maintenance mode configuration.'; @@ -162,6 +162,10 @@ $string['warningduration'] = 'Warning duration'; $string['warningduration_help'] = 'How long before the start of the outage should the warning be displayed.'; $string['warningreenablemaintenancemode'] = 'Please note that saving this outage will re-enable maintenance mode.
Untick "Auto start maintenance mode" if you want to prevent this.'; +$string['accesskey'] = 'Access key'; +$string['accesskey_help'] = 'Testers should pass the access key initially in the url parameters e.g. ?accesskey=xyz. This will then be stored in a cookie for 24 hours, and the url parameter will not be necessary.
Note: the access key is in addition to any IP restrictions setup.'; +$string['useaccesskey'] = 'Use access key'; +$string['useaccesskey:desc'] = 'Require testers to access site during outage by providing the access key below'; /* * Privacy provider (GDPR) diff --git a/tests/local/outagelib_test.php b/tests/local/outagelib_test.php index 8a6618c..5ab154b 100644 --- a/tests/local/outagelib_test.php +++ b/tests/local/outagelib_test.php @@ -320,14 +320,30 @@ public function test_createmaintenancephpcode() { $expected = <<<'EOT' = 123) && (time() < 456)) { - define('MOODLE_INTERNAL', true); + if (!defined('MOODLE_INTERNAL')) { + define('MOODLE_INTERNAL', true); + } require_once($CFG->dirroot.'/lib/moodlelib.php'); if (file_exists($CFG->dirroot.'/lib/classes/ip_utils.php')) { require_once($CFG->dirroot.'/lib/classes/ip_utils.php'); } - if (!remoteip_in_list('hey\'\"you + // Put access key as a cookie if given. This stops the need to put it as a url param on every request. + $urlaccesskey = optional_param('accesskey', null, PARAM_TEXT); + + if (!empty($urlaccesskey)) { + setcookie('auth_outage_accesskey', $urlaccesskey, time() + 86400, '/', '', true, false); + } + + // Use url access key if given, else the cookie, else null. + $useraccesskey = $urlaccesskey ?: $_COOKIE['auth_outage_accesskey'] ?? null; + + $ipblocked = !remoteip_in_list('hey\'\"you a.b.c.d -e.e.e.e/20')) { +e.e.e.e/20'); + $accesskeyblocked = $useraccesskey != '12345'; + $blocked = (true && $accesskeyblocked) || (true && $ipblocked); + + if ($blocked) { header($_SERVER['SERVER_PROTOCOL'] . ' 503 Moodle under maintenance'); header('Status: 503 Moodle under maintenance'); header('Retry-After: 300'); @@ -343,7 +359,23 @@ public function test_createmaintenancephpcode() { if ((defined('AJAX_SCRIPT') && AJAX_SCRIPT) || (defined('WS_SERVER') && WS_SERVER)) { exit(0); } - echo ''; + + if (true && $ipblocked) { + echo ''; + } + + if (true && !$ipblocked) { + echo ''; + } + + if (true && $accesskeyblocked) { + echo ''; + } + + if (true && !$accesskeyblocked) { + echo ''; + } + if (file_exists($CFG->dataroot.'/climaintenance.template.html')) { require($CFG->dataroot.'/climaintenance.template.html'); exit(0); @@ -353,7 +385,7 @@ public function test_createmaintenancephpcode() { } } EOT; - $found = outagelib::create_climaintenancephp_code(123, 456, "hey'\"you\na.b.c.d\ne.e.e.e/20"); + $found = outagelib::create_climaintenancephp_code(123, 456, "hey'\"you\na.b.c.d\ne.e.e.e/20", '12345'); self::assertSame($expected, $found); } @@ -370,12 +402,28 @@ public function test_createmaintenancephpcode_withoutage($configkey) { $expected = <<<'EOT' = 123) && (time() < 456)) { - define('MOODLE_INTERNAL', true); + if (!defined('MOODLE_INTERNAL')) { + define('MOODLE_INTERNAL', true); + } require_once($CFG->dirroot.'/lib/moodlelib.php'); if (file_exists($CFG->dirroot.'/lib/classes/ip_utils.php')) { require_once($CFG->dirroot.'/lib/classes/ip_utils.php'); } - if (!remoteip_in_list('127.0.0.1')) { + // Put access key as a cookie if given. This stops the need to put it as a url param on every request. + $urlaccesskey = optional_param('accesskey', null, PARAM_TEXT); + + if (!empty($urlaccesskey)) { + setcookie('auth_outage_accesskey', $urlaccesskey, time() + 86400, '/', '', true, false); + } + + // Use url access key if given, else the cookie, else null. + $useraccesskey = $urlaccesskey ?: $_COOKIE['auth_outage_accesskey'] ?? null; + + $ipblocked = !remoteip_in_list('127.0.0.1'); + $accesskeyblocked = $useraccesskey != '5678'; + $blocked = (true && $accesskeyblocked) || (true && $ipblocked); + + if ($blocked) { header($_SERVER['SERVER_PROTOCOL'] . ' 503 Moodle under maintenance'); header('Status: 503 Moodle under maintenance'); header('Retry-After: 300'); @@ -391,7 +439,23 @@ public function test_createmaintenancephpcode_withoutage($configkey) { if ((defined('AJAX_SCRIPT') && AJAX_SCRIPT) || (defined('WS_SERVER') && WS_SERVER)) { exit(0); } - echo ''; + + if (true && $ipblocked) { + echo ''; + } + + if (true && !$ipblocked) { + echo ''; + } + + if (true && $accesskeyblocked) { + echo ''; + } + + if (true && !$accesskeyblocked) { + echo ''; + } + if (file_exists($CFG->dataroot.'/climaintenance.template.html')) { require($CFG->dataroot.'/climaintenance.template.html'); exit(0); @@ -404,6 +468,7 @@ public function test_createmaintenancephpcode_withoutage($configkey) { $outage = new outage([ 'starttime' => 123, 'stoptime' => 456, + 'accesskey' => '5678', ]); $file = $CFG->dataroot.'/climaintenance.php'; set_config($configkey, '127.0.0.1', 'auth_outage'); @@ -419,15 +484,16 @@ public function test_createmaintenancephpcode_withoutage_provider(): array { } /** - * Test create maintenance php code without IPs + * Test create maintenance php code without IPs or accesskey */ - public function test_createmaintenancephpcode_withoutips() { + public function test_createmaintenancephpcode_withoutips_or_accesskey() { global $CFG; $this->resetAfterTest(true); $outage = new outage([ 'starttime' => 123, 'stoptime' => 456, + 'accesskey' => null, ]); $file = $CFG->dataroot.'/climaintenance.php'; set_config('allowedips', '', 'auth_outage'); diff --git a/version.php b/version.php index 444264d..22065ed 100644 --- a/version.php +++ b/version.php @@ -28,7 +28,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = "auth_outage"; -$plugin->version = 2024052400; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2024080200; // The current plugin version (Date: YYYYMMDDXX). $plugin->release = 2024052400; // Human-readable release information. $plugin->requires = 2017111309; // 2017111309 = T13, but this really requires 3.9 and higher. $plugin->maturity = MATURITY_STABLE; // Suitable for PRODUCTION environments!