diff --git a/lib/external/classes/util.php b/lib/external/classes/util.php index 798513e9600..bc6dbea9b8c 100644 --- a/lib/external/classes/util.php +++ b/lib/external/classes/util.php @@ -16,9 +16,14 @@ namespace core_external; +use context; use context_course; use context_helper; +use context_system; +use core_user; +use moodle_exception; use moodle_url; +use stdClass; /** * Utility functions for the external API. @@ -144,4 +149,271 @@ class util { } return $files; } + + + /** + * Create and return a session linked token. Token to be used for html embedded client apps that want to communicate + * with the Moodle server through web services. The token is linked to the current session for the current page request. + * It is expected this will be called in the script generating the html page that is embedding the client app and that the + * returned token will be somehow passed into the client app being embedded in the page. + * + * @param int $tokentype EXTERNAL_TOKEN_EMBEDDED|EXTERNAL_TOKEN_PERMANENT + * @param stdClass|int $serviceorid service linked to the token + * @param int $userid user linked to the token + * @param context $contextorid + * @param int $validuntil date when the token expired + * @param string $iprestriction allowed ip - if 0 or empty then all ips are allowed + * @return string generated token + */ + public static function generate_token( + int $tokentype, + stdClass $service, + int $userid, + context $contextorid, + int $validuntil = 0, + string $iprestriction = '' + ): string { + global $DB, $USER; + + // Make sure the token doesn't exist (even if it should be almost impossible with the random generation). + $numtries = 0; + do { + $numtries ++; + $generatedtoken = md5(uniqid((string) rand(), true)); + if ($numtries > 5) { + throw new moodle_exception('tokengenerationfailed'); + } + } while ($DB->record_exists('external_tokens', ['token' => $generatedtoken])); + $newtoken = (object) [ + 'token' => $generatedtoken, + ]; + + if (empty($service->requiredcapability) || has_capability($service->requiredcapability, $context, $userid)) { + $newtoken->externalserviceid = $service->id; + } else { + throw new moodle_exception('nocapabilitytousethisservice'); + } + + $newtoken->tokentype = $tokentype; + $newtoken->userid = $userid; + if ($tokentype == EXTERNAL_TOKEN_EMBEDDED) { + $newtoken->sid = session_id(); + } + + $newtoken->contextid = $context->id; + $newtoken->creatorid = $USER->id; + $newtoken->timecreated = time(); + $newtoken->validuntil = $validuntil; + if (!empty($iprestriction)) { + $newtoken->iprestriction = $iprestriction; + } + + // Generate the private token, it must be transmitted only via https. + $newtoken->privatetoken = random_string(64); + $DB->insert_record('external_tokens', $newtoken); + return $newtoken->token; + } + + /** + * Get a service by its id. + * + * @param int $serviceid + * @return stdClass + */ + public static function get_service_by_id(int $serviceid): stdClass { + global $DB; + + return $DB->get_record('external_services', ['id' => $serviceid], '*', MUST_EXIST); + } + + /** + * Get a service by its name. + * + * @param string $name The service name. + * @return stdClass + */ + public static function get_service_by_name(string $name): stdClass { + global $DB; + + return $DB->get_record('external_services', ['name' => $name], '*', MUST_EXIST); + } + + /** + * Set the last time a token was sent and trigger the \core\event\webservice_token_sent event. + * + * This function is used when a token is generated by the user via login/token.php or admin/tool/mobile/launch.php. + * In order to protect the privatetoken, we remove it from the event params. + * + * @param stdClass $token token object + */ + public static function log_token_request(stdClass $token): void { + global $DB, $USER; + + $token->privatetoken = null; + + // Log token access. + $DB->set_field('external_tokens', 'lastaccess', time(), ['id' => $token->id]); + + $event = \core\event\webservice_token_sent::create([ + 'objectid' => $token->id, + ]); + $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) { + return; + } + + $shouldskip = WS_SERVER || CLI_SCRIPT || !NO_MOODLE_COOKIES; + if ($shouldskip && !PHPUNIT_TEST) { + return; + } + + $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); + $logintime = time(); + $task->set_custom_data([ + 'useragent' => \core_useragent::get_user_agent_string(), + 'ismoodleapp' => \core_useragent::is_moodle_app(), + 'loginip' => $loginip, + 'logintime' => $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(time() + (2 * MINSECS)); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + } + + /** + * Generate or return an existing token for the current authenticated user. + * This function is used for creating a valid token for users authenticathing via places, including: + * - login/token.php + * - admin/tool/mobile/launch.php. + * + * @param stdClass $service external service object + * @return stdClass token object + * @throws moodle_exception + */ + public static function generate_token_for_current_user(stdClass $service) { + global $DB, $USER, $CFG; + + core_user::require_active_user($USER, true, true); + + // Check if there is any required system capability. + if ($service->requiredcapability && !has_capability($service->requiredcapability, context_system::instance())) { + throw new moodle_exception('missingrequiredcapability', 'webservice', '', $service->requiredcapability); + } + + // Specific checks related to user restricted service. + if ($service->restrictedusers) { + $authoriseduser = $DB->get_record('external_services_users', [ + 'externalserviceid' => $service->id, + 'userid' => $USER->id, + ]); + + if (empty($authoriseduser)) { + throw new moodle_exception('usernotallowed', 'webservice', '', $service->shortname); + } + + if (!empty($authoriseduser->validuntil) && $authoriseduser->validuntil < time()) { + throw new moodle_exception('invalidtimedtoken', 'webservice'); + } + + if (!empty($authoriseduser->iprestriction) && !address_in_subnet(getremoteaddr(), $authoriseduser->iprestriction)) { + throw new moodle_exception('invalidiptoken', 'webservice'); + } + } + + // Check if a token has already been created for this user and this service. + $conditions = [ + 'userid' => $USER->id, + 'externalserviceid' => $service->id, + 'tokentype' => EXTERNAL_TOKEN_PERMANENT, + ]; + $tokens = $DB->get_records('external_tokens', $conditions, 'timecreated ASC'); + + // A bit of sanity checks. + foreach ($tokens as $key => $token) { + + // Checks related to a specific token. (script execution continue). + $unsettoken = false; + // If sid is set then there must be a valid associated session no matter the token type. + if (!empty($token->sid)) { + if (!\core\session\manager::session_exists($token->sid)) { + // This token will never be valid anymore, delete it. + $DB->delete_records('external_tokens', ['sid' => $token->sid]); + $unsettoken = true; + } + } + + // Remove token is not valid anymore. + if (!empty($token->validuntil) && $token->validuntil < time()) { + $DB->delete_records('external_tokens', ['token' => $token->token, 'tokentype' => EXTERNAL_TOKEN_PERMANENT]); + $unsettoken = true; + } + + // Remove token if its IP is restricted. + if (isset($token->iprestriction) && !address_in_subnet(getremoteaddr(), $token->iprestriction)) { + $unsettoken = true; + } + + if ($unsettoken) { + unset($tokens[$key]); + } + } + + // If some valid tokens exist then use the most recent. + if (count($tokens) > 0) { + $token = array_pop($tokens); + } else { + $context = context_system::instance(); + $isofficialservice = $service->shortname == MOODLE_OFFICIAL_MOBILE_SERVICE; + + if ( + ($isofficialservice && has_capability('moodle/webservice:createmobiletoken', $context)) || + (!is_siteadmin($USER) && has_capability('moodle/webservice:createtoken', $context)) + ) { + + // Create a new token. + $token = new stdClass; + $token->token = md5(uniqid((string) rand(), true)); + $token->userid = $USER->id; + $token->tokentype = EXTERNAL_TOKEN_PERMANENT; + $token->contextid = context_system::instance()->id; + $token->creatorid = $USER->id; + $token->timecreated = time(); + $token->externalserviceid = $service->id; + // By default tokens are valid for 12 weeks. + $token->validuntil = $token->timecreated + $CFG->tokenduration; + $token->iprestriction = null; + $token->sid = null; + $token->lastaccess = null; + // Generate the private token, it must be transmitted only via https. + $token->privatetoken = random_string(64); + $token->id = $DB->insert_record('external_tokens', $token); + + $eventtoken = clone $token; + $eventtoken->privatetoken = null; + $params = [ + 'objectid' => $eventtoken->id, + 'relateduserid' => $USER->id, + 'other' => [ + 'auto' => true, + ], + ]; + $event = \core\event\webservice_token_created::create($params); + $event->add_record_snapshot('external_tokens', $eventtoken); + $event->trigger(); + } else { + throw new moodle_exception('cannotcreatetoken', 'webservice', '', $service->shortname); + } + } + return $token; + } } diff --git a/lib/external/tests/util_test.php b/lib/external/tests/util_test.php index 1eefdad8786..c5c54797507 100644 --- a/lib/external/tests/util_test.php +++ b/lib/external/tests/util_test.php @@ -119,7 +119,7 @@ class util_test extends \advanced_testcase { $courseids = [$c1->id, $c2->id, $c3->id]; $this->setAdminUser(); - [$courses, $warnings] = \external_util::validate_courses($courseids); + [$courses, $warnings] = util::validate_courses($courseids); $this->assertEmpty($warnings); $this->assertCount(3, $courses); $this->assertArrayHasKey($c1->id, $courses); @@ -130,7 +130,7 @@ class util_test extends \advanced_testcase { $this->assertEquals($c3->id, $courses[$c3->id]->id); $this->setUser($u1); - [$courses, $warnings] = \external_util::validate_courses($courseids); + [$courses, $warnings] = util::validate_courses($courseids); $this->assertCount(2, $warnings); $this->assertEquals($c2->id, $warnings[0]['itemid']); $this->assertEquals($c3->id, $warnings[1]['itemid']); @@ -146,7 +146,7 @@ class util_test extends \advanced_testcase { * * @covers \core_external\util::get_area_files */ - public function test_external_util_get_area_files(): void { + public function test_get_area_files(): void { global $CFG, $DB; $this->DB = $DB; @@ -195,4 +195,32 @@ class util_test extends \advanced_testcase { $files = util::get_area_files($context, $component, $filearea, $itemid); $this->assertEquals($expectedfiles, $files); } + + /** + * Test default time for user created tokens. + * + * @covers \core_external\util::generate_token_for_current_user + */ + public function test_user_created_tokens_duration(): void { + global $CFG, $DB; + $this->resetAfterTest(true); + + $CFG->enablewebservices = 1; + $CFG->enablemobilewebservice = 1; + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $service = $DB->get_record('external_services', ['shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE, 'enabled' => 1]); + + $this->setUser($user1); + $timenow = time(); + $token = util::generate_token_for_current_user($service); + $this->assertGreaterThanOrEqual($timenow + $CFG->tokenduration, $token->validuntil); + + // Change token default time. + $this->setUser($user2); + set_config('tokenduration', DAYSECS); + $token = util::external_generate_token_for_current_user($service); + $timenow = time(); + $this->assertLessThanOrEqual($timenow + DAYSECS, $token->validuntil); + } } diff --git a/lib/externallib.php b/lib/externallib.php index 8e1202bdabd..265aa8c43c6 100644 --- a/lib/externallib.php +++ b/lib/externallib.php @@ -48,54 +48,31 @@ class_alias(\core_external\external_settings::class, 'external_settings'); * @param int $validuntil date when the token expired * @param string $iprestriction allowed ip - if 0 or empty then all ips are allowed * @return string generated token - * @author 2010 Jamie Pratt * @since Moodle 2.0 */ -function external_generate_token($tokentype, $serviceorid, $userid, $contextorid, $validuntil=0, $iprestriction=''){ - global $DB, $USER; - // make sure the token doesn't exist (even if it should be almost impossible with the random generation) - $numtries = 0; - do { - $numtries ++; - $generatedtoken = md5(uniqid(rand(),1)); - if ($numtries > 5){ - throw new moodle_exception('tokengenerationfailed'); - } - } while ($DB->record_exists('external_tokens', array('token'=>$generatedtoken))); - $newtoken = new stdClass(); - $newtoken->token = $generatedtoken; - if (!is_object($serviceorid)){ - $service = $DB->get_record('external_services', array('id' => $serviceorid)); +function external_generate_token($tokentype, $serviceorid, $userid, $contextorid, $validuntil = 0, $iprestriction = '') { + if (is_numeric($serviceorid)) { + $service = util::get_service_by_id($serviceorid); + } else if (is_string($serviceorid)) { + $service = util::get_service_by_name($serviceorid); } else { $service = $serviceorid; } - if (!is_object($contextorid)){ + + if (!is_object($contextorid)) { $context = context::instance_by_id($contextorid, MUST_EXIST); } else { $context = $contextorid; } - if (empty($service->requiredcapability) || has_capability($service->requiredcapability, $context, $userid)) { - $newtoken->externalserviceid = $service->id; - } else { - throw new moodle_exception('nocapabilitytousethisservice'); - } - $newtoken->tokentype = $tokentype; - $newtoken->userid = $userid; - if ($tokentype == EXTERNAL_TOKEN_EMBEDDED){ - $newtoken->sid = session_id(); - } - $newtoken->contextid = $context->id; - $newtoken->creatorid = $USER->id; - $newtoken->timecreated = time(); - $newtoken->validuntil = $validuntil; - if (!empty($iprestriction)) { - $newtoken->iprestriction = $iprestriction; - } - // Generate the private token, it must be transmitted only via https. - $newtoken->privatetoken = random_string(64); - $DB->insert_record('external_tokens', $newtoken); - return $newtoken->token; + return util::generate_token( + $tokentype, + $service, + $userid, + $context, + $validuntil, + $iprestriction + ); } /** @@ -109,10 +86,15 @@ function external_generate_token($tokentype, $serviceorid, $userid, $contextorid * @return int returns token id. * @since Moodle 2.0 */ -function external_create_service_token($servicename, $context){ - global $USER, $DB; - $service = $DB->get_record('external_services', array('name'=>$servicename), '*', MUST_EXIST); - return external_generate_token(EXTERNAL_TOKEN_EMBEDDED, $service, $USER->id, $context, 0); +function external_create_service_token($servicename, $contextid) { + global $USER; + + return util::generate_token( + EXTERNAL_TOKEN_EMBEDDED, + util::get_service_by_name($servicename), + $USER->id, + \context::instance_by_id($contextid) + ); } /** @@ -281,119 +263,9 @@ function external_format_text($text, $textformat, $contextorid, $component = nul * @param stdClass $service external service object * @return stdClass token object * @since Moodle 3.2 - * @throws moodle_exception */ function external_generate_token_for_current_user($service) { - global $DB, $USER, $CFG; - - core_user::require_active_user($USER, true, true); - - // Check if there is any required system capability. - if ($service->requiredcapability and !has_capability($service->requiredcapability, context_system::instance())) { - throw new moodle_exception('missingrequiredcapability', 'webservice', '', $service->requiredcapability); - } - - // Specific checks related to user restricted service. - if ($service->restrictedusers) { - $authoriseduser = $DB->get_record('external_services_users', - array('externalserviceid' => $service->id, 'userid' => $USER->id)); - - if (empty($authoriseduser)) { - throw new moodle_exception('usernotallowed', 'webservice', '', $service->shortname); - } - - if (!empty($authoriseduser->validuntil) and $authoriseduser->validuntil < time()) { - throw new moodle_exception('invalidtimedtoken', 'webservice'); - } - - if (!empty($authoriseduser->iprestriction) and !address_in_subnet(getremoteaddr(), $authoriseduser->iprestriction)) { - throw new moodle_exception('invalidiptoken', 'webservice'); - } - } - - // Check if a token has already been created for this user and this service. - $conditions = array( - 'userid' => $USER->id, - 'externalserviceid' => $service->id, - 'tokentype' => EXTERNAL_TOKEN_PERMANENT - ); - $tokens = $DB->get_records('external_tokens', $conditions, 'timecreated ASC'); - - // A bit of sanity checks. - foreach ($tokens as $key => $token) { - - // Checks related to a specific token. (script execution continue). - $unsettoken = false; - // If sid is set then there must be a valid associated session no matter the token type. - if (!empty($token->sid)) { - if (!\core\session\manager::session_exists($token->sid)) { - // This token will never be valid anymore, delete it. - $DB->delete_records('external_tokens', array('sid' => $token->sid)); - $unsettoken = true; - } - } - - // Remove token is not valid anymore. - if (!empty($token->validuntil) and $token->validuntil < time()) { - $DB->delete_records('external_tokens', array('token' => $token->token, 'tokentype' => EXTERNAL_TOKEN_PERMANENT)); - $unsettoken = true; - } - - // Remove token if its IP is restricted. - if (isset($token->iprestriction) and !address_in_subnet(getremoteaddr(), $token->iprestriction)) { - $unsettoken = true; - } - - if ($unsettoken) { - unset($tokens[$key]); - } - } - - // If some valid tokens exist then use the most recent. - if (count($tokens) > 0) { - $token = array_pop($tokens); - } else { - $context = context_system::instance(); - $isofficialservice = $service->shortname == MOODLE_OFFICIAL_MOBILE_SERVICE; - - if (($isofficialservice and has_capability('moodle/webservice:createmobiletoken', $context)) or - (!is_siteadmin($USER) && has_capability('moodle/webservice:createtoken', $context))) { - - // Create a new token. - $token = new stdClass; - $token->token = md5(uniqid(rand(), 1)); - $token->userid = $USER->id; - $token->tokentype = EXTERNAL_TOKEN_PERMANENT; - $token->contextid = context_system::instance()->id; - $token->creatorid = $USER->id; - $token->timecreated = time(); - $token->externalserviceid = $service->id; - // By default tokens are valid for 12 weeks. - $token->validuntil = $token->timecreated + $CFG->tokenduration; - $token->iprestriction = null; - $token->sid = null; - $token->lastaccess = null; - // Generate the private token, it must be transmitted only via https. - $token->privatetoken = random_string(64); - $token->id = $DB->insert_record('external_tokens', $token); - - $eventtoken = clone $token; - $eventtoken->privatetoken = null; - $params = array( - 'objectid' => $eventtoken->id, - 'relateduserid' => $USER->id, - 'other' => array( - 'auto' => true - ) - ); - $event = \core\event\webservice_token_created::create($params); - $event->add_record_snapshot('external_tokens', $eventtoken); - $event->trigger(); - } else { - throw new moodle_exception('cannotcreatetoken', 'webservice', '', $service->shortname); - } - } - return $token; + return util::generate_token_for_current_user($service); } /** @@ -405,37 +277,6 @@ function external_generate_token_for_current_user($service) { * @param stdClass $token token object * @since Moodle 3.2 */ -function external_log_token_request($token) { - global $DB, $USER; - - $token->privatetoken = null; - - // Log token access. - $DB->set_field('external_tokens', 'lastaccess', time(), array('id' => $token->id)); - - $params = array( - 'objectid' => $token->id, - ); - $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); - } +function external_log_token_request($token): void { + util::log_token_request($token); } diff --git a/lib/tests/externallib_test.php b/lib/tests/externallib_test.php index df82f185175..570aa217de9 100644 --- a/lib/tests/externallib_test.php +++ b/lib/tests/externallib_test.php @@ -170,32 +170,6 @@ class externallib_test extends \advanced_testcase { $settings->set_raw($currentraw); $settings->set_filter($currentfilter); } - - /** - * Test default time for user created tokens. - */ - public function test_user_created_tokens_duration() { - global $CFG, $DB; - $this->resetAfterTest(true); - - $CFG->enablewebservices = 1; - $CFG->enablemobilewebservice = 1; - $user1 = $this->getDataGenerator()->create_user(); - $user2 = $this->getDataGenerator()->create_user(); - $service = $DB->get_record('external_services', array('shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE, 'enabled' => 1)); - - $this->setUser($user1); - $timenow = time(); - $token = external_generate_token_for_current_user($service); - $this->assertGreaterThanOrEqual($timenow + $CFG->tokenduration, $token->validuntil); - - // Change token default time. - $this->setUser($user2); - set_config('tokenduration', DAYSECS); - $token = external_generate_token_for_current_user($service); - $timenow = time(); - $this->assertLessThanOrEqual($timenow + DAYSECS, $token->validuntil); - } } /*