Issue #2 - IP Block implementation.

This commit is contained in:
Daniel Thee Roperto
2016-11-02 17:03:34 +11:00
parent 42fec9e752
commit 54c43d2508
13 changed files with 441 additions and 28 deletions

View File

@@ -77,6 +77,17 @@ https://github.com/catalyst/moodle-auth_outage/issues
3. Go to `Dashboard ► Site administration ► Plugins ► Authentication ► Manage authentication`,
enable the `Outage manager` plugin and place it on the top.
4. If you need to use the IP Blocking, please add the following lines into your `config.php`
after your `$CFG->dataroot` is set:
```
if (file_exists("$CFG->dataroot/climaintenance.php")) {
$CFG->dirroot = __DIR__;
require("$CFG->dataroot/climaintenance.php");
} else {
$CFG->auth_outage_check = 1;
}
```
How to use
----------

View File

@@ -135,7 +135,7 @@ class outagedb {
}
// Trigger outages modified events.
outagelib::outages_modified();
outagelib::prepare_next_outage();
// All done, return the id.
return $outage->id;
@@ -165,7 +165,7 @@ class outagedb {
calendar::delete($id);
// Trigger events.
outagelib::outages_modified();
outagelib::prepare_next_outage();
}
/**
@@ -358,4 +358,35 @@ class outagedb {
// Allowing multiple records still raises an internal error.
return (count($data) == 0) ? null : new outage(array_shift($data));
}
/**
* Gets an ongoing outage (between start and stop time but not finished).
* @param int|null $time Timestamp considered to check for outages, null for current date/time.
* @return outage|null The outage or null if no active outages were found.
* @throws coding_exception
*/
public static function get_ongoing($time = null) {
global $DB;
if ($time === null) {
$time = time();
}
if (!is_int($time) || ($time <= 0)) {
throw new coding_exception('$time must be null or a positive int.', $time);
}
$data = $DB->get_records_select(
'auth_outage',
'starttime <= :datetime1 AND :datetime2 <= stoptime AND finished IS NULL',
['datetime1' => $time, 'datetime2' => $time, 'datetime3' => $time],
'starttime ASC, stoptime DESC, title ASC',
'*',
0,
1
);
// Not using $DB->get_record_select instead because there is no 'limit' parameter.
// Allowing multiple records still raises an internal error.
return (count($data) == 0) ? null : new outage(array_shift($data));
}
}

View File

