2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 * Class for loading/storing oauth2 endpoints from the DB.
21 * @copyright 2017 Damyon Wiese
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 namespace core\oauth2;
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->libdir . '/filelib.php');
38 * Static list of api methods for system oauth2 configuration.
40 * @copyright 2017 Damyon Wiese
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
46 * Create a google ready OAuth 2 service.
47 * @return core\oauth2\issuer
49 private static function create_google() {
52 'image' => 'https://accounts.google.com/favicon.ico',
53 'baseurl' => 'http://accounts.google.com/',
54 'loginparamsoffline' => 'access_type=offline&prompt=consent',
55 'showonloginpage' => true
58 $issuer = new issuer(0, $record);
62 'issuerid' => $issuer->get('id'),
63 'name' => 'discovery_endpoint',
64 'url' => 'https://accounts.google.com/.well-known/openid-configuration'
66 $endpoint = new endpoint(0, $record);
72 * Create a facebook ready OAuth 2 service.
73 * @return core\oauth2\issuer
75 private static function create_facebook() {
76 // Facebook is a custom setup.
79 'image' => 'https://facebookbrand.com/wp-content/themes/fb-branding/prj-fb-branding/assets/images/fb-art.png',
81 'loginscopes' => 'public_profile email',
82 'loginscopesoffline' => 'public_profile email',
83 'showonloginpage' => true
86 $issuer = new issuer(0, $record);
90 'authorization_endpoint' => 'https://www.facebook.com/v2.8/dialog/oauth',
91 'token_endpoint' => 'https://graph.facebook.com/v2.8/oauth/access_token',
92 'userinfo_endpoint' => 'https://graph.facebook.com/v2.8/me?fields=id,first_name,last_name,link,picture,name,email'
95 foreach ($endpoints as $name => $url) {
97 'issuerid' => $issuer->get('id'),
101 $endpoint = new endpoint(0, $record);
105 // Create the field mappings.
107 'name' => 'alternatename',
108 'last_name' => 'lastname',
110 'first_name' => 'firstname',
111 'picture-data-url' => 'picture',
114 foreach ($mapping as $external => $internal) {
116 'issuerid' => $issuer->get('id'),
117 'externalfield' => $external,
118 'internalfield' => $internal
120 $userfieldmapping = new user_field_mapping(0, $record);
121 $userfieldmapping->create();
127 * Create a microsoft ready OAuth 2 service.
128 * @return core\oauth2\issuer
130 private static function create_microsoft() {
131 // Microsoft is a custom setup.
133 'name' => 'Microsoft',
134 'image' => 'https://www.microsoft.com/favicon.ico',
136 'loginscopes' => 'openid profile email user.read',
137 'loginscopesoffline' => 'openid profile email user.read offline_access',
138 'showonloginpage' => true
141 $issuer = new issuer(0, $record);
145 'authorization_endpoint' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
146 'token_endpoint' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
147 'userinfo_endpoint' => 'https://graph.microsoft.com/v1.0/me/',
148 'userpicture_endpoint' => 'https://graph.microsoft.com/v1.0/me/photo/$value',
151 foreach ($endpoints as $name => $url) {
153 'issuerid' => $issuer->get('id'),
157 $endpoint = new endpoint(0, $record);
161 // Create the field mappings.
163 'givenName' => 'firstname',
164 'surname' => 'lastname',
165 'userPrincipalName' => 'email',
166 'displayName' => 'alternatename',
167 'officeLocation' => 'address',
168 'mobilePhone' => 'phone1',
169 'preferredLanguage' => 'lang'
171 foreach ($mapping as $external => $internal) {
173 'issuerid' => $issuer->get('id'),
174 'externalfield' => $external,
175 'internalfield' => $internal
177 $userfieldmapping = new user_field_mapping(0, $record);
178 $userfieldmapping->create();
184 * Create one of the standard issuers.
185 * @param string $type One of google, facebook, microsoft
186 * @return \core\oauth2\issuer
188 public static function create_standard_issuer($type) {
189 require_capability('moodle/site:config', context_system::instance());
190 if ($type == 'google') {
191 return self::create_google();
192 } else if ($type == 'microsoft') {
193 return self::create_microsoft();
194 } else if ($type == 'facebook') {
195 return self::create_facebook();
197 throw new moodle_exception('OAuth 2 service type not recognised: ' . $type);
202 * List all the issuers, ordered by the sortorder field
203 * @return core\oauth2\issuer[]
205 public static function get_all_issuers() {
206 return issuer::get_records([], 'sortorder');
210 * Get a single issuer by id.
213 * @return core\oauth2\issuer
215 public static function get_issuer($id) {
216 return new issuer($id);
220 * Get a single endpoint by id.
223 * @return core\oauth2\endpoint
225 public static function get_endpoint($id) {
226 return new endpoint($id);
230 * Get a single user field mapping by id.
233 * @return core\oauth2\user_field_mapping
235 public static function get_user_field_mapping($id) {
236 return new user_field_mapping($id);
240 * Get the system account for an installed OAuth service.
241 * Never ever ever expose this to a webservice because it contains the refresh token which grants API access.
243 * @param \core\oauth2\issuer $issuer
244 * @return \core\oauth2\client
246 public static function get_system_account(issuer $issuer) {
247 return system_account::get_record(['issuerid' => $issuer->get('id')]);
251 * Get the full list of system scopes required by an oauth issuer.
252 * This includes the list required for login as well as any scopes injected by the oauth2_system_scopes callback in plugins.
254 * @param \core\oauth2\issuer $issuer
257 public static function get_system_scopes_for_issuer($issuer) {
258 $scopes = $issuer->get('loginscopesoffline');
260 $pluginsfunction = get_plugins_with_function('oauth2_system_scopes', 'lib.php');
261 foreach ($pluginsfunction as $plugintype => $plugins) {
262 foreach ($plugins as $pluginfunction) {
263 // Get additional scopes from the plugin.
264 $pluginscopes = $pluginfunction($issuer);
265 if (empty($pluginscopes)) {
269 // Merge the additional scopes with the existing ones.
270 $additionalscopes = explode(' ', $pluginscopes);
272 foreach ($additionalscopes as $scope) {
273 if (!empty($scope)) {
274 if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
275 $scopes .= ' ' . $scope;
286 * Get an authenticated oauth2 client using the system account.
287 * This call uses the refresh token to get an access token.
289 * @param core\oauth2\issuer $issuer
290 * @return core\oauth2\client
292 public static function get_system_oauth_client(issuer $issuer) {
293 $systemaccount = self::get_system_account($issuer);
294 if (empty($systemaccount)) {
297 // Get all the scopes!
298 $scopes = self::get_system_scopes_for_issuer($issuer);
300 $client = new \core\oauth2\client($issuer, null, $scopes, true);
302 if (!$client->is_logged_in()) {
303 if (!$client->upgrade_refresh_token($systemaccount)) {
311 * Get an authenticated oauth2 client using the current user account.
312 * This call does the redirect dance back to the current page after authentication.
314 * @param core\oauth2\issuer $issuer The desired OAuth issuer
315 * @param moodle_url $currenturl The url to the current page.
316 * @param string $additionalscopes The additional scopes required for authorization.
317 * @return core\oauth2\client
319 public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '') {
320 $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes);
326 * Get the list of defined endpoints for this OAuth issuer
328 * @param core\oauth2\issuer $issuer The desired OAuth issuer
329 * @return core\oauth2\endpoint[]
331 public static function get_endpoints(issuer $issuer) {
332 return endpoint::get_records(['issuerid' => $issuer->get('id')]);
336 * Get the list of defined mapping from OAuth user fields to moodle user fields.
338 * @param core\oauth2\issuer $issuer The desired OAuth issuer
339 * @return core\oauth2\user_field_mapping[]
341 public static function get_user_field_mappings(issuer $issuer) {
342 return user_field_mapping::get_records(['issuerid' => $issuer->get('id')]);
346 * Guess an image from the discovery URL.
348 * @param core\oauth2\issuer $issuer The desired OAuth issuer
350 protected static function guess_image($issuer) {
351 if (empty($issuer->get('image'))) {
352 $baseurl = parse_url($issuer->get('baseurl'));
353 $imageurl = $baseurl['scheme'] . '://' . $baseurl['host'] . '/favicon.ico';
354 $issuer->set('image', $imageurl);
360 * If the discovery endpoint exists for this issuer, try and determine the list of valid endpoints.
362 * @param issuer $issuer
363 * @return int The number of discovered services.
365 protected static function discover_endpoints($issuer) {
368 if (empty($issuer->get('baseurl'))) {
372 $url = $issuer->get_endpoint_url('discovery');
374 $url = $issuer->get('baseurl') . '/.well-known/openid-configuration';
377 if (!$json = $curl->get($url)) {
378 $msg = 'Could not discover end points for identity issuer' . $issuer->get('name');
379 throw new moodle_exception($msg);
382 if ($msg = $curl->error) {
383 throw new moodle_exception('Could not discover service endpoints: ' . $msg);
386 $info = json_decode($json);
388 $msg = 'Could not discover end points for identity issuer' . $issuer->get('name');
389 throw new moodle_exception($msg);
392 foreach (endpoint::get_records(['issuerid' => $issuer->get('id')]) as $endpoint) {
393 if ($endpoint->get('name') != 'discovery_endpoint') {
398 foreach ($info as $key => $value) {
399 if (substr_compare($key, '_endpoint', - strlen('_endpoint')) === 0) {
400 $record = new stdClass();
401 $record->issuerid = $issuer->get('id');
402 $record->name = $key;
403 $record->url = $value;
405 $endpoint = new endpoint(0, $record);
409 if ($key == 'scopes_supported') {
410 $issuer->set('scopessupported', implode(' ', $value));
415 // We got to here - must be a decent OpenID connect service. Add the default user field mapping list.
416 foreach (user_field_mapping::get_records(['issuerid' => $issuer->get('id')]) as $userfieldmapping) {
417 $userfieldmapping->delete();
420 // Create the field mappings.
422 'given_name' => 'firstname',
423 'middle_name' => 'middlename',
424 'family_name' => 'lastname',
427 'nickname' => 'alternatename',
428 'picture' => 'picture',
429 'address' => 'address',
433 foreach ($mapping as $external => $internal) {
435 'issuerid' => $issuer->get('id'),
436 'externalfield' => $external,
437 'internalfield' => $internal
439 $userfieldmapping = new user_field_mapping(0, $record);
440 $userfieldmapping->create();
443 return endpoint::count_records(['issuerid' => $issuer->get('id')]);
447 * Take the data from the mform and update the issuer.
449 * @param stdClass $data
450 * @return core\oauth2\issuer
452 public static function update_issuer($data) {
453 require_capability('moodle/site:config', context_system::instance());
454 $issuer = new issuer(0, $data);
456 // Will throw exceptions on validation failures.
459 // Perform service discovery.
460 self::discover_endpoints($issuer);
461 self::guess_image($issuer);
466 * Take the data from the mform and create the issuer.
468 * @param stdClass $data
469 * @return core\oauth2\issuer
471 public static function create_issuer($data) {
472 require_capability('moodle/site:config', context_system::instance());
473 $issuer = new issuer(0, $data);
475 // Will throw exceptions on validation failures.
478 // Perform service discovery.
479 self::discover_endpoints($issuer);
480 self::guess_image($issuer);
485 * Take the data from the mform and update the endpoint.
487 * @param stdClass $data
488 * @return core\oauth2\endpoint
490 public static function update_endpoint($data) {
491 require_capability('moodle/site:config', context_system::instance());
492 $endpoint = new endpoint(0, $data);
494 // Will throw exceptions on validation failures.
501 * Take the data from the mform and create the endpoint.
503 * @param stdClass $data
504 * @return core\oauth2\endpoint
506 public static function create_endpoint($data) {
507 require_capability('moodle/site:config', context_system::instance());
508 $endpoint = new endpoint(0, $data);
510 // Will throw exceptions on validation failures.
516 * Take the data from the mform and update the user field mapping.
518 * @param stdClass $data
519 * @return core\oauth2\user_field_mapping
521 public static function update_user_field_mapping($data) {
522 require_capability('moodle/site:config', context_system::instance());
523 $userfieldmapping = new user_field_mapping(0, $data);
525 // Will throw exceptions on validation failures.
526 $userfieldmapping->update();
528 return $userfieldmapping;
532 * Take the data from the mform and create the user field mapping.
534 * @param stdClass $data
535 * @return core\oauth2\user_field_mapping
537 public static function create_user_field_mapping($data) {
538 require_capability('moodle/site:config', context_system::instance());
539 $userfieldmapping = new user_field_mapping(0, $data);
541 // Will throw exceptions on validation failures.
542 $userfieldmapping->create();
543 return $userfieldmapping;
547 * Reorder this identity issuer.
549 * Requires moodle/site:config capability at the system context.
551 * @param int $id The id of the identity issuer to move.
554 public static function move_up_issuer($id) {
555 require_capability('moodle/site:config', context_system::instance());
556 $current = new issuer($id);
558 $sortorder = $current->get('sortorder');
559 if ($sortorder == 0) {
563 $sortorder = $sortorder - 1;
564 $current->set('sortorder', $sortorder);
566 $filters = array('sortorder' => $sortorder);
567 $children = issuer::get_records($filters, 'id');
568 foreach ($children as $needtoswap) {
569 $needtoswap->set('sortorder', $sortorder + 1);
570 $needtoswap->update();
574 $result = $current->update();
580 * Reorder this identity issuer.
582 * Requires moodle/site:config capability at the system context.
584 * @param int $id The id of the identity issuer to move.
587 public static function move_down_issuer($id) {
588 require_capability('moodle/site:config', context_system::instance());
589 $current = new issuer($id);
591 $max = issuer::count_records();
596 $sortorder = $current->get('sortorder');
597 if ($sortorder >= $max) {
600 $sortorder = $sortorder + 1;
601 $current->set('sortorder', $sortorder);
603 $filters = array('sortorder' => $sortorder);
604 $children = issuer::get_records($filters);
605 foreach ($children as $needtoswap) {
606 $needtoswap->set('sortorder', $sortorder - 1);
607 $needtoswap->update();
611 $result = $current->update();
617 * Disable an identity issuer.
619 * Requires moodle/site:config capability at the system context.
621 * @param int $id The id of the identity issuer to disable.
624 public static function disable_issuer($id) {
625 require_capability('moodle/site:config', context_system::instance());
626 $issuer = new issuer($id);
628 $issuer->set('enabled', 0);
629 return $issuer->update();
634 * Enable an identity issuer.
636 * Requires moodle/site:config capability at the system context.
638 * @param int $id The id of the identity issuer to enable.
641 public static function enable_issuer($id) {
642 require_capability('moodle/site:config', context_system::instance());
643 $issuer = new issuer($id);
645 $issuer->set('enabled', 1);
646 return $issuer->update();
650 * Delete an identity issuer.
652 * Requires moodle/site:config capability at the system context.
654 * @param int $id The id of the identity issuer to delete.
657 public static function delete_issuer($id) {
658 require_capability('moodle/site:config', context_system::instance());
659 $issuer = new issuer($id);
661 $systemaccount = self::get_system_account($issuer);
662 if ($systemaccount) {
663 $systemaccount->delete();
665 $endpoints = self::get_endpoints($issuer);
667 foreach ($endpoints as $endpoint) {
672 // Will throw exceptions on validation failures.
673 return $issuer->delete();
677 * Delete an endpoint.
679 * Requires moodle/site:config capability at the system context.
681 * @param int $id The id of the endpoint to delete.
684 public static function delete_endpoint($id) {
685 require_capability('moodle/site:config', context_system::instance());
686 $endpoint = new endpoint($id);
688 // Will throw exceptions on validation failures.
689 return $endpoint->delete();
693 * Delete a user_field_mapping.
695 * Requires moodle/site:config capability at the system context.
697 * @param int $id The id of the user_field_mapping to delete.
700 public static function delete_user_field_mapping($id) {
701 require_capability('moodle/site:config', context_system::instance());
702 $userfieldmapping = new user_field_mapping($id);
704 // Will throw exceptions on validation failures.
705 return $userfieldmapping->delete();
709 * Perform the OAuth dance and get a refresh token.
711 * Requires moodle/site:config capability at the system context.
713 * @param core\oauth2\issuer $issuer
714 * @param moodle_url $returnurl The url to the current page (we will be redirected back here after authentication).
717 public static function connect_system_account($issuer, $returnurl) {
718 require_capability('moodle/site:config', context_system::instance());
720 // We need to authenticate with an oauth 2 client AS a system user and get a refresh token for offline access.
721 $scopes = self::get_system_scopes_for_issuer($issuer);
723 // Allow callbacks to inject non-standard scopes to the auth request.
725 $client = new client($issuer, $returnurl, $scopes, true);
727 if (!optional_param('response', false, PARAM_BOOL)) {
731 if (optional_param('error', '', PARAM_RAW)) {
735 if (!$client->is_logged_in()) {
736 redirect($client->get_login_url());
739 $refreshtoken = $client->get_refresh_token();
740 if (!$refreshtoken) {
744 $systemaccount = self::get_system_account($issuer);
745 if ($systemaccount) {
746 $systemaccount->delete();
749 $userinfo = $client->get_userinfo();
751 $record = new stdClass();
752 $record->issuerid = $issuer->get('id');
753 $record->refreshtoken = $refreshtoken;
754 $record->grantedscopes = $scopes;
755 $record->email = $userinfo['email'];
756 $record->username = $userinfo['username'];
758 $systemaccount = new system_account(0, $record);
760 $systemaccount->create();