1fe80f52329c0f5ba01079a81a436ad6e5e70517
[moodle.git] / lib / oauthlib.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/>.
18 defined('MOODLE_INTERNAL') || die();
20 require_once($CFG->libdir.'/filelib.php');
22 /**
23  * OAuth helper class
24  *
25  * 1. You can extends oauth_helper to add specific functions, such as twitter extends oauth_helper
26  * 2. Call request_token method to get oauth_token and oauth_token_secret, and redirect user to authorize_url,
27  *    developer needs to store oauth_token and oauth_token_secret somewhere, we will use them to request
28  *    access token later on
29  * 3. User approved the request, and get back to moodle
30  * 4. Call get_access_token, it takes previous oauth_token and oauth_token_secret as arguments, oauth_token
31  *    will be used in OAuth request, oauth_token_secret will be used to bulid signature, this method will
32  *    return access_token and access_secret, store these two values in database or session
33  * 5. Now you can access oauth protected resources by access_token and access_secret using oauth_helper::request
34  *    method (or get() post())
35  *
36  * Note:
37  * 1. This class only support HMAC-SHA1
38  * 2. oauth_helper class don't store tokens and secrets, you must store them manually
39  * 3. Some functions are based on http://code.google.com/p/oauth/
40  *
41  * @package    moodlecore
42  * @copyright  2010 Dongsheng Cai <dongsheng@moodle.com>
43  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44  */
46 class oauth_helper {
47     /** @var string consumer key, issued by oauth provider*/
48     protected $consumer_key;
49     /** @var string consumer secret, issued by oauth provider*/
50     protected $consumer_secret;
51     /** @var string oauth root*/
52     protected $api_root;
53     /** @var string request token url*/
54     protected $request_token_api;
55     /** @var string authorize url*/
56     protected $authorize_url;
57     protected $http_method;
58     /** @var string */
59     protected $access_token_api;
60     /** @var curl */
61     protected $http;
62     /** @var array options to pass to the next curl request */
63     protected $http_options;
65     /**
66      * Contructor for oauth_helper.
67      * Subclass can override construct to build its own $this->http
68      *
69      * @param array $args requires at least three keys, oauth_consumer_key
70      *                    oauth_consumer_secret and api_root, oauth_helper will
71      *                    guess request_token_api, authrize_url and access_token_api
72      *                    based on api_root, but it not always works
73      */
74     function __construct($args) {
75         if (!empty($args['api_root'])) {
76             $this->api_root = $args['api_root'];
77         } else {
78             $this->api_root = '';
79         }
80         $this->consumer_key = $args['oauth_consumer_key'];
81         $this->consumer_secret = $args['oauth_consumer_secret'];
83         if (empty($args['request_token_api'])) {
84             $this->request_token_api = $this->api_root . '/request_token';
85         } else {
86             $this->request_token_api = $args['request_token_api'];
87         }
89         if (empty($args['authorize_url'])) {
90             $this->authorize_url = $this->api_root . '/authorize';
91         } else {
92             $this->authorize_url = $args['authorize_url'];
93         }
95         if (empty($args['access_token_api'])) {
96             $this->access_token_api = $this->api_root . '/access_token';
97         } else {
98             $this->access_token_api = $args['access_token_api'];
99         }
101         if (!empty($args['oauth_callback'])) {
102             $this->oauth_callback = new moodle_url($args['oauth_callback']);
103         }
104         if (!empty($args['access_token'])) {
105             $this->access_token = $args['access_token'];
106         }
107         if (!empty($args['access_token_secret'])) {
108             $this->access_token_secret = $args['access_token_secret'];
109         }
110         $this->http = new curl(array('debug'=>false));
111         $this->http_options = array();
112     }
114     /**
115      * Build parameters list:
116      *    oauth_consumer_key="0685bd9184jfhq22",
117      *    oauth_nonce="4572616e48616d6d65724c61686176",
118      *    oauth_token="ad180jjd733klru7",
119      *    oauth_signature_method="HMAC-SHA1",
120      *    oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D",
121      *    oauth_timestamp="137131200",
122      *    oauth_version="1.0"
123      *    oauth_verifier="1.0"
124      * @param array $param
125      * @return string
126      */
127     function get_signable_parameters($params){
128         $sorted = $params;
129         ksort($sorted);
131         $total = array();
132         foreach ($sorted as $k => $v) {
133             if ($k == 'oauth_signature') {
134                 continue;
135             }
137             $total[] = rawurlencode($k) . '=' . rawurlencode($v);
138         }
139         return implode('&', $total);
140     }
142     /**
143      * Create signature for oauth request
144      * @param string $url
145      * @param string $secret
146      * @param array $params
147      * @return string
148      */
149     public function sign($http_method, $url, $params, $secret) {
150         $sig = array(
151             strtoupper($http_method),
152             preg_replace('/%7E/', '~', rawurlencode($url)),
153             rawurlencode($this->get_signable_parameters($params)),
154         );
156         $base_string = implode('&', $sig);
157         $sig = base64_encode(hash_hmac('sha1', $base_string, $secret, true));
158         return $sig;
159     }
161     /**
162      * Initilize oauth request parameters, including:
163      *    oauth_consumer_key="0685bd9184jfhq22",
164      *    oauth_token="ad180jjd733klru7",
165      *    oauth_signature_method="HMAC-SHA1",
166      *    oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D",
167      *    oauth_timestamp="137131200",
168      *    oauth_nonce="4572616e48616d6d65724c61686176",
169      *    oauth_version="1.0"
170      * To access protected resources, oauth_token should be defined
171      *
172      * @param string $url
173      * @param string $token
174      * @param string $http_method
175      * @return array
176      */
177     public function prepare_oauth_parameters($url, $params, $http_method = 'POST') {
178         if (is_array($params)) {
179             $oauth_params = $params;
180         } else {
181             $oauth_params = array();
182         }
183         $oauth_params['oauth_version']      = '1.0';
184         $oauth_params['oauth_nonce']        = $this->get_nonce();
185         $oauth_params['oauth_timestamp']    = $this->get_timestamp();
186         $oauth_params['oauth_consumer_key'] = $this->consumer_key;
187         if (!empty($this->oauth_callback)) {
188             $oauth_params['oauth_callback'] = $this->oauth_callback->out(false);
189         }
190         $oauth_params['oauth_signature_method'] = 'HMAC-SHA1';
191         $oauth_params['oauth_signature']        = $this->sign($http_method, $url, $oauth_params, $this->sign_secret);
192         return $oauth_params;
193     }
195     public function setup_oauth_http_header($params) {
197         $total = array();
198         ksort($params);
199         foreach ($params as $k => $v) {
200             $total[] = rawurlencode($k) . '="' . rawurlencode($v).'"';
201         }
202         $str = implode(', ', $total);
203         $str = 'Authorization: OAuth '.$str;
204         $this->http->setHeader('Expect:');
205         $this->http->setHeader($str);
206     }
208     /**
209      * Sets the options for the next curl request
210      *
211      * @param array $options
212      */
213     public function setup_oauth_http_options($options) {
214         $this->http_options = $options;
215     }
217     /**
218      * Request token for authentication
219      * This is the first step to use OAuth, it will return oauth_token and oauth_token_secret
220      * @return array
221      */
222     public function request_token() {
223         $this->sign_secret = $this->consumer_secret.'&';
224         $params = $this->prepare_oauth_parameters($this->request_token_api, array(), 'GET');
225         $content = $this->http->get($this->request_token_api, $params, $this->http_options);
226         // Including:
227         //     oauth_token
228         //     oauth_token_secret
229         $result = $this->parse_result($content);
230         if (empty($result['oauth_token'])) {
231             throw new moodle_exception('Error while requesting an oauth token');
232         }
233         // build oauth authrize url
234         if (!empty($this->oauth_callback)) {
235             // url must be rawurlencode
236             $result['authorize_url'] = $this->authorize_url . '?oauth_token='.$result['oauth_token'].'&oauth_callback='.rawurlencode($this->oauth_callback->out(false));
237         } else {
238             // no callback
239             $result['authorize_url'] = $this->authorize_url . '?oauth_token='.$result['oauth_token'];
240         }
241         return $result;
242     }
244     /**
245      * Set oauth access token for oauth request
246      * @param string $token
247      * @param string $secret
248      */
249     public function set_access_token($token, $secret) {
250         $this->access_token = $token;
251         $this->access_token_secret = $secret;
252     }
254     /**
255      * Request oauth access token from server
256      * @param string $method
257      * @param string $url
258      * @param string $token
259      * @param string $secret
260      */
261     public function get_access_token($token, $secret, $verifier='') {
262         $this->sign_secret = $this->consumer_secret.'&'.$secret;
263         $params = $this->prepare_oauth_parameters($this->access_token_api, array('oauth_token'=>$token, 'oauth_verifier'=>$verifier), 'POST');
264         $this->setup_oauth_http_header($params);
265         // Should never send the callback in this request.
266         unset($params['oauth_callback']);
267         $content = $this->http->post($this->access_token_api, $params, $this->http_options);
268         $keys = $this->parse_result($content);
269         $this->set_access_token($keys['oauth_token'], $keys['oauth_token_secret']);
270         return $keys;
271     }
273     /**
274      * Request oauth protected resources
275      * @param string $method
276      * @param string $url
277      * @param string $token
278      * @param string $secret
279      */
280     public function request($method, $url, $params=array(), $token='', $secret='') {
281         if (empty($token)) {
282             $token = $this->access_token;
283         }
284         if (empty($secret)) {
285             $secret = $this->access_token_secret;
286         }
287         // to access protected resource, sign_secret will alwasy be consumer_secret+token_secret
288         $this->sign_secret = $this->consumer_secret.'&'.$secret;
289         if (strtolower($method) === 'post' && !empty($params)) {
290             $oauth_params = $this->prepare_oauth_parameters($url, array('oauth_token'=>$token) + $params, $method);
291         } else {
292             $oauth_params = $this->prepare_oauth_parameters($url, array('oauth_token'=>$token), $method);
293         }
294         $this->setup_oauth_http_header($oauth_params);
295         $content = call_user_func_array(array($this->http, strtolower($method)), array($url, $params, $this->http_options));
296         // reset http header and options to prepare for the next request
297         $this->http->resetHeader();
298         // return request return value
299         return $content;
300     }
302     /**
303      * shortcut to start http get request
304      */
305     public function get($url, $params=array(), $token='', $secret='') {
306         return $this->request('GET', $url, $params, $token, $secret);
307     }
309     /**
310      * shortcut to start http post request
311      */
312     public function post($url, $params=array(), $token='', $secret='') {
313         return $this->request('POST', $url, $params, $token, $secret);
314     }
316     /**
317      * A method to parse oauth response to get oauth_token and oauth_token_secret
318      * @param string $str
319      * @return array
320      */
321     public function parse_result($str) {
322         if (empty($str)) {
323             throw new moodle_exception('error');
324         }
325         $parts = explode('&', $str);
326         $result = array();
327         foreach ($parts as $part){
328             list($k, $v) = explode('=', $part, 2);
329             $result[urldecode($k)] = urldecode($v);
330         }
331         if (empty($result)) {
332             throw new moodle_exception('error');
333         }
334         return $result;
335     }
337     /**
338      * Set nonce
339      */
340     function set_nonce($str) {
341         $this->nonce = $str;
342     }
343     /**
344      * Set timestamp
345      */
346     function set_timestamp($time) {
347         $this->timestamp = $time;
348     }
349     /**
350      * Generate timestamp
351      */
352     function get_timestamp() {
353         if (!empty($this->timestamp)) {
354             $timestamp = $this->timestamp;
355             unset($this->timestamp);
356             return $timestamp;
357         }
358         return time();
359     }
360     /**
361      * Generate nonce for oauth request
362      */
363     function get_nonce() {
364         if (!empty($this->nonce)) {
365             $nonce = $this->nonce;
366             unset($this->nonce);
367             return $nonce;
368         }
369         $mt = microtime();
370         $rand = mt_rand();
372         return md5($mt . $rand);
373     }
376 /**
377  * OAuth 2.0 Client for using web access tokens.
378  *
379  * http://tools.ietf.org/html/draft-ietf-oauth-v2-22
380  *
381  * @package   core
382  * @copyright Dan Poltawski <talktodan@gmail.com>
383  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
384  */
385 abstract class oauth2_client extends curl {
386     /** @var string $clientid client identifier issued to the client */
387     private $clientid = '';
388     /** @var string $clientsecret The client secret. */
389     private $clientsecret = '';
390     /** @var moodle_url $returnurl URL to return to after authenticating */
391     private $returnurl = null;
392     /** @var string $scope of the authentication request */
393     protected $scope = '';
394     /** @var stdClass $accesstoken access token object */
395     private $accesstoken = null;
396     /** @var string $refreshtoken refresh token string */
397     private $refreshtoken = '';
398     /** @var string $mocknextresponse string */
399     private $mocknextresponse = '';
400     /** @var array $upgradedcodes list of upgraded codes in this request */
401     private static $upgradedcodes = [];
403     /**
404      * Returns the auth url for OAuth 2.0 request
405      * @return string the auth url
406      */
407     abstract protected function auth_url();
409     /**
410      * Returns the token url for OAuth 2.0 request
411      * @return string the auth url
412      */
413     abstract protected function token_url();
415     /**
416      * Constructor.
417      *
418      * @param string $clientid
419      * @param string $clientsecret
420      * @param moodle_url $returnurl
421      * @param string $scope
422      */
423     public function __construct($clientid, $clientsecret, moodle_url $returnurl, $scope) {
424         parent::__construct();
425         $this->clientid = $clientid;
426         $this->clientsecret = $clientsecret;
427         $this->returnurl = $returnurl;
428         $this->scope = $scope;
429         $this->accesstoken = $this->get_stored_token();
430     }
432     /**
433      * Is the user logged in? Note that if this is called
434      * after the first part of the authorisation flow the token
435      * is upgraded to an accesstoken.
436      *
437      * @return boolean true if logged in
438      */
439     public function is_logged_in() {
440         // Has the token expired?
441         if (isset($this->accesstoken->expires) && time() >= $this->accesstoken->expires) {
442             $this->log_out();
443             return false;
444         }
446         // We have a token so we are logged in.
447         if (isset($this->accesstoken->token)) {
448             // Check that the access token has all the requested scopes.
449             $scopemissing = false;
450             $scopecheck = ' ' . $this->accesstoken->scope . ' ';
452             $requiredscopes = explode(' ', $this->scope);
453             foreach ($requiredscopes as $requiredscope) {
454                 if (strpos($scopecheck, ' ' . $requiredscope . ' ') === false) {
455                     $scopemissing = true;
456                     break;
457                 }
458             }
459             if (!$scopemissing) {
460                 return true;
461             }
462         }
464         // If we've been passed then authorization code generated by the
465         // authorization server try and upgrade the token to an access token.
466         $code = optional_param('oauth2code', null, PARAM_RAW);
467         // Note - sometimes we may call is_logged_in twice in the same request - we don't want to attempt
468         // to upgrade the same token twice.
469         if ($code && !in_array($code, self::$upgradedcodes) && $this->upgrade_token($code)) {
470             return true;
471         }
473         return false;
474     }
476     /**
477      * Callback url where the request is returned to.
478      *
479      * @return moodle_url url of callback
480      */
481     public static function callback_url() {
482         global $CFG;
484         return new moodle_url('/admin/oauth2callback.php');
485     }
487     /**
488      * An additional array of url params to pass with a login request.
489      *
490      * @return array of name value pairs.
491      */
492     public function get_additional_login_parameters() {
493         return [];
494     }
496     /**
497      * Returns the login link for this oauth request
498      *
499      * @return moodle_url login url
500      */
501     public function get_login_url() {
503         $callbackurl = self::callback_url();
504         $params = array_merge(
505             [
506                 'client_id' => $this->clientid,
507                 'response_type' => 'code',
508                 'redirect_uri' => $callbackurl->out(false),
509                 'state' => $this->returnurl->out_as_local_url(false),
510                 'scope' => $this->scope,
511             ],
512             $this->get_additional_login_parameters()
513         );
515         return new moodle_url($this->auth_url(), $params);
516     }
518     /**
519      * Given an array of name value pairs - build a valid HTTP POST application/x-www-form-urlencoded string.
520      *
521      * @param array $params Name / value pairs.
522      * @return string POST data.
523      */
524     public function build_post_data($params) {
525         $result = [];
526         foreach ($params as $name => $value) {
527             $result[] = str_replace('&', '%26', $name) . '=' . str_replace('&', '%26', $value);
528         }
529         return implode('&', $result);
530     }
532     /**
533      * Upgrade a authorization token from oauth 2.0 to an access token
534      *
535      * @param string $code the code returned from the oauth authenticaiton
536      * @return boolean true if token is upgraded succesfully
537      */
538     public function upgrade_token($code) {
539         $callbackurl = self::callback_url();
540         $params = array('code' => $code,
541             'client_id' => $this->clientid,
542             'client_secret' => $this->clientsecret,
543             'grant_type' => 'authorization_code',
544             'redirect_uri' => $callbackurl->out(false),
545         );
547         // Requests can either use http GET or POST.
548         if ($this->use_http_get()) {
549             $response = $this->get($this->token_url(), $params);
550         } else {
551             $response = $this->post($this->token_url(), $this->build_post_data($params));
552         }
554         if ($this->info['http_code'] !== 200) {
555             throw new moodle_exception('Could not upgrade oauth token');
556         }
558         $r = json_decode($response);
560         if (!empty($r->error)) {
561             throw new moodle_exception($r->error . ' ' . $r->error_description);
562         }
564         if (!isset($r->access_token)) {
565             return false;
566         }
568         if (isset($r->refresh_token)) {
569             $this->refreshtoken = $r->refresh_token;
570         }
572         // Store the token an expiry time.
573         $accesstoken = new stdClass;
574         $accesstoken->token = $r->access_token;
575         if (isset($r->expires_in)) {
576             // Expires 10 seconds before actual expiry.
577             $accesstoken->expires = (time() + ($r->expires_in - 10));
578         }
579         $accesstoken->scope = $this->scope;
580         // Also add the scopes.
581         self::$upgradedcodes[] = $code;
582         $this->store_token($accesstoken);
584         return true;
585     }
587     /**
588      * Logs out of a oauth request, clearing any stored tokens
589      */
590     public function log_out() {
591         $this->store_token(null);
592     }
594     /**
595      * Make a HTTP request, adding the access token we have
596      *
597      * @param string $url The URL to request
598      * @param array $options
599      * @return bool
600      */
601     protected function request($url, $options = array()) {
602         $murl = new moodle_url($url);
604         if ($this->accesstoken) {
605             if ($this->use_http_get()) {
606                 // If using HTTP GET add as a parameter.
607                 $murl->param('access_token', $this->accesstoken->token);
608             } else {
609                 $this->setHeader('Authorization: Bearer '.$this->accesstoken->token);
610             }
611         }
613         $response = parent::request($murl->out(false), $options);
615         $this->resetHeader();
617         return $response;
618     }
620     /**
621      * Multiple HTTP Requests
622      * This function could run multi-requests in parallel.
623      *
624      * @param array $requests An array of files to request
625      * @param array $options An array of options to set
626      * @return array An array of results
627      */
628     protected function multi($requests, $options = array()) {
629         if ($this->accesstoken) {
630             $this->setHeader('Authorization: Bearer '.$this->accesstoken->token);
631         }
632         return parent::multi($requests, $options);
633     }
635     /**
636      * Returns the tokenname for the access_token to be stored
637      * through multiple requests.
638      *
639      * The default implentation is to use the classname combiend
640      * with the scope.
641      *
642      * @return string tokenname for prefernce storage
643      */
644     protected function get_tokenname() {
645         // This is unusual but should work for most purposes.
646         return get_class($this).'-'.md5($this->scope);
647     }
649     /**
650      * Store a token between requests. Currently uses
651      * session named by get_tokenname
652      *
653      * @param stdClass|null $token token object to store or null to clear
654      */
655     protected function store_token($token) {
656         global $SESSION;
658         $this->accesstoken = $token;
659         $name = $this->get_tokenname();
661         if ($token !== null) {
662             $SESSION->{$name} = $token;
663         } else {
664             unset($SESSION->{$name});
665         }
666     }
668     /**
669      * Get a refresh token!!!
670      *
671      * @return string
672      */
673     public function get_refresh_token() {
674         return $this->refreshtoken;
675     }
677     /**
678      * Retrieve a token stored.
679      *
680      * @return stdClass|null token object
681      */
682     protected function get_stored_token() {
683         global $SESSION;
685         $name = $this->get_tokenname();
687         if (isset($SESSION->{$name})) {
688             return $SESSION->{$name};
689         }
691         return null;
692     }
694     /**
695      * Get access token.
696      *
697      * This is just a getter to read the private property.
698      *
699      * @return string
700      */
701     public function get_accesstoken() {
702         return $this->accesstoken;
703     }
705     /**
706      * Get the client ID.
707      *
708      * This is just a getter to read the private property.
709      *
710      * @return string
711      */
712     public function get_clientid() {
713         return $this->clientid;
714     }
716     /**
717      * Get the client secret.
718      *
719      * This is just a getter to read the private property.
720      *
721      * @return string
722      */
723     public function get_clientsecret() {
724         return $this->clientsecret;
725     }
727     /**
728      * Should HTTP GET be used instead of POST?
729      * Some APIs do not support POST and want oauth to use
730      * GET instead (with the auth_token passed as a GET param).
731      *
732      * @return bool true if GET should be used
733      */
734     protected function use_http_get() {
735         return false;
736     }