* @since 2.0 */ abstract class BaseOAuth extends BaseClient implements ClientInterface { const CONTENT_TYPE_JSON = 'json'; // JSON format const CONTENT_TYPE_URLENCODED = 'urlencoded'; // urlencoded query string, like name1=value1&name2=value2 const CONTENT_TYPE_XML = 'xml'; // XML format const CONTENT_TYPE_AUTO = 'auto'; // attempts to determine format automatically /** * @var string protocol version. */ public $version = '1.0'; /** * @var string URL, which user will be redirected after authentication at the OAuth provider web site. * Note: this should be absolute URL (with http:// or https:// leading). * By default current URL will be used. */ private $_returnUrl; /** * @var string API base URL. */ public $apiBaseUrl; /** * @var string authorize URL. */ public $authUrl; /** * @var string auth request scope. */ public $scope; /** * @var array cURL request options. Option values from this field will overwrite corresponding * values from [[defaultCurlOptions()]]. */ private $_curlOptions = []; /** * @var OAuthToken|array access token instance or its array configuration. */ private $_accessToken; /** * @var signature\BaseMethod|array signature method instance or its array configuration. */ private $_signatureMethod = []; /** * @param string $returnUrl return URL */ public function setReturnUrl($returnUrl) { $this->_returnUrl = $returnUrl; } /** * @return string return URL. */ public function getReturnUrl() { if ($this->_returnUrl === null) { $this->_returnUrl = $this->defaultReturnUrl(); } return $this->_returnUrl; } /** * @param array $curlOptions cURL options. */ public function setCurlOptions(array $curlOptions) { $this->_curlOptions = $curlOptions; } /** * @return array cURL options. */ public function getCurlOptions() { return $this->_curlOptions; } /** * @param array|OAuthToken $token */ public function setAccessToken($token) { if (!is_object($token)) { $token = $this->createToken($token); } $this->_accessToken = $token; $this->saveAccessToken($token); } /** * @return OAuthToken auth token instance. */ public function getAccessToken() { if (!is_object($this->_accessToken)) { $this->_accessToken = $this->restoreAccessToken(); } return $this->_accessToken; } /** * @param array|signature\BaseMethod $signatureMethod signature method instance or its array configuration. * @throws InvalidParamException on wrong argument. */ public function setSignatureMethod($signatureMethod) { if (!is_object($signatureMethod) && !is_array($signatureMethod)) { throw new InvalidParamException('"' . get_class($this) . '::signatureMethod" should be instance of "\yii\autclient\signature\BaseMethod" or its array configuration. "' . gettype($signatureMethod) . '" has been given.'); } $this->_signatureMethod = $signatureMethod; } /** * @return signature\BaseMethod signature method instance. */ public function getSignatureMethod() { if (!is_object($this->_signatureMethod)) { $this->_signatureMethod = $this->createSignatureMethod($this->_signatureMethod); } return $this->_signatureMethod; } /** * Composes default [[returnUrl]] value. * @return string return URL. */ protected function defaultReturnUrl() { return Yii::$app->getRequest()->getAbsoluteUrl(); } /** * Sends HTTP request. * @param string $method request type. * @param string $url request URL. * @param array $params request params. * @param array $headers additional request headers. * @return array response. * @throws Exception on failure. */ protected function sendRequest($method, $url, array $params = [], array $headers = []) { $curlOptions = $this->mergeCurlOptions( $this->defaultCurlOptions(), $this->getCurlOptions(), [ CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_URL => $url, ], $this->composeRequestCurlOptions(strtoupper($method), $url, $params) ); $curlResource = curl_init(); foreach ($curlOptions as $option => $value) { curl_setopt($curlResource, $option, $value); } $response = curl_exec($curlResource); $responseHeaders = curl_getinfo($curlResource); // check cURL error $errorNumber = curl_errno($curlResource); $errorMessage = curl_error($curlResource); curl_close($curlResource); if ($errorNumber > 0) { throw new Exception('Curl error requesting "' . $url . '": #' . $errorNumber . ' - ' . $errorMessage); } if ($responseHeaders['http_code'] != 200) { throw new InvalidResponseException($responseHeaders, $response, 'Request failed with code: ' . $responseHeaders['http_code'] . ', message: ' . $response); } return $this->processResponse($response, $this->determineContentTypeByHeaders($responseHeaders)); } /** * Merge CUrl options. * If each options array has an element with the same key value, the latter * will overwrite the former. * @param array $options1 options to be merged to. * @param array $options2 options to be merged from. You can specify additional * arrays via third argument, fourth argument etc. * @return array merged options (the original options are not changed.) */ protected function mergeCurlOptions($options1, $options2) { $args = func_get_args(); $res = array_shift($args); while (!empty($args)) { $next = array_shift($args); foreach ($next as $k => $v) { if (is_array($v) && !empty($res[$k]) && is_array($res[$k])) { $res[$k] = array_merge($res[$k], $v); } else { $res[$k] = $v; } } } return $res; } /** * Returns default cURL options. * @return array cURL options. */ protected function defaultCurlOptions() { return [ CURLOPT_USERAGENT => Yii::$app->name . ' OAuth ' . $this->version . ' Client', CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false, ]; } /** * Processes raw response converting it to actual data. * @param string $rawResponse raw response. * @param string $contentType response content type. * @throws Exception on failure. * @return array actual response. */ protected function processResponse($rawResponse, $contentType = self::CONTENT_TYPE_AUTO) { if (empty($rawResponse)) { return []; } switch ($contentType) { case self::CONTENT_TYPE_AUTO: { $contentType = $this->determineContentTypeByRaw($rawResponse); if ($contentType == self::CONTENT_TYPE_AUTO) { throw new Exception('Unable to determine response content type automatically.'); } $response = $this->processResponse($rawResponse, $contentType); break; } case self::CONTENT_TYPE_JSON: { $response = Json::decode($rawResponse, true); if (isset($response['error'])) { throw new Exception('Response error: ' . $response['error']); } break; } case self::CONTENT_TYPE_URLENCODED: { $response = []; parse_str($rawResponse, $response); break; } case self::CONTENT_TYPE_XML: { $response = $this->convertXmlToArray($rawResponse); break; } default: { throw new Exception('Unknown response type "' . $contentType . '".'); } } return $response; } /** * Converts XML document to array. * @param string|\SimpleXMLElement $xml xml to process. * @return array XML array representation. */ protected function convertXmlToArray($xml) { if (!is_object($xml)) { $xml = simplexml_load_string($xml); } $result = (array) $xml; foreach ($result as $key => $value) { if (is_object($value)) { $result[$key] = $this->convertXmlToArray($value); } } return $result; } /** * Attempts to determine HTTP request content type by headers. * @param array $headers request headers. * @return string content type. */ protected function determineContentTypeByHeaders(array $headers) { if (isset($headers['content_type'])) { if (stripos($headers['content_type'], 'json') !== false) { return self::CONTENT_TYPE_JSON; } if (stripos($headers['content_type'], 'urlencoded') !== false) { return self::CONTENT_TYPE_URLENCODED; } if (stripos($headers['content_type'], 'xml') !== false) { return self::CONTENT_TYPE_XML; } } return self::CONTENT_TYPE_AUTO; } /** * Attempts to determine the content type from raw content. * @param string $rawContent raw response content. * @return string response type. */ protected function determineContentTypeByRaw($rawContent) { if (preg_match('/^\\{.*\\}$/is', $rawContent)) { return self::CONTENT_TYPE_JSON; } if (preg_match('/^[^=|^&]+=[^=|^&]+(&[^=|^&]+=[^=|^&]+)*$/is', $rawContent)) { return self::CONTENT_TYPE_URLENCODED; } if (preg_match('/^<.*>$/is', $rawContent)) { return self::CONTENT_TYPE_XML; } return self::CONTENT_TYPE_AUTO; } /** * Creates signature method instance from its configuration. * @param array $signatureMethodConfig signature method configuration. * @return signature\BaseMethod signature method instance. */ protected function createSignatureMethod(array $signatureMethodConfig) { if (!array_key_exists('class', $signatureMethodConfig)) { $signatureMethodConfig['class'] = signature\HmacSha1::className(); } return Yii::createObject($signatureMethodConfig); } /** * Creates token from its configuration. * @param array $tokenConfig token configuration. * @return OAuthToken token instance. */ protected function createToken(array $tokenConfig = []) { if (!array_key_exists('class', $tokenConfig)) { $tokenConfig['class'] = OAuthToken::className(); } return Yii::createObject($tokenConfig); } /** * Composes URL from base URL and GET params. * @param string $url base URL. * @param array $params GET params. * @return string composed URL. */ protected function composeUrl($url, array $params = []) { if (strpos($url, '?') === false) { $url .= '?'; } else { $url .= '&'; } $url .= http_build_query($params, '', '&', PHP_QUERY_RFC3986); return $url; } /** * Saves token as persistent state. * @param OAuthToken $token auth token * @return static self reference. */ protected function saveAccessToken(OAuthToken $token) { return $this->setState('token', $token); } /** * Restores access token. * @return OAuthToken auth token. */ protected function restoreAccessToken() { $token = $this->getState('token'); if (is_object($token)) { /* @var $token OAuthToken */ if ($token->getIsExpired()) { $token = $this->refreshAccessToken($token); } } return $token; } /** * Sets persistent state. * @param string $key state key. * @param mixed $value state value * @return static self reference. */ protected function setState($key, $value) { $session = Yii::$app->getSession(); $key = $this->getStateKeyPrefix() . $key; $session->set($key, $value); return $this; } /** * Returns persistent state value. * @param string $key state key. * @return mixed state value. */ protected function getState($key) { $session = Yii::$app->getSession(); $key = $this->getStateKeyPrefix() . $key; $value = $session->get($key); return $value; } /** * Removes persistent state value. * @param string $key state key. * @return boolean success. */ protected function removeState($key) { $session = Yii::$app->getSession(); $key = $this->getStateKeyPrefix() . $key; $session->remove($key); return true; } /** * Returns session key prefix, which is used to store internal states. * @return string session key prefix. */ protected function getStateKeyPrefix() { return get_class($this) . '_' . sha1($this->authUrl) . '_'; } /** * Performs request to the OAuth API. * @param string $apiSubUrl API sub URL, which will be append to [[apiBaseUrl]], or absolute API URL. * @param string $method request method. * @param array $params request parameters. * @param array $headers additional request headers. * @return array API response * @throws Exception on failure. */ public function api($apiSubUrl, $method = 'GET', array $params = [], array $headers = []) { if (preg_match('/^https?:\\/\\//is', $apiSubUrl)) { $url = $apiSubUrl; } else { $url = $this->apiBaseUrl . '/' . $apiSubUrl; } $accessToken = $this->getAccessToken(); if (!is_object($accessToken) || !$accessToken->getIsValid()) { throw new Exception('Invalid access token.'); } return $this->apiInternal($accessToken, $url, $method, $params, $headers); } /** * Composes HTTP request CUrl options, which will be merged with the default ones. * @param string $method request type. * @param string $url request URL. * @param array $params request params. * @return array CUrl options. * @throws Exception on failure. */ abstract protected function composeRequestCurlOptions($method, $url, array $params); /** * Gets new auth token to replace expired one. * @param OAuthToken $token expired auth token. * @return OAuthToken new auth token. */ abstract public function refreshAccessToken(OAuthToken $token); /** * Performs request to the OAuth API. * @param OAuthToken $accessToken actual access token. * @param string $url absolute API URL. * @param string $method request method. * @param array $params request parameters. * @param array $headers additional request headers. * @return array API response. * @throws Exception on failure. */ abstract protected function apiInternal($accessToken, $url, $method, array $params, array $headers); }