MDL-59746 oauth2: urlencode parameters for post requests
[moodle.git] / lib / oauthlib.php
CommitLineData
3e123368 1<?php
3e123368
DC
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/>.
16
469fb5d6
DP
17
18defined('MOODLE_INTERNAL') || die();
19
20require_once($CFG->libdir.'/filelib.php');
21
3e123368
DC
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 */
45
46class 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;
8d8a6009
MG
62 /** @var array options to pass to the next curl request */
63 protected $http_options;
3e123368
DC
64
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'];
82
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 }
88
89 if (empty($args['authorize_url'])) {
90 $this->authorize_url = $this->api_root . '/authorize';
91 } else {
92 $this->authorize_url = $args['authorize_url'];
93 }
94
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 }
100
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));
8d8a6009 111 $this->http_options = array();
3e123368
DC
112 }
113
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);
130
131 $total = array();
132 foreach ($sorted as $k => $v) {
133 if ($k == 'oauth_signature') {
134 continue;
135 }
136
137 $total[] = rawurlencode($k) . '=' . rawurlencode($v);
138 }
139 return implode('&', $total);
140 }
141
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 );
155
156 $base_string = implode('&', $sig);
157 $sig = base64_encode(hash_hmac('sha1', $base_string, $secret, true));
158 return $sig;
159 }
160
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 }
194
195 public function setup_oauth_http_header($params) {
196
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 }
207
8d8a6009
MG
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 }
216
3e123368
DC
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');
8d8a6009 225 $content = $this->http->get($this->request_token_api, $params, $this->http_options);
3e123368
DC
226 // Including:
227 // oauth_token
228 // oauth_token_secret
229 $result = $this->parse_result($content);
230 if (empty($result['oauth_token'])) {
0e6ee5e9 231 throw new moodle_exception('Error while requesting an oauth token');
3e123368
DC
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 }
243
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 }
253
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);
926a3512
FM
265 // Should never send the callback in this request.
266 unset($params['oauth_callback']);
8d8a6009 267 $content = $this->http->post($this->access_token_api, $params, $this->http_options);
3e123368
DC
268 $keys = $this->parse_result($content);
269 $this->set_access_token($keys['oauth_token'], $keys['oauth_token_secret']);
270 return $keys;
271 }
272
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;
6ec68429
MG
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 }
3e123368 294 $this->setup_oauth_http_header($oauth_params);
8d8a6009 295 $content = call_user_func_array(array($this->http, strtolower($method)), array($url, $params, $this->http_options));
7e1e775f
MG
296 // reset http header and options to prepare for the next request
297 $this->http->resetHeader();
298 // return request return value
3e123368
DC
299 return $content;
300 }
301
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 }
308
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 }
315
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 }
336
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();
371
372 return md5($mt . $rand);
373 }
374}
469fb5d6
DP
375
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 */
385abstract class oauth2_client extends curl {
72fd103a 386 /** @var string $clientid client identifier issued to the client */
469fb5d6 387 private $clientid = '';
72fd103a 388 /** @var string $clientsecret The client secret. */
469fb5d6 389 private $clientsecret = '';
72fd103a 390 /** @var moodle_url $returnurl URL to return to after authenticating */
db7602af 391 private $returnurl = null;
72fd103a 392 /** @var string $scope of the authentication request */
931c0234 393 protected $scope = '';
72fd103a 394 /** @var stdClass $accesstoken access token object */
469fb5d6 395 private $accesstoken = null;
72fd103a 396 /** @var string $refreshtoken refresh token string */
8eb12812 397 protected $refreshtoken = '';
72fd103a 398 /** @var string $mocknextresponse string */
931c0234 399 private $mocknextresponse = '';
72fd103a 400 /** @var array $upgradedcodes list of upgraded codes in this request */
2fad1410 401 private static $upgradedcodes = [];
469fb5d6
DP
402
403 /**
404 * Returns the auth url for OAuth 2.0 request
405 * @return string the auth url
406 */
407 abstract protected function auth_url();
408
409 /**
410 * Returns the token url for OAuth 2.0 request
411 * @return string the auth url
412 */
413 abstract protected function token_url();
414
415 /**
416 * Constructor.
417 *
418 * @param string $clientid
419 * @param string $clientsecret
db7602af 420 * @param moodle_url $returnurl
469fb5d6
DP
421 * @param string $scope
422 */
db7602af 423 public function __construct($clientid, $clientsecret, moodle_url $returnurl, $scope) {
469fb5d6
DP
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 }
431
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 }
445
446 // We have a token so we are logged in.
447 if (isset($this->accesstoken->token)) {
8445556b 448 // Check that the access token has all the requested scopes.
2fad1410 449 $scopemissing = false;
8445556b
DW
450 $scopecheck = ' ' . $this->accesstoken->scope . ' ';
451
452 $requiredscopes = explode(' ', $this->scope);
453 foreach ($requiredscopes as $requiredscope) {
454 if (strpos($scopecheck, ' ' . $requiredscope . ' ') === false) {
2fad1410
DW
455 $scopemissing = true;
456 break;
8445556b
DW
457 }
458 }
2fad1410
DW
459 if (!$scopemissing) {
460 return true;
461 }
462 }
463
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)) {
469fb5d6
DP
470 return true;
471 }
472
469fb5d6
DP
473 return false;
474 }
475
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;
483
484 return new moodle_url('/admin/oauth2callback.php');
485 }
486
931c0234
DW
487 /**
488 * An additional array of url params to pass with a login request.
489 *
490 * @return array of name value pairs.
491 */
60237253
DW
492 public function get_additional_login_parameters() {
493 return [];
494 }
495
469fb5d6
DP
496 /**
497 * Returns the login link for this oauth request
498 *
499 * @return moodle_url login url
500 */
501 public function get_login_url() {
502
503 $callbackurl = self::callback_url();
60237253
DW
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 );
514
515 return new moodle_url($this->auth_url(), $params);
516 }
517
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) {
8f1e67d3 527 $result[] = urlencode($name) . '=' . urlencode($value);
60237253
DW
528 }
529 return implode('&', $result);
469fb5d6
DP
530 }
531
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();
60237253
DW
540 $params = array('code' => $code,
541 'client_id' => $this->clientid,
469fb5d6
DP
542 'client_secret' => $this->clientsecret,
543 'grant_type' => 'authorization_code',
469fb5d6
DP
544 'redirect_uri' => $callbackurl->out(false),
545 );
546
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 {
60237253 551 $response = $this->post($this->token_url(), $this->build_post_data($params));
469fb5d6
DP
552 }
553
eb4ab7c4 554 if ($this->info['http_code'] !== 200) {
469fb5d6
DP
555 throw new moodle_exception('Could not upgrade oauth token');
556 }
557
558 $r = json_decode($response);
559
675b291d
STA
560 if (is_null($r)) {
561 throw new moodle_exception("Could not decode JSON token response");
562 }
563
8445556b
DW
564 if (!empty($r->error)) {
565 throw new moodle_exception($r->error . ' ' . $r->error_description);
566 }
567
469fb5d6
DP
568 if (!isset($r->access_token)) {
569 return false;
570 }
571
60237253
DW
572 if (isset($r->refresh_token)) {
573 $this->refreshtoken = $r->refresh_token;
574 }
575
469fb5d6
DP
576 // Store the token an expiry time.
577 $accesstoken = new stdClass;
578 $accesstoken->token = $r->access_token;
3a4c497c
AN
579 if (isset($r->expires_in)) {
580 // Expires 10 seconds before actual expiry.
581 $accesstoken->expires = (time() + ($r->expires_in - 10));
582 }
141ee541 583 $accesstoken->scope = $this->scope;
8445556b 584 // Also add the scopes.
2fad1410 585 self::$upgradedcodes[] = $code;
469fb5d6
DP
586 $this->store_token($accesstoken);
587
588 return true;
589 }
590
591 /**
592 * Logs out of a oauth request, clearing any stored tokens
593 */
594 public function log_out() {
595 $this->store_token(null);
596 }
597
598 /**
599 * Make a HTTP request, adding the access token we have
600 *
601 * @param string $url The URL to request
602 * @param array $options
603 * @return bool
604 */
605 protected function request($url, $options = array()) {
606 $murl = new moodle_url($url);
607
608 if ($this->accesstoken) {
609 if ($this->use_http_get()) {
610 // If using HTTP GET add as a parameter.
611 $murl->param('access_token', $this->accesstoken->token);
612 } else {
613 $this->setHeader('Authorization: Bearer '.$this->accesstoken->token);
614 }
615 }
616
675b291d
STA
617 // Force JSON format content in response.
618 $this->setHeader('Accept: application/json');
619
60237253
DW
620 $response = parent::request($murl->out(false), $options);
621
622 $this->resetHeader();
623
624 return $response;
469fb5d6
DP
625 }
626
627 /**
628 * Multiple HTTP Requests
629 * This function could run multi-requests in parallel.
630 *
631 * @param array $requests An array of files to request
632 * @param array $options An array of options to set
633 * @return array An array of results
634 */
635 protected function multi($requests, $options = array()) {
636 if ($this->accesstoken) {
637 $this->setHeader('Authorization: Bearer '.$this->accesstoken->token);
638 }
639 return parent::multi($requests, $options);
640 }
641
642 /**
643 * Returns the tokenname for the access_token to be stored
644 * through multiple requests.
645 *
646 * The default implentation is to use the classname combiend
647 * with the scope.
648 *
649 * @return string tokenname for prefernce storage
650 */
651 protected function get_tokenname() {
652 // This is unusual but should work for most purposes.
653 return get_class($this).'-'.md5($this->scope);
654 }
655
656 /**
657 * Store a token between requests. Currently uses
658 * session named by get_tokenname
659 *
660 * @param stdClass|null $token token object to store or null to clear
661 */
662 protected function store_token($token) {
663 global $SESSION;
664
665 $this->accesstoken = $token;
666 $name = $this->get_tokenname();
667
668 if ($token !== null) {
669 $SESSION->{$name} = $token;
670 } else {
671 unset($SESSION->{$name});
672 }
673 }
674
60237253
DW
675 /**
676 * Get a refresh token!!!
677 *
678 * @return string
679 */
680 public function get_refresh_token() {
681 return $this->refreshtoken;
682 }
683
469fb5d6
DP
684 /**
685 * Retrieve a token stored.
686 *
687 * @return stdClass|null token object
688 */
689 protected function get_stored_token() {
690 global $SESSION;
691
692 $name = $this->get_tokenname();
693
694 if (isset($SESSION->{$name})) {
695 return $SESSION->{$name};
696 }
697
698 return null;
699 }
700
79d89136
FM
701 /**
702 * Get access token.
703 *
704 * This is just a getter to read the private property.
705 *
706 * @return string
707 */
708 public function get_accesstoken() {
709 return $this->accesstoken;
710 }
711
712 /**
713 * Get the client ID.
714 *
715 * This is just a getter to read the private property.
716 *
717 * @return string
718 */
719 public function get_clientid() {
720 return $this->clientid;
721 }
722
723 /**
724 * Get the client secret.
725 *
726 * This is just a getter to read the private property.
727 *
728 * @return string
729 */
730 public function get_clientsecret() {
731 return $this->clientsecret;
732 }
733
469fb5d6
DP
734 /**
735 * Should HTTP GET be used instead of POST?
736 * Some APIs do not support POST and want oauth to use
737 * GET instead (with the auth_token passed as a GET param).
738 *
739 * @return bool true if GET should be used
740 */
741 protected function use_http_get() {
742 return false;
743 }
744}