]> git.mxchange.org Git - friendica.git/blob - library/oauth2-php/lib/OAuth2Client.inc
Merge branch 'oauthapi'
[friendica.git] / library / oauth2-php / lib / OAuth2Client.inc
1 <?php
2
3 /**
4  * The default Cache Lifetime (in seconds).
5  */
6 define("OAUTH2_DEFAULT_EXPIRES_IN", 3600);
7
8 /**
9  * The default Base domain for the Cookie.
10  */
11 define("OAUTH2_DEFAULT_BASE_DOMAIN", '');
12
13 /**
14  * OAuth2.0 draft v10 client-side implementation.
15  *
16  * @author Originally written by Naitik Shah <naitik@facebook.com>.
17  * @author Update to draft v10 by Edison Wong <hswong3i@pantarei-design.com>.
18  *
19  * @sa <a href="https://github.com/facebook/php-sdk">Facebook PHP SDK</a>.
20  */
21 abstract class OAuth2Client {
22
23   /**
24    * Array of persistent variables stored.
25    */
26   protected $conf = array();
27
28   /**
29    * Returns a persistent variable.
30    *
31    * To avoid problems, always use lower case for persistent variable names.
32    *
33    * @param $name
34    *   The name of the variable to return.
35    * @param $default
36    *   The default value to use if this variable has never been set.
37    *
38    * @return
39    *   The value of the variable.
40    */
41   public function getVariable($name, $default = NULL) {
42     return isset($this->conf[$name]) ? $this->conf[$name] : $default;
43   }
44
45   /**
46    * Sets a persistent variable.
47    *
48    * To avoid problems, always use lower case for persistent variable names.
49    *
50    * @param $name
51    *   The name of the variable to set.
52    * @param $value
53    *   The value to set.
54    */
55   public function setVariable($name, $value) {
56     $this->conf[$name] = $value;
57     return $this;
58   }
59
60   // Stuff that should get overridden by subclasses.
61   //
62   // I don't want to make these abstract, because then subclasses would have
63   // to implement all of them, which is too much work.
64   //
65   // So they're just stubs. Override the ones you need.
66
67   /**
68    * Initialize a Drupal OAuth2.0 Application.
69    *
70    * @param $config
71    *   An associative array as below:
72    *   - base_uri: The base URI for the OAuth2.0 endpoints.
73    *   - code: (optional) The authorization code.
74    *   - username: (optional) The username.
75    *   - password: (optional) The password.
76    *   - client_id: (optional) The application ID.
77    *   - client_secret: (optional) The application secret.
78    *   - authorize_uri: (optional) The end-user authorization endpoint URI.
79    *   - access_token_uri: (optional) The token endpoint URI.
80    *   - services_uri: (optional) The services endpoint URI.
81    *   - cookie_support: (optional) TRUE to enable cookie support.
82    *   - base_domain: (optional) The domain for the cookie.
83    *   - file_upload_support: (optional) TRUE if file uploads are enabled.
84    */
85   public function __construct($config = array()) {
86     // We must set base_uri first.
87     $this->setVariable('base_uri', $config['base_uri']);
88     unset($config['base_uri']);
89
90     // Use predefined OAuth2.0 params, or get it from $_REQUEST.
91     foreach (array('code', 'username', 'password') as $name) {
92       if (isset($config[$name]))
93         $this->setVariable($name, $config[$name]);
94       else if (isset($_REQUEST[$name]) && !empty($_REQUEST[$name]))
95         $this->setVariable($name, $_REQUEST[$name]);
96       unset($config[$name]);
97     }
98
99     // Endpoint URIs.
100     foreach (array('authorize_uri', 'access_token_uri', 'services_uri') as $name) {
101       if (isset($config[$name]))
102         if (substr($config[$name], 0, 4) == "http")
103           $this->setVariable($name, $config[$name]);
104         else
105           $this->setVariable($name, $this->getVariable('base_uri') . $config[$name]);
106       unset($config[$name]);
107     }
108
109     // Other else configurations.
110     foreach ($config as $name => $value) {
111       $this->setVariable($name, $value);
112     }
113   }
114
115   /**
116    * Try to get session object from custom method.
117    *
118    * By default we generate session object based on access_token response, or
119    * if it is provided from server with $_REQUEST. For sure, if it is provided
120    * by server it should follow our session object format.
121    *
122    * Session object provided by server can ensure the correct expirse and
123    * base_domain setup as predefined in server, also you may get more useful
124    * information for custom functionality, too. BTW, this may require for
125    * additional remote call overhead.
126    *
127    * You may wish to override this function with your custom version due to
128    * your own server-side implementation.
129    *
130    * @param $access_token
131    *   (optional) A valid access token in associative array as below:
132    *   - access_token: A valid access_token generated by OAuth2.0
133    *     authorization endpoint.
134    *   - expires_in: (optional) A valid expires_in generated by OAuth2.0
135    *     authorization endpoint.
136    *   - refresh_token: (optional) A valid refresh_token generated by OAuth2.0
137    *     authorization endpoint.
138    *   - scope: (optional) A valid scope generated by OAuth2.0
139    *     authorization endpoint.
140    *
141    *  @return
142    *    A valid session object in associative array for setup cookie, and
143    *    NULL if not able to generate it with custom method.
144    */
145   protected function getSessionObject($access_token = NULL) {
146     $session = NULL;
147
148     // Try generate local version of session cookie.
149     if (!empty($access_token) && isset($access_token['access_token'])) {
150       $session['access_token'] = $access_token['access_token'];
151       $session['base_domain'] = $this->getVariable('base_domain', OAUTH2_DEFAULT_BASE_DOMAIN);
152       $session['expirse'] = isset($access_token['expires_in']) ? time() + $access_token['expires_in'] : time() + $this->getVariable('expires_in', OAUTH2_DEFAULT_EXPIRES_IN);
153       $session['refresh_token'] = isset($access_token['refresh_token']) ? $access_token['refresh_token'] : '';
154       $session['scope'] = isset($access_token['scope']) ? $access_token['scope'] : '';
155       $session['secret'] = md5(base64_encode(pack('N6', mt_rand(), mt_rand(), mt_rand(), mt_rand(), mt_rand(), uniqid())));
156
157       // Provide our own signature.
158       $sig = self::generateSignature(
159         $session,
160         $this->getVariable('client_secret')
161       );
162       $session['sig'] = $sig;
163     }
164
165     // Try loading session from $_REQUEST.
166     if (!$session && isset($_REQUEST['session'])) {
167       $session = json_decode(
168         get_magic_quotes_gpc()
169           ? stripslashes($_REQUEST['session'])
170           : $_REQUEST['session'],
171         TRUE
172       );
173     }
174
175     return $session;
176   }
177
178   /**
179    * Make an API call.
180    *
181    * Support both OAuth2.0 or normal GET/POST API call, with relative
182    * or absolute URI.
183    *
184    * If no valid OAuth2.0 access token found in session object, this function
185    * will automatically switch as normal remote API call without "oauth_token"
186    * parameter.
187    *
188    * Assume server reply in JSON object and always decode during return. If
189    * you hope to issue a raw query, please use makeRequest().
190    *
191    * @param $path
192    *   The target path, relative to base_path/service_uri or an absolute URI.
193    * @param $method
194    *   (optional) The HTTP method (default 'GET').
195    * @param $params
196    *   (optional The GET/POST parameters.
197    *
198    * @return
199    *   The JSON decoded response object.
200    *
201    * @throws OAuth2Exception
202    */
203   public function api($path, $method = 'GET', $params = array()) {
204     if (is_array($method) && empty($params)) {
205       $params = $method;
206       $method = 'GET';
207     }
208
209     // json_encode all params values that are not strings.
210     foreach ($params as $key => $value) {
211       if (!is_string($value)) {
212         $params[$key] = json_encode($value);
213       }
214     }
215
216     $result = json_decode($this->makeOAuth2Request(
217       $this->getUri($path),
218       $method,
219       $params
220     ), TRUE);
221
222     // Results are returned, errors are thrown.
223     if (is_array($result) && isset($result['error'])) {
224       $e = new OAuth2Exception($result);
225       switch ($e->getType()) {
226         // OAuth 2.0 Draft 10 style.
227         case 'invalid_token':
228           $this->setSession(NULL);
229         default:
230           $this->setSession(NULL);
231       }
232       throw $e;
233     }
234     return $result;
235   }
236
237   // End stuff that should get overridden.
238
239   /**
240    * Default options for cURL.
241    */
242   public static $CURL_OPTS = array(
243     CURLOPT_CONNECTTIMEOUT => 10,
244     CURLOPT_RETURNTRANSFER => TRUE,
245     CURLOPT_HEADER         => TRUE,
246     CURLOPT_TIMEOUT        => 60,
247     CURLOPT_USERAGENT      => 'oauth2-draft-v10',
248     CURLOPT_HTTPHEADER     => array("Accept: application/json"),
249   );
250
251   /**
252    * Set the Session.
253    *
254    * @param $session
255    *   (optional) The session object to be set. NULL if hope to frush existing
256    *   session object.
257    * @param $write_cookie
258    *   (optional) TRUE if a cookie should be written. This value is ignored
259    *   if cookie support has been disabled.
260    *
261    * @return
262    *   The current OAuth2.0 client-side instance.
263    */
264   public function setSession($session = NULL, $write_cookie = TRUE) {
265     $this->setVariable('_session', $this->validateSessionObject($session));
266     $this->setVariable('_session_loaded', TRUE);
267     if ($write_cookie) {
268       $this->setCookieFromSession($this->getVariable('_session'));
269     }
270     return $this;
271   }
272
273   /**
274    * Get the session object.
275    *
276    * This will automatically look for a signed session via custom method,
277    * OAuth2.0 grant type with authorization_code, OAuth2.0 grant type with
278    * password, or cookie that we had already setup.
279    *
280    * @return
281    *   The valid session object with OAuth2.0 infomration, and NULL if not
282    *   able to discover any cases.
283    */
284   public function getSession() {
285     if (!$this->getVariable('_session_loaded')) {
286       $session = NULL;
287       $write_cookie = TRUE;
288
289       // Try obtain login session by custom method.
290       $session = $this->getSessionObject(NULL);
291       $session = $this->validateSessionObject($session);
292
293       // grant_type == authorization_code.
294       if (!$session && $this->getVariable('code')) {
295         $access_token = $this->getAccessTokenFromAuthorizationCode($this->getVariable('code'));
296         $session = $this->getSessionObject($access_token);
297         $session = $this->validateSessionObject($session);
298       }
299
300       // grant_type == password.
301       if (!$session && $this->getVariable('username') && $this->getVariable('password')) {
302         $access_token = $this->getAccessTokenFromPassword($this->getVariable('username'), $this->getVariable('password'));
303         $session = $this->getSessionObject($access_token);
304         $session = $this->validateSessionObject($session);
305       }
306
307       // Try loading session from cookie if necessary.
308       if (!$session && $this->getVariable('cookie_support')) {
309         $cookie_name = $this->getSessionCookieName();
310         if (isset($_COOKIE[$cookie_name])) {
311           $session = array();
312           parse_str(trim(
313             get_magic_quotes_gpc()
314               ? stripslashes($_COOKIE[$cookie_name])
315               : $_COOKIE[$cookie_name],
316             '"'
317           ), $session);
318           $session = $this->validateSessionObject($session);
319           // Write only if we need to delete a invalid session cookie.
320           $write_cookie = empty($session);
321         }
322       }
323
324       $this->setSession($session, $write_cookie);
325     }
326
327     return $this->getVariable('_session');
328   }
329
330   /**
331    * Gets an OAuth2.0 access token from session.
332    *
333    * This will trigger getSession() and so we MUST initialize with required
334    * configuration.
335    *
336    * @return
337    *   The valid OAuth2.0 access token, and NULL if not exists in session.
338    */
339   public function getAccessToken() {
340     $session = $this->getSession();
341     return isset($session['access_token']) ? $session['access_token'] : NULL;
342   }
343
344   /**
345    * Get access token from OAuth2.0 token endpoint with authorization code.
346    *
347    * This function will only be activated if both access token URI, client
348    * identifier and client secret are setup correctly.
349    *
350    * @param $code
351    *   Authorization code issued by authorization server's authorization
352    *   endpoint.
353    *
354    * @return
355    *   A valid OAuth2.0 JSON decoded access token in associative array, and
356    *   NULL if not enough parameters or JSON decode failed.
357    */
358   private function getAccessTokenFromAuthorizationCode($code) {
359     if ($this->getVariable('access_token_uri') && $this->getVariable('client_id') && $this->getVariable('client_secret')) {
360       return json_decode($this->makeRequest(
361         $this->getVariable('access_token_uri'),
362         'POST',
363         array(
364           'grant_type' => 'authorization_code',
365           'client_id' => $this->getVariable('client_id'),
366           'client_secret' => $this->getVariable('client_secret'),
367           'code' => $code,
368           'redirect_uri' => $this->getCurrentUri(),
369         )
370       ), TRUE);
371     }
372     return NULL;
373   }
374
375   /**
376    * Get access token from OAuth2.0 token endpoint with basic user
377    * credentials.
378    *
379    * This function will only be activated if both username and password
380    * are setup correctly.
381    *
382    * @param $username
383    *   Username to be check with.
384    * @param $password
385    *   Password to be check with.
386    *
387    * @return
388    *   A valid OAuth2.0 JSON decoded access token in associative array, and
389    *   NULL if not enough parameters or JSON decode failed.
390    */
391   private function getAccessTokenFromPassword($username, $password) {
392     if ($this->getVariable('access_token_uri') && $this->getVariable('client_id') && $this->getVariable('client_secret')) {
393       return json_decode($this->makeRequest(
394         $this->getVariable('access_token_uri'),
395         'POST',
396         array(
397           'grant_type' => 'password',
398           'client_id' => $this->getVariable('client_id'),
399           'client_secret' => $this->getVariable('client_secret'),
400           'username' => $username,
401           'password' => $password,
402         )
403       ), TRUE);
404     }
405     return NULL;
406   }
407
408   /**
409    * Make an OAuth2.0 Request.
410    *
411    * Automatically append "oauth_token" in query parameters if not yet
412    * exists and able to discover a valid access token from session. Otherwise
413    * just ignore setup with "oauth_token" and handle the API call AS-IS, and
414    * so may issue a plain API call without OAuth2.0 protection.
415    *
416    * @param $path
417    *   The target path, relative to base_path/service_uri or an absolute URI.
418    * @param $method
419    *   (optional) The HTTP method (default 'GET').
420    * @param $params
421    *   (optional The GET/POST parameters.
422    *
423    * @return
424    *   The JSON decoded response object.
425    *
426    * @throws OAuth2Exception
427    */
428   protected function makeOAuth2Request($path, $method = 'GET', $params = array()) {
429     if ((!isset($params['oauth_token']) || empty($params['oauth_token'])) && $oauth_token = $this->getAccessToken()) {
430       $params['oauth_token'] = $oauth_token;
431     }
432     return $this->makeRequest($path, $method, $params);
433   }
434
435   /**
436    * Makes an HTTP request.
437    *
438    * This method can be overriden by subclasses if developers want to do
439    * fancier things or use something other than cURL to make the request.
440    *
441    * @param $path
442    *   The target path, relative to base_path/service_uri or an absolute URI.
443    * @param $method
444    *   (optional) The HTTP method (default 'GET').
445    * @param $params
446    *   (optional The GET/POST parameters.
447    * @param $ch
448    *   (optional) An initialized curl handle
449    *
450    * @return
451    *   The JSON decoded response object.
452    */
453   protected function makeRequest($path, $method = 'GET', $params = array(), $ch = NULL) {
454     if (!$ch)
455       $ch = curl_init();
456
457     $opts = self::$CURL_OPTS;
458     if ($params) {
459       switch ($method) {
460         case 'GET':
461           $path .= '?' . http_build_query($params, NULL, '&');
462           break;
463         // Method override as we always do a POST.
464         default:
465           if ($this->getVariable('file_upload_support')) {
466             $opts[CURLOPT_POSTFIELDS] = $params;
467           }
468           else {
469             $opts[CURLOPT_POSTFIELDS] = http_build_query($params, NULL, '&');
470           }
471       }
472     }
473     $opts[CURLOPT_URL] = $path;
474
475     // Disable the 'Expect: 100-continue' behaviour. This causes CURL to wait
476     // for 2 seconds if the server does not support this header.
477     if (isset($opts[CURLOPT_HTTPHEADER])) {
478       $existing_headers = $opts[CURLOPT_HTTPHEADER];
479       $existing_headers[] = 'Expect:';
480       $opts[CURLOPT_HTTPHEADER] = $existing_headers;
481     }
482     else {
483       $opts[CURLOPT_HTTPHEADER] = array('Expect:');
484     }
485
486     curl_setopt_array($ch, $opts);
487     $result = curl_exec($ch);
488
489     if (curl_errno($ch) == 60) { // CURLE_SSL_CACERT
490       error_log('Invalid or no certificate authority found, using bundled information');
491       curl_setopt($ch, CURLOPT_CAINFO,
492                   dirname(__FILE__) . '/fb_ca_chain_bundle.crt');
493       $result = curl_exec($ch);
494     }
495
496     if ($result === FALSE) {
497       $e = new OAuth2Exception(array(
498         'code' => curl_errno($ch),
499         'message' => curl_error($ch),
500       ));
501       curl_close($ch);
502       throw $e;
503     }
504     curl_close($ch);
505
506     // Split the HTTP response into header and body.
507     list($headers, $body) = explode("\r\n\r\n", $result);
508     $headers = explode("\r\n", $headers);
509
510     // We catch HTTP/1.1 4xx or HTTP/1.1 5xx error response.
511     if (strpos($headers[0], 'HTTP/1.1 4') !== FALSE || strpos($headers[0], 'HTTP/1.1 5') !== FALSE) {
512       $result = array(
513         'code' => 0,
514         'message' => '',
515       );
516
517       if (preg_match('/^HTTP\/1.1 ([0-9]{3,3}) (.*)$/', $headers[0], $matches)) {
518         $result['code'] = $matches[1];
519         $result['message'] = $matches[2];
520       }
521
522       // In case retrun with WWW-Authenticate replace the description.
523       foreach ($headers as $header) {
524         if (preg_match("/^WWW-Authenticate:.*error='(.*)'/", $header, $matches)) {
525           $result['error'] = $matches[1];
526         }
527       }
528
529       return json_encode($result);
530     }
531
532     return $body;
533   }
534
535   /**
536    * The name of the cookie that contains the session object.
537    *
538    * @return
539    *   The cookie name.
540    */
541   private function getSessionCookieName() {
542     return 'oauth2_' . $this->getVariable('client_id');
543   }
544
545   /**
546    * Set a JS Cookie based on the _passed in_ session.
547    *
548    * It does not use the currently stored session - you need to explicitly
549    * pass it in.
550    *
551    * @param $session
552    *   The session to use for setting the cookie.
553    */
554   protected function setCookieFromSession($session = NULL) {
555     if (!$this->getVariable('cookie_support'))
556       return;
557
558     $cookie_name = $this->getSessionCookieName();
559     $value = 'deleted';
560     $expires = time() - 3600;
561     $base_domain = $this->getVariable('base_domain', OAUTH2_DEFAULT_BASE_DOMAIN);
562     if ($session) {
563       $value = '"' . http_build_query($session, NULL, '&') . '"';
564       $base_domain = isset($session['base_domain']) ? $session['base_domain'] : $base_domain;
565       $expires = isset($session['expires']) ? $session['expires'] : time() + $this->getVariable('expires_in', OAUTH2_DEFAULT_EXPIRES_IN);
566     }
567
568     // Prepend dot if a domain is found.
569     if ($base_domain)
570       $base_domain = '.' . $base_domain;
571
572     // If an existing cookie is not set, we dont need to delete it.
573     if ($value == 'deleted' && empty($_COOKIE[$cookie_name]))
574       return;
575
576     if (headers_sent())
577       error_log('Could not set cookie. Headers already sent.');
578     else
579       setcookie($cookie_name, $value, $expires, '/', $base_domain);
580   }
581
582   /**
583    * Validates a session_version = 3 style session object.
584    *
585    * @param $session
586    *   The session object.
587    *
588    * @return
589    *   The session object if it validates, NULL otherwise.
590    */
591   protected function validateSessionObject($session) {
592     // Make sure some essential fields exist.
593     if (is_array($session) && isset($session['access_token']) && isset($session['sig'])) {
594       // Validate the signature.
595       $session_without_sig = $session;
596       unset($session_without_sig['sig']);
597
598       $expected_sig = self::generateSignature(
599         $session_without_sig,
600         $this->getVariable('client_secret')
601       );
602
603       if ($session['sig'] != $expected_sig) {
604         error_log('Got invalid session signature in cookie.');
605         $session = NULL;
606       }
607     }
608     else {
609       $session = NULL;
610     }
611     return $session;
612   }
613
614   /**
615    * Since $_SERVER['REQUEST_URI'] is only available on Apache, we
616    * generate an equivalent using other environment variables.
617    */
618   function getRequestUri() {
619     if (isset($_SERVER['REQUEST_URI'])) {
620       $uri = $_SERVER['REQUEST_URI'];
621     }
622     else {
623       if (isset($_SERVER['argv'])) {
624         $uri = $_SERVER['SCRIPT_NAME'] . '?' . $_SERVER['argv'][0];
625       }
626       elseif (isset($_SERVER['QUERY_STRING'])) {
627         $uri = $_SERVER['SCRIPT_NAME'] . '?' . $_SERVER['QUERY_STRING'];
628       }
629       else {
630         $uri = $_SERVER['SCRIPT_NAME'];
631       }
632     }
633     // Prevent multiple slashes to avoid cross site requests via the Form API.
634     $uri = '/' . ltrim($uri, '/');
635
636     return $uri;
637   }
638
639   /**
640    * Returns the Current URL.
641    *
642    * @return
643    *   The current URL.
644    */
645   protected function getCurrentUri() {
646     $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on'
647       ? 'https://'
648       : 'http://';
649     $current_uri = $protocol . $_SERVER['HTTP_HOST'] . $this->getRequestUri();
650     $parts = parse_url($current_uri);
651
652     $query = '';
653     if (!empty($parts['query'])) {
654       $params = array();
655       parse_str($parts['query'], $params);
656       $params = array_filter($params);
657       if (!empty($params)) {
658         $query = '?' . http_build_query($params, NULL, '&');
659       }
660     }
661
662     // Use port if non default.
663     $port = isset($parts['port']) &&
664       (($protocol === 'http://' && $parts['port'] !== 80) || ($protocol === 'https://' && $parts['port'] !== 443))
665       ? ':' . $parts['port'] : '';
666
667     // Rebuild.
668     return $protocol . $parts['host'] . $port . $parts['path'] . $query;
669   }
670
671   /**
672    * Build the URL for given path and parameters.
673    *
674    * @param $path
675    *   (optional) The path.
676    * @param $params
677    *   (optional) The query parameters in associative array.
678    *
679    * @return
680    *   The URL for the given parameters.
681    */
682   protected function getUri($path = '', $params = array()) {
683     $url = $this->getVariable('services_uri') ? $this->getVariable('services_uri') : $this->getVariable('base_uri');
684
685     if (!empty($path))
686       if (substr($path, 0, 4) == "http")
687         $url = $path;
688       else
689         $url = rtrim($url, '/') . '/' . ltrim($path, '/');
690
691     if (!empty($params))
692       $url .= '?' . http_build_query($params, NULL, '&');
693
694     return $url;
695   }
696
697   /**
698    * Generate a signature for the given params and secret.
699    *
700    * @param $params
701    *   The parameters to sign.
702    * @param $secret
703    *   The secret to sign with.
704    *
705    * @return
706    *   The generated signature
707    */
708   protected function generateSignature($params, $secret) {
709     // Work with sorted data.
710     ksort($params);
711
712     // Generate the base string.
713     $base_string = '';
714     foreach ($params as $key => $value) {
715       $base_string .= $key . '=' . $value;
716     }
717     $base_string .= $secret;
718
719     return md5($base_string);
720   }
721 }