Changeset View
Changeset View
Standalone View
Standalone View
src/conduit/ConduitClient.php
- This file was added.
| <?php | |||||
| final class ConduitClient extends Phobject { | |||||
| private $uri; | |||||
| private $host; | |||||
| private $connectionID; | |||||
| private $sessionKey; | |||||
| private $timeout = 300.0; | |||||
| private $username; | |||||
| private $password; | |||||
| private $publicKey; | |||||
| private $privateKey; | |||||
| private $conduitToken; | |||||
| private $oauthToken; | |||||
| const AUTH_ASYMMETRIC = 'asymmetric'; | |||||
| const SIGNATURE_CONSIGN_1 = 'Consign1.0/'; | |||||
| public function getConnectionID() { | |||||
| return $this->connectionID; | |||||
| } | |||||
| public function __construct($uri) { | |||||
| $this->uri = new PhutilURI($uri); | |||||
| if (!strlen($this->uri->getDomain())) { | |||||
| throw new Exception( | |||||
| pht("Conduit URI '%s' must include a valid host.", $uri)); | |||||
| } | |||||
| $this->host = $this->uri->getDomain(); | |||||
| } | |||||
| /** | |||||
| * Override the domain specified in the service URI and provide a specific | |||||
| * host identity. | |||||
| * | |||||
| * This can be used to connect to a specific node in a cluster environment. | |||||
| */ | |||||
| public function setHost($host) { | |||||
| $this->host = $host; | |||||
| return $this; | |||||
| } | |||||
| public function getHost() { | |||||
| return $this->host; | |||||
| } | |||||
| public function setConduitToken($conduit_token) { | |||||
| $this->conduitToken = $conduit_token; | |||||
| return $this; | |||||
| } | |||||
| public function getConduitToken() { | |||||
| return $this->conduitToken; | |||||
| } | |||||
| public function setOAuthToken($oauth_token) { | |||||
| $this->oauthToken = $oauth_token; | |||||
| return $this; | |||||
| } | |||||
| public function callMethodSynchronous($method, array $params) { | |||||
| return $this->callMethod($method, $params)->resolve(); | |||||
| } | |||||
| public function didReceiveResponse($method, $data) { | |||||
| if ($method == 'conduit.connect') { | |||||
| $this->sessionKey = idx($data, 'sessionKey'); | |||||
| $this->connectionID = idx($data, 'connectionID'); | |||||
| } | |||||
| return $data; | |||||
| } | |||||
| public function setTimeout($timeout) { | |||||
| $this->timeout = $timeout; | |||||
| return $this; | |||||
| } | |||||
| public function setSigningKeys( | |||||
| $public_key, | |||||
| PhutilOpaqueEnvelope $private_key) { | |||||
| $this->publicKey = $public_key; | |||||
| $this->privateKey = $private_key; | |||||
| return $this; | |||||
| } | |||||
| public function callMethod($method, array $params) { | |||||
| $meta = array(); | |||||
| if ($this->sessionKey) { | |||||
| $meta['sessionKey'] = $this->sessionKey; | |||||
| } | |||||
| if ($this->connectionID) { | |||||
| $meta['connectionID'] = $this->connectionID; | |||||
| } | |||||
| if ($method == 'conduit.connect') { | |||||
| $certificate = idx($params, 'certificate'); | |||||
| if ($certificate) { | |||||
| $token = time(); | |||||
| $params['authToken'] = $token; | |||||
| $params['authSignature'] = sha1($token.$certificate); | |||||
| } | |||||
| unset($params['certificate']); | |||||
| } | |||||
| if ($this->privateKey && $this->publicKey) { | |||||
| $meta['auth.type'] = self::AUTH_ASYMMETRIC; | |||||
| $meta['auth.key'] = $this->publicKey; | |||||
| $meta['auth.host'] = $this->getHostStringForSignature(); | |||||
| $signature = $this->signRequest($method, $params, $meta); | |||||
| $meta['auth.signature'] = $signature; | |||||
| } | |||||
| if ($this->conduitToken) { | |||||
| $meta['token'] = $this->conduitToken; | |||||
| } | |||||
| if ($this->oauthToken) { | |||||
| $meta['access_token'] = $this->oauthToken; | |||||
| } | |||||
| if ($meta) { | |||||
| $params['__conduit__'] = $meta; | |||||
| } | |||||
| $uri = id(clone $this->uri)->setPath('/api/'.$method); | |||||
| $data = array( | |||||
| 'params' => json_encode($params), | |||||
| 'output' => 'json', | |||||
| // This is a hint to Phabricator that the client expects a Conduit | |||||
| // response. It is not necessary, but provides better error messages in | |||||
| // some cases. | |||||
| '__conduit__' => true, | |||||
| ); | |||||
| // Always use the cURL-based HTTPSFuture, for proxy support and other | |||||
| // protocol edge cases that HTTPFuture does not support. | |||||
| $core_future = new HTTPSFuture($uri, $data); | |||||
| $core_future->addHeader('Host', $this->getHostStringForHeader()); | |||||
| $core_future->setMethod('POST'); | |||||
| $core_future->setTimeout($this->timeout); | |||||
| if ($this->username !== null) { | |||||
| $core_future->setHTTPBasicAuthCredentials( | |||||
| $this->username, | |||||
| $this->password); | |||||
| } | |||||
| return id(new ConduitFuture($core_future)) | |||||
| ->setClient($this, $method); | |||||
| } | |||||
| public function setBasicAuthCredentials($username, $password) { | |||||
| $this->username = $username; | |||||
| $this->password = new PhutilOpaqueEnvelope($password); | |||||
| return $this; | |||||
| } | |||||
| private function getHostStringForHeader() { | |||||
| return $this->newHostString(false); | |||||
| } | |||||
| private function getHostStringForSignature() { | |||||
| return $this->newHostString(true); | |||||
| } | |||||
| /** | |||||
| * Build a string describing the host for this request. | |||||
| * | |||||
| * This method builds strings in two modes: with explicit ports for request | |||||
| * signing (which always include the port number) and with implicit ports | |||||
| * for use in the "Host:" header of requests (which omit the port number if | |||||
| * the port is the same as the default port for the protocol). | |||||
| * | |||||
| * This implicit port behavior is similar to what browsers do, so it is less | |||||
| * likely to get us into trouble with webserver configurations. | |||||
| * | |||||
| * @param bool True to include the port explicitly. | |||||
| * @return string String describing the host for the request. | |||||
| */ | |||||
| private function newHostString($with_explicit_port) { | |||||
| $host = $this->getHost(); | |||||
| $uri = new PhutilURI($this->uri); | |||||
| $protocol = $uri->getProtocol(); | |||||
| $port = $uri->getPort(); | |||||
| $implicit_ports = array( | |||||
| 'https' => 443, | |||||
| ); | |||||
| $default_port = 80; | |||||
| $implicit_port = idx($implicit_ports, $protocol, $default_port); | |||||
| if ($with_explicit_port) { | |||||
| if (!$port) { | |||||
| $port = $implicit_port; | |||||
| } | |||||
| } else { | |||||
| if ($port == $implicit_port) { | |||||
| $port = null; | |||||
| } | |||||
| } | |||||
| if (!$port) { | |||||
| $result = $host; | |||||
| } else { | |||||
| $result = $host.':'.$port; | |||||
| } | |||||
| return $result; | |||||
| } | |||||
| private function signRequest( | |||||
| $method, | |||||
| array $params, | |||||
| array $meta) { | |||||
| $input = self::encodeRequestDataForSignature( | |||||
| $method, | |||||
| $params, | |||||
| $meta); | |||||
| $signature = null; | |||||
| $result = openssl_sign( | |||||
| $input, | |||||
| $signature, | |||||
| $this->privateKey->openEnvelope()); | |||||
| if (!$result) { | |||||
| throw new Exception( | |||||
| pht('Unable to sign Conduit request with signing key.')); | |||||
| } | |||||
| return self::SIGNATURE_CONSIGN_1.base64_encode($signature); | |||||
| } | |||||
| public static function verifySignature( | |||||
| $method, | |||||
| array $params, | |||||
| array $meta, | |||||
| $openssl_public_key) { | |||||
| $auth_type = idx($meta, 'auth.type'); | |||||
| switch ($auth_type) { | |||||
| case self::AUTH_ASYMMETRIC: | |||||
| break; | |||||
| default: | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Unable to verify request signature, specified "%s" '. | |||||
| '("%s") is unknown.', | |||||
| 'auth.type', | |||||
| $auth_type)); | |||||
| } | |||||
| $public_key = idx($meta, 'auth.key'); | |||||
| if (!strlen($public_key)) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Unable to verify request signature, no "%s" present in '. | |||||
| 'request protocol information.', | |||||
| 'auth.key')); | |||||
| } | |||||
| $signature = idx($meta, 'auth.signature'); | |||||
| if (!strlen($signature)) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Unable to verify request signature, no "%s" present '. | |||||
| 'in request protocol information.', | |||||
| 'auth.signature')); | |||||
| } | |||||
| $prefix = self::SIGNATURE_CONSIGN_1; | |||||
| if (strncmp($signature, $prefix, strlen($prefix)) !== 0) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Unable to verify request signature, signature format is not '. | |||||
| 'known.')); | |||||
| } | |||||
| $signature = substr($signature, strlen($prefix)); | |||||
| $input = self::encodeRequestDataForSignature( | |||||
| $method, | |||||
| $params, | |||||
| $meta); | |||||
| $signature = base64_decode($signature); | |||||
| $trap = new PhutilErrorTrap(); | |||||
| $result = @openssl_verify( | |||||
| $input, | |||||
| $signature, | |||||
| $openssl_public_key); | |||||
| $err = $trap->getErrorsAsString(); | |||||
| $trap->destroy(); | |||||
| if ($result === 1) { | |||||
| // Signature is good. | |||||
| return true; | |||||
| } else if ($result === 0) { | |||||
| // Signature is bad. | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Request signature verification failed: signature is not correct.')); | |||||
| } else { | |||||
| // Some kind of error. | |||||
| if (strlen($err)) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'OpenSSL encountered an error verifying the request signature: %s', | |||||
| $err)); | |||||
| } else { | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'OpenSSL encountered an unknown error verifying the request: %s', | |||||
| $err)); | |||||
| } | |||||
| } | |||||
| } | |||||
| private static function encodeRequestDataForSignature( | |||||
| $method, | |||||
| array $params, | |||||
| array $meta) { | |||||
| unset($meta['auth.signature']); | |||||
| $structure = array( | |||||
| 'method' => $method, | |||||
| 'protocol' => $meta, | |||||
| 'parameters' => $params, | |||||
| ); | |||||
| return self::encodeRawDataForSignature($structure); | |||||
| } | |||||
| public static function encodeRawDataForSignature($data) { | |||||
| $out = array(); | |||||
| if (is_array($data)) { | |||||
| if (phutil_is_natural_list($data)) { | |||||
| $out[] = 'A'; | |||||
| $out[] = count($data); | |||||
| $out[] = ':'; | |||||
| foreach ($data as $value) { | |||||
| $out[] = self::encodeRawDataForSignature($value); | |||||
| } | |||||
| } else { | |||||
| ksort($data); | |||||
| $out[] = 'O'; | |||||
| $out[] = count($data); | |||||
| $out[] = ':'; | |||||
| foreach ($data as $key => $value) { | |||||
| $out[] = self::encodeRawDataForSignature($key); | |||||
| $out[] = self::encodeRawDataForSignature($value); | |||||
| } | |||||
| } | |||||
| } else if (is_string($data)) { | |||||
| $out[] = 'S'; | |||||
| $out[] = strlen($data); | |||||
| $out[] = ':'; | |||||
| $out[] = $data; | |||||
| } else if (is_int($data)) { | |||||
| $out[] = 'I'; | |||||
| $out[] = strlen((string)$data); | |||||
| $out[] = ':'; | |||||
| $out[] = (string)$data; | |||||
| } else if (is_null($data)) { | |||||
| $out[] = 'N'; | |||||
| $out[] = ':'; | |||||
| } else if ($data === true) { | |||||
| $out[] = 'B1:'; | |||||
| } else if ($data === false) { | |||||
| $out[] = 'B0:'; | |||||
| } else { | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Unexpected data type in request data: %s.', | |||||
| gettype($data))); | |||||
| } | |||||
| return implode('', $out); | |||||
| } | |||||
| } | |||||