Ir a la última revisión | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
<?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 tool_mfa;use dml_exception;use tool_mfa\plugininfo\factor;/*** MFA management class.** @package tool_mfa* @author Peter Burnett <peterburnett@catalyst-au.net>* @copyright Catalyst IT* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class manager {/** @var int */const REDIRECT = 1;/** @var int */const NO_REDIRECT = 0;/** @var int */const REDIRECT_EXCEPTION = -1;/** @var int */const REDIR_LOOP_THRESHOLD = 5;/*** Displays a debug table with current factor information.** @return void*/public static function display_debug_notification(): void {global $OUTPUT, $PAGE;if (!get_config('tool_mfa', 'debugmode')) {return;}$html = $OUTPUT->heading(get_string('debugmode:heading', 'tool_mfa'), 3);$table = new \html_table();$table->head = [get_string('weight', 'tool_mfa'),get_string('factor', 'tool_mfa'),get_string('setup', 'tool_mfa'),get_string('achievedweight', 'tool_mfa'),get_string('status'),];$table->attributes['class'] = 'admintable generaltable table table-bordered';$table->colclasses = ['text-right','','','text-right','text-center',];$factors = factor::get_enabled_factors();$userfactors = factor::get_active_user_factor_types();$runningtotal = 0;$weighttoggle = false;foreach ($factors as $factor) {$namespace = 'factor_'.$factor->name;$name = get_string('pluginname', $namespace);// If factor is unknown, pending from here.if ($factor->get_state() == factor::STATE_UNKNOWN) {$weighttoggle = true;}// Stop adding weight if 100 achieved.if (!$weighttoggle) {$achieved = $factor->get_state() == factor::STATE_PASS ? $factor->get_weight() : 0;$achieved = '+'.$achieved;$runningtotal += $achieved;} else {$achieved = '';}// Setup.if ($factor->has_setup()) {$found = false;foreach ($userfactors as $userfactor) {if ($userfactor->name == $factor->name) {$found = true;}}$setup = $found ? get_string('yes') : get_string('no');} else {$setup = get_string('na', 'tool_mfa');}// Status.$OUTPUT = $PAGE->get_renderer('tool_mfa');// If toggle has been flipped, fall to default pending badge.if ($weighttoggle) {$state = $OUTPUT->get_state_badge('');} else {$state = $OUTPUT->get_state_badge($factor->get_state());}$table->data[] = [$factor->get_weight(),$name,$setup,$achieved,$state,];// If we just hit 100, flip toggle.if ($runningtotal >= 100) {$weighttoggle = true;}}$finalstate = self::get_status();$table->data[] = ['','','<b>' . get_string('overall', 'tool_mfa') . '</b>',self::get_cumulative_weight(),$OUTPUT->get_state_badge($finalstate),];$html .= \html_writer::table($table);echo $html;}/*** Returns the total weight from all factors currently enabled for user.** @return int*/public static function get_total_weight(): int {$totalweight = 0;$factors = factor::get_active_user_factor_types();foreach ($factors as $factor) {if ($factor->get_state() == factor::STATE_PASS) {$totalweight += $factor->get_weight();}}return $totalweight;}/*** Checks that provided factorid exists and belongs to current user.** @param int $factorid* @param object $user* @return bool* @throws \dml_exception*/public static function is_factorid_valid(int $factorid, object $user): bool {global $DB;return $DB->record_exists('tool_mfa', ['userid' => $user->id, 'id' => $factorid]);}/*** Function to display to the user that they cannot login, then log them out.** @return void*/public static function cannot_login(): void {global $ME, $PAGE, $SESSION, $USER;// Determine page URL without triggering warnings from $PAGE.if (!preg_match("~(\/admin\/tool\/mfa\/auth.php)~", $ME)) {// If URL isn't set, we need to redir to auth.php.// This ensures URL and required info is correctly set.// Then we arrive back here.redirect(new \moodle_url('/admin/tool/mfa/auth.php'));}$renderer = $PAGE->get_renderer('tool_mfa');echo $renderer->header();if (get_config('tool_mfa', 'debugmode')) {self::display_debug_notification();}echo $renderer->not_enough_factors();echo $renderer->footer();// Emit an event for failure, then logout.$event = \tool_mfa\event\user_failed_mfa::user_failed_mfa_event($USER);$event->trigger();// We should set the redir flag, as this page is generated through auth.php.$SESSION->tool_mfa_has_been_redirected = true;die;}/*** Logout user.** @return void*/public static function mfa_logout(): void {$authsequence = get_enabled_auth_plugins();foreach ($authsequence as $authname) {$authplugin = get_auth_plugin($authname);$authplugin->logoutpage_hook();}require_logout();}/*** Function to get the overall status of a user's authentication.** @return string a STATE variable from plugininfo*/public static function get_status(): string {global $SESSION;// Check for any instant fail states.$factors = factor::get_active_user_factor_types();foreach ($factors as $factor) {$factor->load_locked_state();if ($factor->get_state() == factor::STATE_FAIL) {return factor::STATE_FAIL;}}$passcondition = ((isset($SESSION->tool_mfa_authenticated) && $SESSION->tool_mfa_authenticated) ||self::passed_enough_factors());// Check next factor for instant fail (fallback).if (factor::get_next_user_login_factor()->get_state() == factor::STATE_FAIL) {// We need to handle a special case here, where someone reached the fallback,// If they were able to modify their state on the error page, such as passing iprange,// We must return pass.if ($passcondition) {return factor::STATE_PASS;}return factor::STATE_FAIL;}// Now check for general passing state. If found, ensure that session var is set.if ($passcondition) {return factor::STATE_PASS;}// Else return neutral state.return factor::STATE_NEUTRAL;}/*** Function to check the overall status of a users authentication,* and perform any required actions.** @param bool $shouldreload whether the function should reload (used for auth.php).* @return void*/public static function resolve_mfa_status(bool $shouldreload = false): void {global $SESSION;$state = self::get_status();if ($state == factor::STATE_PASS) {self::set_pass_state();// Check if user even had to reach auth page.if (isset($SESSION->tool_mfa_has_been_redirected)) {if (empty($SESSION->wantsurl)) {$wantsurl = '/';} else {$wantsurl = $SESSION->wantsurl;}unset($SESSION->wantsurl);redirect(new \moodle_url($wantsurl));} else {// Don't touch anything, let user be on their way.return;}} else if ($state == factor::STATE_FAIL) {self::cannot_login();} else if ($shouldreload) {// Set a session variable to track whether user is where they want to be.$SESSION->tool_mfa_has_been_redirected = true;$authurl = new \moodle_url('/admin/tool/mfa/auth.php');redirect($authurl);}}/*** Checks whether user has passed enough factors to be allowed in.** @return bool true if user has passed enough factors.*/public static function passed_enough_factors(): bool {// Check for any instant fail states.$factors = factor::get_active_user_factor_types();foreach ($factors as $factor) {if ($factor->get_state() == factor::STATE_FAIL) {self::mfa_logout();}}$totalweight = self::get_cumulative_weight();if ($totalweight >= 100) {return true;}return false;}/*** Sets the session variable for pass_state, if not already set.** @return void*/public static function set_pass_state(): void {global $DB, $SESSION, $USER;if (!isset($SESSION->tool_mfa_authenticated)) {$SESSION->tool_mfa_authenticated = true;$event = \tool_mfa\event\user_passed_mfa::user_passed_mfa_event($USER);$event->trigger();// Allow plugins to callback as soon possible after user has passed MFA.$hook = new \tool_mfa\hook\after_user_passed_mfa();\core\di::get(\core\hook\manager::class)->dispatch($hook);// Add/update record in DB for users last mfa auth.self::update_pass_time();// Unset session vars during mfa auth.unset($SESSION->mfa_redir_referer);unset($SESSION->mfa_redir_count);// Unset user preferences during mfa auth.unset_user_preference('mfa_sleep_duration', $USER);try {// Clear locked user factors, they may now reauth with anything.@$DB->set_field('tool_mfa', 'lockcounter', 0, ['userid' => $USER->id]);// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch} catch (\Exception $e) {// This occurs when upgrade.php hasn't been run. Nothing to do here.}// Fire post pass state factor actions.$factors = factor::get_active_user_factor_types();foreach ($factors as $factor) {$factor->post_pass_state();// Also set the states for this session to neutral if they were locked.if ($factor->get_state() == factor::STATE_LOCKED) {$factor->set_state(factor::STATE_NEUTRAL);}}// Output notifications if any factors were reset for this user.$enabledfactors = factor::get_enabled_factors();foreach ($enabledfactors as $factor) {$pref = 'tool_mfa_reset_' . $factor->name;$factorpref = get_user_preferences($pref, false);if ($factorpref) {$url = new \moodle_url('/admin/tool/mfa/user_preferences.php');$link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));$data = ['factor' => $factor->get_display_name(), 'url' => $link];\core\notification::warning(get_string('factorreset', 'tool_mfa', $data));unset_user_preference($pref);}}// Also check for a global reset.// TODO: Delete this in a few months, the reset all preference is no longer set.$allfactor = get_user_preferences('tool_mfa_reset_all', false);if ($allfactor) {$url = new \moodle_url('/admin/tool/mfa/user_preferences.php');$link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));\core\notification::warning(get_string('factorresetall', 'tool_mfa', $link));unset_user_preference('tool_mfa_reset_all');}}}/*** Inserts or updates user's last MFA pass time in DB.* This should only be called from set_pass_state.** @return void*/private static function update_pass_time(): void {global $DB, $USER;$exists = $DB->record_exists('tool_mfa_auth', ['userid' => $USER->id]);if ($exists) {$DB->set_field('tool_mfa_auth', 'lastverified', time(), ['userid' => $USER->id]);} else {$DB->insert_record('tool_mfa_auth', ['userid' => $USER->id, 'lastverified' => time()]);}}/*** Checks whether the user should be redirected from the provided url.** @param string|\moodle_url $url* @param bool|null $preventredirect* @return int*/public static function should_require_mfa(string|\moodle_url $url, bool|null $preventredirect): int {global $CFG, $USER, $SESSION;// If no cookies then no session so cannot do MFA.// Unit testing based on defines is not viable.if (NO_MOODLE_COOKIES && !PHPUNIT_TEST) {return self::NO_REDIRECT;}// Remove all params before comparison.$url->remove_all_params();// Checks for upgrades pending.if (is_siteadmin()) {// We should only allow an upgrade from the frontend to complete.// After that is completed, only the settings shouldn't redirect.// Everything else should be safe to enforce MFA.if (moodle_needs_upgrading()) {return self::NO_REDIRECT;}// An upgrade isn't complete if there are settings that must be saved.$upgradesettings = new \moodle_url('/admin/upgradesettings.php');if ($url->compare($upgradesettings, URL_MATCH_BASE)) {return self::NO_REDIRECT;}}// Dont redirect logo images from pluginfile.php (for example: logo in header).$logourl = new \moodle_url('/pluginfile.php/1/core_admin/logocompact/');if ($url->compare($logourl)) {return self::NO_REDIRECT;}// Admin not setup.if (!empty($CFG->adminsetuppending)) {return self::NO_REDIRECT;}// Initial installation.// We get this for free from get_plugins_with_function.// Upgrade check.// We get this for free from get_plugins_with_function.// Honor prevent_redirect.if ($preventredirect) {return self::NO_REDIRECT;}// User not properly setup.if (user_not_fully_set_up($USER)) {return self::NO_REDIRECT;}// Enrolment.$enrol = new \moodle_url('/enrol/index.php');if ($enrol->compare($url, URL_MATCH_BASE)) {return self::NO_REDIRECT;}// Guest access.if (isguestuser()) {return self::NO_REDIRECT;}// Forced password changes.if (get_user_preferences('auth_forcepasswordchange')) {return self::NO_REDIRECT;}// Login as.if (\core\session\manager::is_loggedinas()) {return self::NO_REDIRECT;}// Site policy.if (isset($USER->policyagreed) && !$USER->policyagreed) {$manager = new \core_privacy\local\sitepolicy\manager();$policyurl = $manager->get_redirect_url(false);if (!empty($policyurl) && $url->compare($policyurl, URL_MATCH_BASE)) {return self::NO_REDIRECT;}}// WS/AJAX check.if (WS_SERVER || AJAX_SCRIPT) {if (isset($SESSION->mfa_pending) && !empty($SESSION->mfa_pending)) {// Allow AJAX and WS, but never from auth.php.return self::NO_REDIRECT;}return self::REDIRECT_EXCEPTION;}// Check factor defined safe urls.$factorurls = self::get_no_redirect_urls();foreach ($factorurls as $factorurl) {if ($factorurl->compare($url)) {return self::NO_REDIRECT;}}// Circular checks.$authurl = new \moodle_url('/admin/tool/mfa/auth.php');$authlocal = $authurl->out_as_local_url();if (isset($SESSION->mfa_redir_referer)&& $SESSION->mfa_redir_referer != $authlocal) {if ($SESSION->mfa_redir_referer == get_local_referer(true)) {// Possible redirect loop.if (!isset($SESSION->mfa_redir_count)) {$SESSION->mfa_redir_count = 1;} else {$SESSION->mfa_redir_count++;}if ($SESSION->mfa_redir_count > self::REDIR_LOOP_THRESHOLD) {return self::REDIRECT_EXCEPTION;}} else {// If not a match, reset counter.$SESSION->mfa_redir_count = 0;}}// Set referer after checks.$SESSION->mfa_redir_referer = get_local_referer(true);// Don't redirect if already on auth.php.if ($url->compare($authurl, URL_MATCH_BASE)) {return self::NO_REDIRECT;}return self::REDIRECT;}/*** Clears the redirect counter for infinite redirect loops. Called from auth.php when a valid load is resolved.** @return void*/public static function clear_redirect_counter(): void {global $SESSION;unset($SESSION->mfa_redir_referer);unset($SESSION->mfa_redir_count);}/*** Gets all defined factor urls that should not redirect.** @return array*/public static function get_no_redirect_urls(): array {$factors = factor::get_factors();$urls = [new \moodle_url('/login/logout.php'),new \moodle_url('/admin/tool/mfa/guide.php'),];foreach ($factors as $factor) {$urls = array_merge($urls, $factor->get_no_redirect_urls());}// Allow forced redirection exclusions.if ($exclusions = get_config('tool_mfa', 'redir_exclusions')) {foreach (explode("\n", $exclusions) as $exclusion) {$urls[] = new \moodle_url($exclusion);}}return $urls;}/*** Sleeps for an increasing period of time.** @return void*/public static function sleep_timer(): void {global $USER;$duration = get_user_preferences('mfa_sleep_duration', null, $USER);if (!empty($duration)) {// Double current time.$duration *= 2;$duration = min(2, $duration);} else {// No duration set.$duration = 0.05;}set_user_preference('mfa_sleep_duration', $duration, $USER);sleep((int)$duration);}/*** If MFA Plugin is ready check tool_mfa_authenticated USER property and* start MFA authentication if it's not set or false.** @param mixed $courseorid* @param mixed $autologinguest* @param mixed $cm* @param mixed $setwantsurltome* @param mixed $preventredirect* @return void*/public static function require_auth($courseorid = null, $autologinguest = null, $cm = null,$setwantsurltome = null, $preventredirect = null): void {global $PAGE, $SESSION, $FULLME;// Guest user should never interact with MFA,// And $SESSION->tool_mfa_authenticated should never be set in a guest session.if (isguestuser()) {return;}if (!self::is_ready()) {// Set session var so if MFA becomes ready, you dont get locked from session.$SESSION->tool_mfa_authenticated = true;return;}if (empty($SESSION->tool_mfa_authenticated) || !$SESSION->tool_mfa_authenticated) {if ($PAGE->has_set_url()) {$cleanurl = $PAGE->url;} else {// Use $FULLME instead.$cleanurl = new \moodle_url($FULLME);}$authurl = new \moodle_url('/admin/tool/mfa/auth.php');$redir = self::should_require_mfa($cleanurl, $preventredirect);if ($redir == self::NO_REDIRECT && !$cleanurl->compare($authurl, URL_MATCH_BASE)) {// A non-MFA page that should take precedence.// This check is for any pages, such as site policy, that must occur before MFA.// This check allows AJAX and WS requests to fire on these pages without throwing an exception.$SESSION->mfa_pending = true;}if ($redir == self::REDIRECT) {if (empty($SESSION->wantsurl)) {!empty($setwantsurltome)? $SESSION->wantsurl = qualified_me(): $SESSION->wantsurl = new \moodle_url('/');$SESSION->tool_mfa_setwantsurl = true;}// Remove pending status.// We must now auth with MFA, now that pending statuses are resolved.unset($SESSION->mfa_pending);// Call resolve_status to instantly pass if no redirect is required.self::resolve_mfa_status(true);} else if ($redir == self::REDIRECT_EXCEPTION) {if (!empty($SESSION->mfa_redir_referer)) {throw new \moodle_exception('redirecterrordetected', 'tool_mfa',$SESSION->mfa_redir_referer, $SESSION->mfa_redir_referer);} else {throw new \moodle_exception('redirecterrordetected', 'error');}}}}/*** Sets config variable for given factor.** @param array $data* @param string $factor** @return bool true or exception* @throws dml_exception*/public static function set_factor_config(array $data, string $factor): bool|dml_exception {$factorconf = get_config($factor);foreach ($data as $key => $newvalue) {if (empty($factorconf->$key)) {add_to_config_log($key, null, $newvalue, $factor);set_config($key, $newvalue, $factor);} else if ($factorconf->$key != $newvalue) {add_to_config_log($key, $factorconf->$key, $newvalue, $factor);set_config($key, $newvalue, $factor);}}return true;}/*** Checks if MFA Plugin is enabled and has enabled factor.* If plugin is disabled or there is no enabled factors,* it means there is nothing to do from user side.* Thus, login flow shouldn't be extended with MFA.** @return bool* @throws \dml_exception*/public static function is_ready(): bool {global $CFG, $USER;if (!empty($CFG->upgraderunning)) {return false;}$pluginenabled = get_config('tool_mfa', 'enabled');if (empty($pluginenabled)) {return false;}// Check if user can interact with MFA.$usercontext = \context_user::instance($USER->id);if (!has_capability('tool/mfa:mfaaccess', $usercontext)) {return false;}$enabledfactors = factor::get_enabled_factors();if (count($enabledfactors) == 0) {return false;}return true;}/*** Performs factor actions for given factor.* Change factor order and enable/disable.** @param string $factorname* @param string $action** @return void* @throws dml_exception*/public static function do_factor_action(string $factorname, string $action): void {$order = explode(',', get_config('tool_mfa', 'factor_order'));$key = array_search($factorname, $order);switch ($action) {case 'up':if ($key >= 1) {$fsave = $order[$key];$order[$key] = $order[$key - 1];$order[$key - 1] = $fsave;}break;case 'down':if ($key < (count($order) - 1)) {$fsave = $order[$key];$order[$key] = $order[$key + 1];$order[$key + 1] = $fsave;}break;case 'enable':if (!$key) {$order[] = $factorname;}break;case 'disable':if ($key) {unset($order[$key]);}break;default:break;}self::set_factor_config(['factor_order' => implode(',', $order)], 'tool_mfa');}/*** Checks if a factor that can make a user pass can be setup.* It checks if a user will always pass regardless,* then checks if there are factors that can be setup to let a user pass.** @return bool*/public static function possible_factor_setup(): bool {global $USER;// Get all active factors.$factors = factor::get_enabled_factors();// Check if there are enough factors that a user can ONLY pass, if so, don't display the menu.$weight = 0;foreach ($factors as $factor) {$states = $factor->possible_states($USER);if (count($states) == 1 && reset($states) == factor::STATE_PASS) {$weight += $factor->get_weight();if ($weight >= 100) {return false;}}}// Now if there is a factor that can be setup, that may return a pass state for the user, display menu.foreach ($factors as $factor) {if ($factor->has_setup()) {if (in_array(factor::STATE_PASS, $factor->possible_states($USER))) {return true;}}}return false;}/*** Gets current user weight, up until first unknown factor.** @return int $totalweight Total weight of all factors.*/public static function get_cumulative_weight(): int {$factors = factor::get_active_user_factor_types();// Factor order is important here, so sort the factors by state.$sortedfactors = factor::sort_factors_by_state($factors, factor::STATE_PASS);$totalweight = 0;foreach ($sortedfactors as $factor) {if ($factor->get_state() == factor::STATE_PASS) {$totalweight += $factor->get_weight();// If over 100, break. Don't care about >100.if ($totalweight >= 100) {break;}} else if ($factor->get_state() == factor::STATE_UNKNOWN) {break;}}return $totalweight;}/*** Checks whether the factor was actually used in the login process.** @param string $factorname the name of the factor.* @return bool true if factor is pending.*/public static function check_factor_pending(string $factorname): bool {$factors = factor::get_active_user_factor_types();// Setup vars.$pending = [];$totalweight = 0;$weighttoggle = false;foreach ($factors as $factor) {// If toggle is reached, put in pending and continue.if ($weighttoggle) {$pending[] = $factor->name;continue;}if ($factor->get_state() == factor::STATE_PASS) {$totalweight += $factor->get_weight();if ($totalweight >= 100) {$weighttoggle = true;}}}// Check whether factor falls into pending category.return in_array($factorname, $pending);}}