Merge branch 'MDL-59961_file_validation' of https://github.com/mrmark/moodle
[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 system_account|false
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|false An authenticated client (or false if the token could not be upgraded)
291      * @throws moodle_exception Request for token upgrade failed for technical reasons
292      */
293     public static function get_system_oauth_client(issuer $issuer) {
294         $systemaccount = self::get_system_account($issuer);
295         if (empty($systemaccount)) {
296             return false;
297         }
298         // Get all the scopes!
299         $scopes = self::get_system_scopes_for_issuer($issuer);
301         $client = new \core\oauth2\client($issuer, null, $scopes, true);
303         if (!$client->is_logged_in()) {
304             if (!$client->upgrade_refresh_token($systemaccount)) {
305                 return false;
306             }
307         }
308         return $client;
309     }
311     /**
312      * Get an authenticated oauth2 client using the current user account.
313      * This call does the redirect dance back to the current page after authentication.
314      *
315      * @param \core\oauth2\issuer $issuer The desired OAuth issuer
316      * @param moodle_url $currenturl The url to the current page.
317      * @param string $additionalscopes The additional scopes required for authorization.
318      * @return \core\oauth2\client
319      */
320     public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '') {
321         $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes);
323         return $client;
324     }
326     /**
327      * Get the list of defined endpoints for this OAuth issuer
328      *
329      * @param \core\oauth2\issuer $issuer The desired OAuth issuer
330      * @return \core\oauth2\endpoint[]
331      */
332     public static function get_endpoints(issuer $issuer) {
333         return endpoint::get_records(['issuerid' => $issuer->get('id')]);
334     }
336     /**
337      * Get the list of defined mapping from OAuth user fields to moodle user fields.
338      *
339      * @param \core\oauth2\issuer $issuer The desired OAuth issuer
340      * @return \core\oauth2\user_field_mapping[]
341      */
342     public static function get_user_field_mappings(issuer $issuer) {
343         return user_field_mapping::get_records(['issuerid' => $issuer->get('id')]);
344     }
346     /**
347      * Guess an image from the discovery URL.
348      *
349      * @param \core\oauth2\issuer $issuer The desired OAuth issuer
350      */
351     protected static function guess_image($issuer) {
352         if (empty($issuer->get('image'))) {
353             $baseurl = parse_url($issuer->get('baseurl'));
354             $imageurl = $baseurl['scheme'] . '://' . $baseurl['host'] . '/favicon.ico';
355             $issuer->set('image', $imageurl);
356             $issuer->update();
357         }
358     }
360     /**
361      * If the discovery endpoint exists for this issuer, try and determine the list of valid endpoints.
362      *
363      * @param issuer $issuer
364      * @return int The number of discovered services.
365      */
366     protected static function discover_endpoints($issuer) {
367         $curl = new curl();
369         if (empty($issuer->get('baseurl'))) {
370             return 0;
371         }
373         $url = $issuer->get_endpoint_url('discovery');
374         if (!$url) {
375             $url = $issuer->get('baseurl') . '/.well-known/openid-configuration';
376         }
378         if (!$json = $curl->get($url)) {
379             $msg = 'Could not discover end points for identity issuer' . $issuer->get('name');
380             throw new moodle_exception($msg);
381         }
383         if ($msg = $curl->error) {
384             throw new moodle_exception('Could not discover service endpoints: ' . $msg);
385         }
387         $info = json_decode($json);
388         if (empty($info)) {
389             $msg = 'Could not discover end points for identity issuer' . $issuer->get('name');
390             throw new moodle_exception($msg);
391         }
393         foreach (endpoint::get_records(['issuerid' => $issuer->get('id')]) as $endpoint) {
394             if ($endpoint->get('name') != 'discovery_endpoint') {
395                 $endpoint->delete();
396             }
397         }
399         foreach ($info as $key => $value) {
400             if (substr_compare($key, '_endpoint', - strlen('_endpoint')) === 0) {
401                 $record = new stdClass();
402                 $record->issuerid = $issuer->get('id');
403                 $record->name = $key;
404                 $record->url = $value;
406                 $endpoint = new endpoint(0, $record);
407                 $endpoint->create();
408             }
410             if ($key == 'scopes_supported') {
411                 $issuer->set('scopessupported', implode(' ', $value));
412                 $issuer->update();
413             }
414         }
416         // We got to here - must be a decent OpenID connect service. Add the default user field mapping list.
417         foreach (user_field_mapping::get_records(['issuerid' => $issuer->get('id')]) as $userfieldmapping) {
418             $userfieldmapping->delete();
419         }
421         // Create the field mappings.
422         $mapping = [
423             'given_name' => 'firstname',
424             'middle_name' => 'middlename',
425             'family_name' => 'lastname',
426             'email' => 'email',
427             'website' => 'url',
428             'nickname' => 'alternatename',
429             'picture' => 'picture',
430             'address' => 'address',
431             'phone' => 'phone1',
432             'locale' => 'lang'
433         ];
434         foreach ($mapping as $external => $internal) {
435             $record = (object) [
436                 'issuerid' => $issuer->get('id'),
437                 'externalfield' => $external,
438                 'internalfield' => $internal
439             ];
440             $userfieldmapping = new user_field_mapping(0, $record);
441             $userfieldmapping->create();
442         }
444         return endpoint::count_records(['issuerid' => $issuer->get('id')]);
445     }
447     /**
448      * Take the data from the mform and update the issuer.
449      *
450      * @param stdClass $data
451      * @return \core\oauth2\issuer
452      */
453     public static function update_issuer($data) {
454         require_capability('moodle/site:config', context_system::instance());
455         $issuer = new issuer(0, $data);
457         // Will throw exceptions on validation failures.
458         $issuer->update();
460         // Perform service discovery.
461         self::discover_endpoints($issuer);
462         self::guess_image($issuer);
463         return $issuer;
464     }
466     /**
467      * Take the data from the mform and create the issuer.
468      *
469      * @param stdClass $data
470      * @return \core\oauth2\issuer
471      */
472     public static function create_issuer($data) {
473         require_capability('moodle/site:config', context_system::instance());
474         $issuer = new issuer(0, $data);
476         // Will throw exceptions on validation failures.
477         $issuer->create();
479         // Perform service discovery.
480         self::discover_endpoints($issuer);
481         self::guess_image($issuer);
482         return $issuer;
483     }
485     /**
486      * Take the data from the mform and update the endpoint.
487      *
488      * @param stdClass $data
489      * @return \core\oauth2\endpoint
490      */
491     public static function update_endpoint($data) {
492         require_capability('moodle/site:config', context_system::instance());
493         $endpoint = new endpoint(0, $data);
495         // Will throw exceptions on validation failures.
496         $endpoint->update();
498         return $endpoint;
499     }
501     /**
502      * Take the data from the mform and create the endpoint.
503      *
504      * @param stdClass $data
505      * @return \core\oauth2\endpoint
506      */
507     public static function create_endpoint($data) {
508         require_capability('moodle/site:config', context_system::instance());
509         $endpoint = new endpoint(0, $data);
511         // Will throw exceptions on validation failures.
512         $endpoint->create();
513         return $endpoint;
514     }
516     /**
517      * Take the data from the mform and update the user field mapping.
518      *
519      * @param stdClass $data
520      * @return \core\oauth2\user_field_mapping
521      */
522     public static function update_user_field_mapping($data) {
523         require_capability('moodle/site:config', context_system::instance());
524         $userfieldmapping = new user_field_mapping(0, $data);
526         // Will throw exceptions on validation failures.
527         $userfieldmapping->update();
529         return $userfieldmapping;
530     }
532     /**
533      * Take the data from the mform and create the user field mapping.
534      *
535      * @param stdClass $data
536      * @return \core\oauth2\user_field_mapping
537      */
538     public static function create_user_field_mapping($data) {
539         require_capability('moodle/site:config', context_system::instance());
540         $userfieldmapping = new user_field_mapping(0, $data);
542         // Will throw exceptions on validation failures.
543         $userfieldmapping->create();
544         return $userfieldmapping;
545     }
547     /**
548      * Reorder this identity issuer.
549      *
550      * Requires moodle/site:config capability at the system context.
551      *
552      * @param int $id The id of the identity issuer to move.
553      * @return boolean
554      */
555     public static function move_up_issuer($id) {
556         require_capability('moodle/site:config', context_system::instance());
557         $current = new issuer($id);
559         $sortorder = $current->get('sortorder');
560         if ($sortorder == 0) {
561             return false;
562         }
564         $sortorder = $sortorder - 1;
565         $current->set('sortorder', $sortorder);
567         $filters = array('sortorder' => $sortorder);
568         $children = issuer::get_records($filters, 'id');
569         foreach ($children as $needtoswap) {
570             $needtoswap->set('sortorder', $sortorder + 1);
571             $needtoswap->update();
572         }
574         // OK - all set.
575         $result = $current->update();
577         return $result;
578     }
580     /**
581      * Reorder this identity issuer.
582      *
583      * Requires moodle/site:config capability at the system context.
584      *
585      * @param int $id The id of the identity issuer to move.
586      * @return boolean
587      */
588     public static function move_down_issuer($id) {
589         require_capability('moodle/site:config', context_system::instance());
590         $current = new issuer($id);
592         $max = issuer::count_records();
593         if ($max > 0) {
594             $max--;
595         }
597         $sortorder = $current->get('sortorder');
598         if ($sortorder >= $max) {
599             return false;
600         }
601         $sortorder = $sortorder + 1;
602         $current->set('sortorder', $sortorder);
604         $filters = array('sortorder' => $sortorder);
605         $children = issuer::get_records($filters);
606         foreach ($children as $needtoswap) {
607             $needtoswap->set('sortorder', $sortorder - 1);
608             $needtoswap->update();
609         }
611         // OK - all set.
612         $result = $current->update();
614         return $result;
615     }
617     /**
618      * Disable an identity issuer.
619      *
620      * Requires moodle/site:config capability at the system context.
621      *
622      * @param int $id The id of the identity issuer to disable.
623      * @return boolean
624      */
625     public static function disable_issuer($id) {
626         require_capability('moodle/site:config', context_system::instance());
627         $issuer = new issuer($id);
629         $issuer->set('enabled', 0);
630         return $issuer->update();
631     }
634     /**
635      * Enable an identity issuer.
636      *
637      * Requires moodle/site:config capability at the system context.
638      *
639      * @param int $id The id of the identity issuer to enable.
640      * @return boolean
641      */
642     public static function enable_issuer($id) {
643         require_capability('moodle/site:config', context_system::instance());
644         $issuer = new issuer($id);
646         $issuer->set('enabled', 1);
647         return $issuer->update();
648     }
650     /**
651      * Delete an identity issuer.
652      *
653      * Requires moodle/site:config capability at the system context.
654      *
655      * @param int $id The id of the identity issuer to delete.
656      * @return boolean
657      */
658     public static function delete_issuer($id) {
659         require_capability('moodle/site:config', context_system::instance());
660         $issuer = new issuer($id);
662         $systemaccount = self::get_system_account($issuer);
663         if ($systemaccount) {
664             $systemaccount->delete();
665         }
666         $endpoints = self::get_endpoints($issuer);
667         if ($endpoints) {
668             foreach ($endpoints as $endpoint) {
669                 $endpoint->delete();
670             }
671         }
673         // Will throw exceptions on validation failures.
674         return $issuer->delete();
675     }
677     /**
678      * Delete an endpoint.
679      *
680      * Requires moodle/site:config capability at the system context.
681      *
682      * @param int $id The id of the endpoint to delete.
683      * @return boolean
684      */
685     public static function delete_endpoint($id) {
686         require_capability('moodle/site:config', context_system::instance());
687         $endpoint = new endpoint($id);
689         // Will throw exceptions on validation failures.
690         return $endpoint->delete();
691     }
693     /**
694      * Delete a user_field_mapping.
695      *
696      * Requires moodle/site:config capability at the system context.
697      *
698      * @param int $id The id of the user_field_mapping to delete.
699      * @return boolean
700      */
701     public static function delete_user_field_mapping($id) {
702         require_capability('moodle/site:config', context_system::instance());
703         $userfieldmapping = new user_field_mapping($id);
705         // Will throw exceptions on validation failures.
706         return $userfieldmapping->delete();
707     }
709     /**
710      * Perform the OAuth dance and get a refresh token.
711      *
712      * Requires moodle/site:config capability at the system context.
713      *
714      * @param \core\oauth2\issuer $issuer
715      * @param moodle_url $returnurl The url to the current page (we will be redirected back here after authentication).
716      * @return boolean
717      */
718     public static function connect_system_account($issuer, $returnurl) {
719         require_capability('moodle/site:config', context_system::instance());
721         // We need to authenticate with an oauth 2 client AS a system user and get a refresh token for offline access.
722         $scopes = self::get_system_scopes_for_issuer($issuer);
724         // Allow callbacks to inject non-standard scopes to the auth request.
726         $client = new client($issuer, $returnurl, $scopes, true);
728         if (!optional_param('response', false, PARAM_BOOL)) {
729             $client->log_out();
730         }
732         if (optional_param('error', '', PARAM_RAW)) {
733             return false;
734         }
736         if (!$client->is_logged_in()) {
737             redirect($client->get_login_url());
738         }
740         $refreshtoken = $client->get_refresh_token();
741         if (!$refreshtoken) {
742             return false;
743         }
745         $systemaccount = self::get_system_account($issuer);
746         if ($systemaccount) {
747             $systemaccount->delete();
748         }
750         $userinfo = $client->get_userinfo();
752         $record = new stdClass();
753         $record->issuerid = $issuer->get('id');
754         $record->refreshtoken = $refreshtoken;
755         $record->grantedscopes = $scopes;
756         $record->email = isset($userinfo['email']) ? $userinfo['email'] : '';
757         $record->username = $userinfo['username'];
759         $systemaccount = new system_account(0, $record);
761         $systemaccount->create();
763         $client->log_out();
764         return true;
765     }