MDL-59961 core_files: make content hash validation reusable
[moodle.git] / lib / classes / oauth2 / api.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
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.
8 //
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.
13 //
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/>.
17 /**
18  * Class for loading/storing oauth2 endpoints from the DB.
19  *
20  * @package    core
21  * @copyright  2017 Damyon Wiese
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 namespace core\oauth2;
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->libdir . '/filelib.php');
30 use context_system;
31 use curl;
32 use stdClass;
33 use moodle_exception;
34 use moodle_url;
37 /**
38  * Static list of api methods for system oauth2 configuration.
39  *
40  * @copyright  2017 Damyon Wiese
41  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42  */
43 class api {
45     /**
46      * Create a google ready OAuth 2 service.
47      * @return core\oauth2\issuer
48      */
49     private static function create_google() {
50         $record = (object) [
51             'name' => '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
56         ];
58         $issuer = new issuer(0, $record);
59         $issuer->create();
61         $record = (object) [
62             'issuerid' => $issuer->get('id'),
63             'name' => 'discovery_endpoint',
64             'url' => 'https://accounts.google.com/.well-known/openid-configuration'
65         ];
66         $endpoint = new endpoint(0, $record);
67         $endpoint->create();
68         return $issuer;
69     }
71     /**
72      * Create a facebook ready OAuth 2 service.
73      * @return core\oauth2\issuer
74      */
75     private static function create_facebook() {
76         // Facebook is a custom setup.
77         $record = (object) [
78             'name' => 'Facebook',
79             'image' => 'https://facebookbrand.com/wp-content/themes/fb-branding/prj-fb-branding/assets/images/fb-art.png',
80             'baseurl' => '',
81             'loginscopes' => 'public_profile email',
82             'loginscopesoffline' => 'public_profile email',
83             'showonloginpage' => true
84         ];
86         $issuer = new issuer(0, $record);
87         $issuer->create();
89         $endpoints = [
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'
93         ];
95         foreach ($endpoints as $name => $url) {
96             $record = (object) [
97                 'issuerid' => $issuer->get('id'),
98                 'name' => $name,
99                 'url' => $url
100             ];
101             $endpoint = new endpoint(0, $record);
102             $endpoint->create();
103         }
105         // Create the field mappings.
106         $mapping = [
107             'name' => 'alternatename',
108             'last_name' => 'lastname',
109             'email' => 'email',
110             'first_name' => 'firstname',
111             'picture-data-url' => 'picture',
112             'link' => 'url',
113         ];
114         foreach ($mapping as $external => $internal) {
115             $record = (object) [
116                 'issuerid' => $issuer->get('id'),
117                 'externalfield' => $external,
118                 'internalfield' => $internal
119             ];
120             $userfieldmapping = new user_field_mapping(0, $record);
121             $userfieldmapping->create();
122         }
123         return $issuer;
124     }
126     /**
127      * Create a microsoft ready OAuth 2 service.
128      * @return core\oauth2\issuer
129      */
130     private static function create_microsoft() {
131         // Microsoft is a custom setup.
132         $record = (object) [
133             'name' => 'Microsoft',
134             'image' => 'https://www.microsoft.com/favicon.ico',
135             'baseurl' => '',
136             'loginscopes' => 'openid profile email user.read',
137             'loginscopesoffline' => 'openid profile email user.read offline_access',
138             'showonloginpage' => true
139         ];
141         $issuer = new issuer(0, $record);
142         $issuer->create();
144         $endpoints = [
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',
149         ];
151         foreach ($endpoints as $name => $url) {
152             $record = (object) [
153                 'issuerid' => $issuer->get('id'),
154                 'name' => $name,
155                 'url' => $url
156             ];
157             $endpoint = new endpoint(0, $record);
158             $endpoint->create();
159         }
161         // Create the field mappings.
162         $mapping = [
163             'givenName' => 'firstname',
164             'surname' => 'lastname',
165             'userPrincipalName' => 'email',
166             'displayName' => 'alternatename',
167             'officeLocation' => 'address',
168             'mobilePhone' => 'phone1',
169             'preferredLanguage' => 'lang'
170         ];
171         foreach ($mapping as $external => $internal) {
172             $record = (object) [
173                 'issuerid' => $issuer->get('id'),
174                 'externalfield' => $external,
175                 'internalfield' => $internal
176             ];
177             $userfieldmapping = new user_field_mapping(0, $record);
178             $userfieldmapping->create();
179         }
180         return $issuer;
181     }
183     /**
184      * Create one of the standard issuers.
185      * @param string $type One of google, facebook, microsoft
186      * @return \core\oauth2\issuer
187      */
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();
196         } else {
197             throw new moodle_exception('OAuth 2 service type not recognised: ' . $type);
198         }
199     }
201     /**
202      * List all the issuers, ordered by the sortorder field
203      * @return core\oauth2\issuer[]
204      */
205     public static function get_all_issuers() {
206         return issuer::get_records([], 'sortorder');
207     }
209     /**
210      * Get a single issuer by id.
211      *
212      * @param int $id
213      * @return core\oauth2\issuer
214      */
215     public static function get_issuer($id) {
216         return new issuer($id);
217     }
219     /**
220      * Get a single endpoint by id.
221      *
222      * @param int $id
223      * @return core\oauth2\endpoint
224      */
225     public static function get_endpoint($id) {
226         return new endpoint($id);
227     }
229     /**
230      * Get a single user field mapping by id.
231      *
232      * @param int $id
233      * @return core\oauth2\user_field_mapping
234      */
235     public static function get_user_field_mapping($id) {
236         return new user_field_mapping($id);
237     }
239     /**
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.
242      *
243      * @param \core\oauth2\issuer $issuer
244      * @return \core\oauth2\client
245      */
246     public static function get_system_account(issuer $issuer) {
247         return system_account::get_record(['issuerid' => $issuer->get('id')]);
248     }
250     /**
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.
253      *
254      * @param \core\oauth2\issuer $issuer
255      * @return string
256      */
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)) {
266                     continue;
267                 }
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;
276                         }
277                     }
278                 }
279             }
280         }
282         return $scopes;
283     }
285     /**
286      * Get an authenticated oauth2 client using the system account.
287      * This call uses the refresh token to get an access token.
288      *
289      * @param core\oauth2\issuer $issuer
290      * @return core\oauth2\client
291      */
292     public static function get_system_oauth_client(issuer $issuer) {
293         $systemaccount = self::get_system_account($issuer);
294         if (empty($systemaccount)) {
295             return false;
296         }
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)) {
304                 return false;
305             }
306         }
307         return $client;
308     }
310     /**
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.
313      *
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
318      */
319     public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '') {
320         $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes);
322         return $client;
323     }
325     /**
326      * Get the list of defined endpoints for this OAuth issuer
327      *
328      * @param core\oauth2\issuer $issuer The desired OAuth issuer
329      * @return core\oauth2\endpoint[]
330      */
331     public static function get_endpoints(issuer $issuer) {
332         return endpoint::get_records(['issuerid' => $issuer->get('id')]);
333     }
335     /**
336      * Get the list of defined mapping from OAuth user fields to moodle user fields.
337      *
338      * @param core\oauth2\issuer $issuer The desired OAuth issuer
339      * @return core\oauth2\user_field_mapping[]
340      */
341     public static function get_user_field_mappings(issuer $issuer) {
342         return user_field_mapping::get_records(['issuerid' => $issuer->get('id')]);
343     }
345     /**
346      * Guess an image from the discovery URL.
347      *
348      * @param core\oauth2\issuer $issuer The desired OAuth issuer
349      */
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);
355             $issuer->update();
356         }
357     }
359     /**
360      * If the discovery endpoint exists for this issuer, try and determine the list of valid endpoints.
361      *
362      * @param issuer $issuer
363      * @return int The number of discovered services.
364      */
365     protected static function discover_endpoints($issuer) {
366         $curl = new curl();
368         if (empty($issuer->get('baseurl'))) {
369             return 0;
370         }
372         $url = $issuer->get_endpoint_url('discovery');
373         if (!$url) {
374             $url = $issuer->get('baseurl') . '/.well-known/openid-configuration';
375         }
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);
380         }
382         if ($msg = $curl->error) {
383             throw new moodle_exception('Could not discover service endpoints: ' . $msg);
384         }
386         $info = json_decode($json);
387         if (empty($info)) {
388             $msg = 'Could not discover end points for identity issuer' . $issuer->get('name');
389             throw new moodle_exception($msg);
390         }
392         foreach (endpoint::get_records(['issuerid' => $issuer->get('id')]) as $endpoint) {
393             if ($endpoint->get('name') != 'discovery_endpoint') {
394                 $endpoint->delete();
395             }
396         }
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);
406                 $endpoint->create();
407             }
409             if ($key == 'scopes_supported') {
410                 $issuer->set('scopessupported', implode(' ', $value));
411                 $issuer->update();
412             }
413         }
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();
418         }
420         // Create the field mappings.
421         $mapping = [
422             'given_name' => 'firstname',
423             'middle_name' => 'middlename',
424             'family_name' => 'lastname',
425             'email' => 'email',
426             'website' => 'url',
427             'nickname' => 'alternatename',
428             'picture' => 'picture',
429             'address' => 'address',
430             'phone' => 'phone1',
431             'locale' => 'lang'
432         ];
433         foreach ($mapping as $external => $internal) {
434             $record = (object) [
435                 'issuerid' => $issuer->get('id'),
436                 'externalfield' => $external,
437                 'internalfield' => $internal
438             ];
439             $userfieldmapping = new user_field_mapping(0, $record);
440             $userfieldmapping->create();
441         }
443         return endpoint::count_records(['issuerid' => $issuer->get('id')]);
444     }
446     /**
447      * Take the data from the mform and update the issuer.
448      *
449      * @param stdClass $data
450      * @return core\oauth2\issuer
451      */
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.
457         $issuer->update();
459         // Perform service discovery.
460         self::discover_endpoints($issuer);
461         self::guess_image($issuer);
462         return $issuer;
463     }
465     /**
466      * Take the data from the mform and create the issuer.
467      *
468      * @param stdClass $data
469      * @return core\oauth2\issuer
470      */
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.
476         $issuer->create();
478         // Perform service discovery.
479         self::discover_endpoints($issuer);
480         self::guess_image($issuer);
481         return $issuer;
482     }
484     /**
485      * Take the data from the mform and update the endpoint.
486      *
487      * @param stdClass $data
488      * @return core\oauth2\endpoint
489      */
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.
495         $endpoint->update();
497         return $endpoint;
498     }
500     /**
501      * Take the data from the mform and create the endpoint.
502      *
503      * @param stdClass $data
504      * @return core\oauth2\endpoint
505      */
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.
511         $endpoint->create();
512         return $endpoint;
513     }
515     /**
516      * Take the data from the mform and update the user field mapping.
517      *
518      * @param stdClass $data
519      * @return core\oauth2\user_field_mapping
520      */
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;
529     }
531     /**
532      * Take the data from the mform and create the user field mapping.
533      *
534      * @param stdClass $data
535      * @return core\oauth2\user_field_mapping
536      */
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;
544     }
546     /**
547      * Reorder this identity issuer.
548      *
549      * Requires moodle/site:config capability at the system context.
550      *
551      * @param int $id The id of the identity issuer to move.
552      * @return boolean
553      */
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) {
560             return false;
561         }
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();
571         }
573         // OK - all set.
574         $result = $current->update();
576         return $result;
577     }
579     /**
580      * Reorder this identity issuer.
581      *
582      * Requires moodle/site:config capability at the system context.
583      *
584      * @param int $id The id of the identity issuer to move.
585      * @return boolean
586      */
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();
592         if ($max > 0) {
593             $max--;
594         }
596         $sortorder = $current->get('sortorder');
597         if ($sortorder >= $max) {
598             return false;
599         }
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();
608         }
610         // OK - all set.
611         $result = $current->update();
613         return $result;
614     }
616     /**
617      * Disable an identity issuer.
618      *
619      * Requires moodle/site:config capability at the system context.
620      *
621      * @param int $id The id of the identity issuer to disable.
622      * @return boolean
623      */
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();
630     }
633     /**
634      * Enable an identity issuer.
635      *
636      * Requires moodle/site:config capability at the system context.
637      *
638      * @param int $id The id of the identity issuer to enable.
639      * @return boolean
640      */
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();
647     }
649     /**
650      * Delete an identity issuer.
651      *
652      * Requires moodle/site:config capability at the system context.
653      *
654      * @param int $id The id of the identity issuer to delete.
655      * @return boolean
656      */
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();
664         }
665         $endpoints = self::get_endpoints($issuer);
666         if ($endpoints) {
667             foreach ($endpoints as $endpoint) {
668                 $endpoint->delete();
669             }
670         }
672         // Will throw exceptions on validation failures.
673         return $issuer->delete();
674     }
676     /**
677      * Delete an endpoint.
678      *
679      * Requires moodle/site:config capability at the system context.
680      *
681      * @param int $id The id of the endpoint to delete.
682      * @return boolean
683      */
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();
690     }
692     /**
693      * Delete a user_field_mapping.
694      *
695      * Requires moodle/site:config capability at the system context.
696      *
697      * @param int $id The id of the user_field_mapping to delete.
698      * @return boolean
699      */
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();
706     }
708     /**
709      * Perform the OAuth dance and get a refresh token.
710      *
711      * Requires moodle/site:config capability at the system context.
712      *
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).
715      * @return boolean
716      */
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)) {
728             $client->log_out();
729         }
731         if (optional_param('error', '', PARAM_RAW)) {
732             return false;
733         }
735         if (!$client->is_logged_in()) {
736             redirect($client->get_login_url());
737         }
739         $refreshtoken = $client->get_refresh_token();
740         if (!$refreshtoken) {
741             return false;
742         }
744         $systemaccount = self::get_system_account($issuer);
745         if ($systemaccount) {
746             $systemaccount->delete();
747         }
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 = isset($userinfo['email']) ? $userinfo['email'] : '';
756         $record->username = $userinfo['username'];
758         $systemaccount = new system_account(0, $record);
760         $systemaccount->create();
762         $client->log_out();
763         return true;
764     }