@@ -124,26 +124,25 @@ class infopage {
/**
* Updates the static info page by (re)creating or deleting it as needed.
* @param outage|null $outage Outage or null if no scheduled outage.
* @param string|null $file File to update. Null to use default.
* @throws coding_exception
* @throws file_exception
*/
public static function update_static_page($file = null) {
public static function update_static_page($outage, $file = null) {
if (is_null($file)) {
$file = self::get_defaulttemplatefile();
}
if (!is_string($file)) {
throw new coding_exception('$file is not a string.', $file);
}
if (!is_null($outage) && !($outage instanceof outage)) {
throw new coding_exception('$outage must be null or an outage object.');
}
$outage = outagedb::get_next_starting();
if (is_null($outage)) {
if (file_exists($file)) {
if (is_file($file) && is_writable($file)) {
unlink($file);
} else {
throw new file_exception('Cannot remove: '.$file);
}
unlink($file);
}
} else {
self::save_static_page($outage, $file);

View File

@@ -30,6 +30,8 @@ use auth_outage\local\controllers\infopage;
use auth_outage\output\renderer;
use coding_exception;
use Exception;
use file_exception;
use invalid_parameter_exception;
use stdClass;
defined('MOODLE_INTERNAL') || die();
@@ -130,29 +132,36 @@ class outagelib {
*/
public static function get_config_defaults() {
return [
'allowedips' => '',
'css' => '',
'default_autostart' => '0',
'default_duration' => (string)(60 * 60),
'default_warning_duration' => (string)(60 * 60),
'default_title' => get_string('defaulttitlevalue', 'auth_outage'),
'default_description' => get_string('defaultdescriptionvalue', 'auth_outage'),
'css' => '',
];
}
/**
* Executed when outages are modified (created, updated or deleted).
*/
public static function outages_modified() {
infopage::update_static_page();
self::update_maintenance_later();
public static function prepare_next_outage() {
// If there is an ongoing outage, prepare it instead.
$outage = outagedb::get_ongoing();
if (is_null($outage)) {
$outage = outagedb::get_next_starting();
}
infopage::update_static_page($outage);
self::update_climaintenance_code($outage);
self::update_maintenance_later($outage);
}
/**
* Calls Moodle API - set_maintenance_later() to set when the next outage starts.
* @param outage|null $outage Outage or null if no scheduled outage.
*/
private static function update_maintenance_later() {
$next = outagedb::get_next_autostarting();
if (is_null($next)) {
private static function update_maintenance_later($outage) {
if (is_null($outage) || !$outage->autostart) {
unset_config('maintenance_later');
} else {
$message = get_config('moodle', 'maintenance_message');
@@ -162,7 +171,7 @@ class outagelib {
// We cannot do much if forced config, but the logs will show the error.
unset_config('maintenance_message');
}
set_config('maintenance_later', $next->starttime);
set_config('maintenance_later', $outage->starttime);
}
}
@@ -187,4 +196,90 @@ class outagelib {
// Nothing preventing the injection.
return true;
}
/**
* Generates the code to put in sitedata/climaintenance.php when needed.
* @param int $starttime Outage start time.
* @param int $stoptime Outage stop time.
* @param string $allowedips List of IPs allowed.
* @return string
* @throws invalid_parameter_exception
*/
public static function create_climaintenancephp_code($starttime, $stoptime, $allowedips) {
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 = str_replace('\'"', '', $allowedips);
$code = <<<'EOT'
<?php
if (time() >= {{STARTTIME}}) {
if (!defined('CLI_SCRIPT') || !CLI_SCRIPT) {
define('MOODLE_INTERNAL', true);
require_once($CFG->dirroot.'/lib/moodlelib.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');
echo '<!-- Blocked by ip, your ip: '.getremoteaddr('n/a').' -->';
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.');
}
}
}
$CFG->auth_outage_check = 1;
EOT;
$search = ['{{STARTTIME}}', '{{ALLOWEDIPS}}', '{{YOURIP}}'];
$replace = [$starttime, $allowedips, getremoteaddr('n/a')];
return str_replace($search, $replace, $code);
}
/**
* Updates the static info page by (re)creating or deleting it as needed.
* @param outage|null $outage Outage or null if no scheduled outage.
* @throws coding_exception
* @throws file_exception
*/
public static function update_climaintenance_code($outage) {
global $CFG;
$file = $CFG->dataroot.'/climaintenance.php';
if (!is_null($outage) && !($outage instanceof outage)) {
throw new coding_exception('$outage must be null or an outage object.');
}
$config = self::get_config();
$allowedips = trim($config->allowedips);
if (is_null($outage) || ($allowedips == '')) {
if (file_exists($file)) {
unlink($file);
}
} else {
$code = self::create_climaintenancephp_code($outage->starttime, $outage->stoptime, $allowedips);
$dir = dirname($file);
if (!file_exists($dir) || !is_dir($dir)) {
throw new file_exception('Directory must exists: '.$dir);
}
file_put_contents($file, $code);
}
}
}

View File

@@ -26,6 +26,7 @@
namespace auth_outage\task;
use auth_outage\local\controllers\infopage;
use auth_outage\local\outagelib;
use core\task\scheduled_task;
defined('MOODLE_INTERNAL') || die();
@@ -51,6 +52,6 @@ class update_static_page extends scheduled_task {
* Executes the event.
*/
public function execute() {
infopage::update_static_page();
outagelib::prepare_next_outage();
}
}

View File

@@ -84,6 +84,10 @@ $string['infountil'] = 'Until:';
$string['infostart'] = 'start';
$string['infostartofwarning'] = 'start of warning';
$string['infopagestaticgenerated'] = 'This warning was generated on {$a->time}.';
$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['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['menusettings'] = 'Settings';
$string['menumanage'] = 'Manage';
$string['messageoutagebackonline'] = 'We are back online!';
@@ -108,6 +112,10 @@ $string['outagefinishwarning'] = 'You are about to mark this outage as finished.
$string['outageslistfuture'] = 'Planned outages';
$string['outageslistpast'] = 'Outage history';
$string['pluginname'] = 'Outage manager';
$string['settingssectiondefaults'] = 'Default Outage Parameters';
$string['settingssectiondefaultsdescription'] = 'Configure the default values used when creating new outages.';
$string['settingssectionplugin'] = 'Plugin Configuration';
$string['settingssectionplugindescription'] = 'General outage management plugin settings.';
$string['starttime'] = 'Start date and time';
$string['starttime_help'] = 'At which date and time the outage starts, preventing general access to the system.';
$string['tableheaderduration'] = 'Duration';

View File

@@ -28,8 +28,12 @@ defined('MOODLE_INTERNAL') || die;
if ($hassiteconfig && is_enabled_auth('outage')) {
$defaults = outagelib::get_config_defaults();
// Configure default settings page.
$settings->visiblename = get_string('menusettings', 'auth_outage');
$settings->add(new admin_setting_heading(
'defaults',
get_string('settingssectiondefaults', 'auth_outage'),
get_string('settingssectiondefaultsdescription', 'auth_outage')));
$settings->add(new admin_setting_configcheckbox(
'auth_outage/default_autostart',
get_string('defaultoutageautostart', 'auth_outage'),
@@ -64,6 +68,12 @@ if ($hassiteconfig && is_enabled_auth('outage')) {
$defaults['default_description'],
PARAM_RAW
));
$settings->add(new admin_setting_heading(
'plugin',
get_string('settingssectionplugin', 'auth_outage'),
get_string('settingssectionplugindescription', 'auth_outage')));
$settings->add(new admin_setting_configtextarea(
'auth_outage/css',
get_string('defaultlayoutcss', 'auth_outage'),
@@ -71,6 +81,36 @@ if ($hassiteconfig && is_enabled_auth('outage')) {
$defaults['css'],
PARAM_RAW
));
// Create 'Allowed IPs' settings.
$allowedips = outagelib::get_config()->allowedips;
$description = '';
if (!isset($CFG->auth_outage_check) || !$CFG->auth_outage_check) {
$description .= $OUTPUT->notification(get_string('allowedipsnoconfig', 'auth_outage'), 'notifyfailure');
}
if (trim($allowedips) == '') {
$message = 'allowedipsempty';
$type = 'notifymessage';
} else if (remoteip_in_list($allowedips)) {
$message = 'allowedipshasmyip';
$type = 'notifysuccess';
} else {
$message = 'allowedipshasntmyip';
$type = 'notifyfailure';
};
$description .= $OUTPUT->notification(get_string($message, 'auth_outage', ['ip' => getremoteaddr()]), $type);
$description .= '<p>'.get_string('ipblockersyntax', 'admin').'</p>';
$settings->add(new admin_setting_configiplist(
'auth_outage/allowedips',
get_string('allowediplist', 'admin'),
$description,
$defaults['allowedips']
));
// Create category for Outage.
$ADMIN->add('authsettings', new admin_category('auth_outage', get_string('pluginname', 'auth_outage')));
// Add settings page toconfigure defaults.

View File

@@ -154,6 +154,21 @@ class behat_auth_outage extends behat_base {
}
}
/**
* @Then /^I should see an empty settings text area "([^"]*)"$/
* @param string $name
*/
public function i_should_see_an_empty_settings_text_area($name) {
$this->assertSession()->fieldValueEquals('s_auth_outage_'.$name, '');
}
/**
* @When /^I go to the "Outage Settings" page$/
*/
public function i_go_to_the_outage_settings_page() {
$this->getSession()->visit($this->locate_path('/admin/settings.php?section=authsettingoutage'));
}
/**
* Counts how many times an specific action is visible.
* @param string $action Action to check.

View File

@@ -0,0 +1,21 @@
@dev @auth @auth_outage @javascript
Feature: IP Blocker
In order to allow admins to access the system during an outage
As an admin
I need to be able to login into Moodle
Terminology:
- An ongoing outage does not block Moodle execution, although it can trigger maintenance mode.
- Maintenance mode completely blocks Moodle and can only be deactivated using the CLI.
Background:
Given the authentication plugin "outage" is enabled
Scenario: Default IP Whitelist Settings
Given I am an administrator
And I am on homepage
When I navigate to "Settings" node in "Site administration > Plugins > Authentication > Outage manager"
Then I should see "Allowed IP list"
And I should see an empty settings text area "allowedips"

View File

@@ -1,4 +1,4 @@
@dev @auth @auth_outage @javascript
@auth @auth_outage @javascript
Feature: Warning bar
In order alert users about an outage
As any user

View File

@@ -257,7 +257,7 @@ class infopagecontroller_test extends auth_outage_base_testcase {
* Tests updating the static page when there is no outage.
*/
public function test_updatestaticpage_nooutage() {
infopage::update_static_page();
infopage::update_static_page(null);
}
/**
@@ -267,7 +267,7 @@ class infopagecontroller_test extends auth_outage_base_testcase {
$file = infopage::get_defaulttemplatefile();
touch($file);
self::assertFileExists($file);
infopage::update_static_page();
infopage::update_static_page(null);
self::assertFileNotExists($file);
}
@@ -276,7 +276,15 @@ class infopagecontroller_test extends auth_outage_base_testcase {
*/
public function test_updatestaticpage_invalidfile() {
$this->set_expected_exception('coding_exception');
infopage::update_static_page(123);
infopage::update_static_page(null, 123);
}
/**
* Tests updating the static page with an invalid outage.
*/
public function test_updatestaticpage_invalidoutage() {
$this->set_expected_exception('coding_exception');
infopage::update_static_page("foobar");
}
/**
@@ -339,6 +347,7 @@ class infopagecontroller_test extends auth_outage_base_testcase {
* Checks if we can create and execute a task to update outage pages.
*/
public function test_tasks() {
$this->resetAfterTest(true);
$task = new update_static_page();
self::assertNotEmpty($task->get_name());
$task->execute();

View File

@@ -24,6 +24,7 @@
*/
use auth_outage\dml\outagedb;
use auth_outage\local\controllers\infopage;
use auth_outage\local\outage;
use auth_outage\local\outagelib;
@@ -70,7 +71,7 @@ class outagelib_test extends advanced_testcase {
$this->resetAfterTest(true);
set_config('maintenance_later', time() + (60 * 60 * 24 * 7)); // In 1 week.
self::assertNotEmpty(get_config('moodle', 'maintenance_later'));
outagelib::outages_modified();
outagelib::prepare_next_outage();
self::assertEmpty(get_config('moodle', 'maintenance_later'));
}
@@ -194,6 +195,7 @@ class outagelib_test extends advanced_testcase {
'default_duration',
'default_title',
'default_warning_duration',
'allowedips',
];
// Set config with values.
foreach ($keys as $k) {
@@ -206,12 +208,33 @@ class outagelib_test extends advanced_testcase {
}
}
/**
* Check that config has key.
*/
public function test_config_keys() {
$this->resetAfterTest(true);
$keys = [
'allowedips',
'css',
'default_autostart',
'default_description',
'default_duration',
'default_title',
'default_warning_duration',
];
$defaults = outagelib::get_config_defaults();
foreach ($keys as $k) {
self::assertArrayHasKey($k, $defaults);
}
}
/**
* Check if get config works getting defaults when needed.
*/
public function test_get_config_invalid() {
$this->resetAfterTest(true);
// Set config with invalid values.
set_config('allowedips', " \n", 'auth_outage');
set_config('css', " \n", 'auth_outage');
set_config('default_autostart', " \n", 'auth_outage');
set_config('default_description', " \n", 'auth_outage');
@@ -221,7 +244,7 @@ class outagelib_test extends advanced_testcase {
// Get defaults.
$defaults = outagelib::get_config_defaults();
$config = outagelib::get_config();
// Ensure it is using all defailts.
// Ensure it is using all defaults.
foreach ($defaults as $k => $v) {
self::assertSame($v, $config->$k);
}
@@ -255,5 +278,165 @@ class outagelib_test extends advanced_testcase {
self::assertEmpty($CFG->additionalhtmltopofbody);
}
}
public function test_createmaintenancephpcode() {
$expected = <<<'EOT'
<?php
if (time() >= 123) {
if (!defined('CLI_SCRIPT') || !CLI_SCRIPT) {
define('MOODLE_INTERNAL', true);
require_once($CFG->dirroot.'/lib/moodlelib.php');
if (!remoteip_in_list('heyyou
a.b.c.d
e.e.e.e/20')) {
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');
echo '<!-- Blocked by ip, your ip: '.getremoteaddr('n/a').' -->';
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.');
}
}
}
$CFG->auth_outage_check = 1;
EOT;
$found = outagelib::create_climaintenancephp_code(123, 456, "hey'\"you\na.b.c.d\ne.e.e.e/20");
self::assertSame($expected, $found);
}
public function test_createmaintenancephpcode_withoutage() {
global $CFG;
$this->resetAfterTest(true);
$expected = <<<'EOT'
<?php
if (time() >= 123) {
if (!defined('CLI_SCRIPT') || !CLI_SCRIPT) {
define('MOODLE_INTERNAL', true);
require_once($CFG->dirroot.'/lib/moodlelib.php');
if (!remoteip_in_list('127.0.0.1')) {
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');
echo '<!-- Blocked by ip, your ip: '.getremoteaddr('n/a').' -->';
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.');
}
}
}
$CFG->auth_outage_check = 1;
EOT;
$outage = new outage([
'starttime' => 123,
'stoptime' => 456,
]);
$file = $CFG->dataroot.'/climaintenance.php';
set_config('allowedips', '127.0.0.1', 'auth_outage');
outagelib::update_climaintenance_code($outage);
self::assertFileExists($file);
$found = file_get_contents($file);
self::assertSame($found, $expected);
}
public function test_createmaintenancephpcode_withoutips() {
global $CFG;
$this->resetAfterTest(true);
$outage = new outage([
'starttime' => 123,
'stoptime' => 456,
]);
$file = $CFG->dataroot.'/climaintenance.php';
set_config('allowedips', '', 'auth_outage');
touch($file);
outagelib::update_climaintenance_code($outage);
self::assertFileNotExists($file);
}
public function test_createmaintenancephpcode_withoutoutage() {
global $CFG;
$file = $CFG->dataroot.'/climaintenance.php';
touch($file);
outagelib::update_climaintenance_code(null);
self::assertFileNotExists($file);
}
/**
* Related to Issue #70: Creating ongoing outage does not trigger maintenance file creation.
*/
public function test_preparenextoutage_notautostart() {
global $CFG;
$this->resetAfterTest(true);
self::setAdminUser();
$now = time();
$outage = new outage([
'autostart' => false,
'warntime' => $now - 200,
'starttime' => $now - 100,
'stoptime' => $now + 200,
'title' => 'Title',
'description' => 'Description',
]);
set_config('allowedips', '127.0.0.1', 'auth_outage');
outagedb::save($outage);
// The method outagelib::prepare_next_outage() should have been called by save().
foreach ([infopage::get_defaulttemplatefile(), $CFG->dataroot.'/climaintenance.php'] as $file) {
self::assertFileExists($file);
unlink($file);
}
}
/**
* Related to Issue #72: IP Block still triggers cli maintenance mode even without autostart.
*/
public function test_preparenextoutage_noautostarttrigger() {
global $CFG;
$this->resetAfterTest(true);
self::setAdminUser();
$now = time();
$outage = new outage([
'autostart' => false,
'warntime' => $now - 200,
'starttime' => $now - 100,
'stoptime' => $now + 200,
'title' => 'Title',
'description' => 'Description',
]);
outagedb::save($outage);
// The method outagelib::prepare_next_outage() should have been called by save().
self::assertFalse(get_config('moodle', 'maintenance_later'));
// This file should not exist even if the statement above fails as Moodle does not create it immediately but test anyway.
self::assertFileNotExists($CFG->dataroot.'/climaintenance.html');
}
}

View File

@@ -28,7 +28,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = "auth_outage";
$plugin->version = 2016110200; // The current plugin version (Date: YYYYMMDDXX).
$plugin->release = '1.0.5'; // Human-readable release information.
$plugin->version = 2016110700; // The current plugin version (Date: YYYYMMDDXX).
$plugin->release = '1.0.6'; // Human-readable release information.
$plugin->requires = 2014051200; // Requires Moodle 2.7 or later. Moodle 3.0 or later recommended.
$plugin->maturity = MATURITY_STABLE; // Suitable for PRODUCTION environments!