Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#340] Access key exclusion method #341

Merged
merged 1 commit into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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?
-------------
Expand Down Expand Up @@ -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
-------------------
Expand Down
4 changes: 3 additions & 1 deletion bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,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.
}
Expand Down
11 changes: 11 additions & 0 deletions classes/form/outage/edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ 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();
}

Expand Down Expand Up @@ -128,6 +136,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);
}
Expand All @@ -151,6 +160,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.
Expand Down
5 changes: 5 additions & 0 deletions classes/local/outage.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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.
Expand Down
106 changes: 77 additions & 29 deletions classes/local/outagelib.php
Original file line number Diff line number Diff line change
Expand Up @@ -279,57 +279,103 @@ 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();

// Since Moodle 4.3 cookiehttponly is default to true and this CFG is not set.
// so if not set, default to true.
$cookiehttponly = isset($CFG->cookiehttponly) ? (bool) $CFG->cookiehttponly : true;

$code = <<<'EOT'
<?php
if ((time() >= {{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}}')) {
header($_SERVER['SERVER_PROTOCOL'] . ' 503 Moodle under maintenance');
header('Status: 503 Moodle under maintenance');
header('Retry-After: 300');
header('Content-type: text/html; charset=utf-8');
header('X-UA-Compatible: IE=edge');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Expires: Mon, 20 Aug 1969 09:23:00 GMT');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
header('Accept-Ranges: none');
header('X-Moodle-Maintenance: manager');
if ((defined('AJAX_SCRIPT') && AJAX_SCRIPT) || (defined('WS_SERVER') && WS_SERVER)) {
exit(0);
// 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);
$isphpunit = defined('PHPUNIT_TEST');

if ($blocked) {
if (!$isphpunit) {
header($_SERVER['SERVER_PROTOCOL'] . ' 503 Moodle under maintenance');
header('Status: 503 Moodle under maintenance');
header('Retry-After: 300');
header('Content-type: text/html; charset=utf-8');
header('X-UA-Compatible: IE=edge');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Expires: Mon, 20 Aug 1969 09:23:00 GMT');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
header('Accept-Ranges: none');
header('X-Moodle-Maintenance: manager');
}
echo '<!-- Blocked by ip, your ip: '.getremoteaddr('n/a').' -->';
if (file_exists($CFG->dataroot.'/climaintenance.template.html')) {
require($CFG->dataroot.'/climaintenance.template.html');

if (!$isphpunit && ((defined('AJAX_SCRIPT') && AJAX_SCRIPT) || (defined('WS_SERVER') && WS_SERVER))) {
exit(0);
}
// The file above should always exist, but just in case...
die('We are currently under maintentance, please try again later.');

if ({{USEALLOWEDIPS}} && $ipblocked) {
echo '<!-- Blocked by ip, your ip: '.getremoteaddr('n/a').' -->';
}

if ({{USEALLOWEDIPS}} && !$ipblocked) {
echo '<!-- Your IP is allowed: '.getremoteaddr('n/a').' -->';
}

if ({{USEACCESSKEY}} && $accesskeyblocked) {
echo '<!-- Blocked by missing or incorrect access key, access key given: '. $useraccesskey .' -->';
}

if ({{USEACCESSKEY}} && !$accesskeyblocked) {
echo '<!-- Your access key is allowed: '. $useraccesskey .' -->';
}

if (!$isphpunit) {
if (file_exists($CFG->dataroot.'/climaintenance.template.html')) {
require($CFG->dataroot.'/climaintenance.template.html');
exit(0);
}
// The file above should always exist, but just in case...
die('We are currently under maintentance, please try again later.');
}
}
}
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);
}

Expand All @@ -351,13 +397,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, $accesskey);

$dir = dirname($file);
if (!file_exists($dir) || !is_dir($dir)) {
Expand Down
1 change: 1 addition & 0 deletions db/install.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<FIELD NAME="modifiedby" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Who last modified this entry."/>
<FIELD NAME="lastmodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="When was this entry last modified."/>
<FIELD NAME="finished" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="Timestamp of when the outage really finished."/>
<FIELD NAME="accesskey" TYPE="char" LENGTH="16" NOTNULL="false" SEQUENCE="false" COMMENT="Unique key used to access during outage"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
Expand Down
15 changes: 15 additions & 0 deletions db/upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,20 @@ function xmldb_auth_outage_upgrade($oldversion) {
upgrade_plugin_savepoint(true, 2016092200, 'auth', 'outage');
}

if ($oldversion < 2024081900) {

// 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, 2024081900, 'auth', 'outage');
}

return true;
}
10 changes: 7 additions & 3 deletions lang/en/auth_outage.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@
$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 (<i>{$a->ip}</i>) and block all other IPs.';
$string['allowedipshasmyip'] = 'Your IP (<i>{$a->ip}</i>) is in the list and you will not be blocked out during an Outage.';
$string['allowedipshasntmyip'] = 'Your IP (<i>{$a->ip}</i>) is not in the list and you will 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 (<i>{$a->ip}</i>) and block all other IPs. IP blocking is in addition to access key blocking (if setup in outage)';
$string['allowedipshasmyip'] = 'Your IP (<i>{$a->ip}</i>) is in the list and your IP will not be blocked out during an Outage.';
$string['allowedipshasntmyip'] = 'Your IP (<i>{$a->ip}</i>) is not in the list and your IP will be blocked out during an outage.';
$string['allowedipsnoconfig'] = 'Your config.php does not have the extra setup to allow blocking via IP.<br />Please refer to our <a href="https://github.com/catalyst/moodle-auth_outage#installation" target="_blank">README.md</a> file for more information.';
$string['logformaintmodeconfig'] = 'Update maintenance mode configuration.';
$string['logformaintmodeconfigcomplete'] = 'Updating maintenance mode configuration complete.';
Expand Down Expand Up @@ -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.<br />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, during which the url parameter will not be necessary.<br /><b>Note:</b> 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)
Expand Down
Loading
Loading