diff --git a/src/aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php b/src/aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php index 45dedf2..7a4b519 100644 --- a/src/aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php +++ b/src/aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php @@ -1,251 +1,244 @@ validateUTF8String($string); return $this->escapeBinaryString($string); } public function escapeBinaryString($string) { return $this->requireConnection()->escape_string($string); } public function getInsertID() { return $this->requireConnection()->insert_id; } public function getAffectedRows() { return $this->requireConnection()->affected_rows; } protected function closeConnection() { if ($this->connectionOpen) { $this->requireConnection()->close(); $this->connectionOpen = false; } } protected function connect() { if (!class_exists('mysqli', false)) { throw new Exception(pht( 'About to call new %s, but the PHP MySQLi extension is not available!', 'mysqli()')); } $user = $this->getConfiguration('user'); $host = $this->getConfiguration('host'); $port = $this->getConfiguration('port'); $database = $this->getConfiguration('database'); $pass = $this->getConfiguration('pass'); if ($pass instanceof PhutilOpaqueEnvelope) { $pass = $pass->openEnvelope(); } // If the host is "localhost", the port is ignored and mysqli attempts to // connect over a socket. if ($port) { if ($host === 'localhost' || $host === null) { $host = '127.0.0.1'; } } $conn = mysqli_init(); $timeout = $this->getConfiguration('timeout'); if ($timeout) { $conn->options(MYSQLI_OPT_CONNECT_TIMEOUT, $timeout); } - // See T13238. Attempt to prevent "LOAD DATA LOCAL INFILE", which allows a - // malicious server to ask the client for any file. - - // NOTE: See T13238. This option does not appear to ever have any effect. - // Only the PHP level configuration of "mysqli.allow_local_infile" is - // effective in preventing "LOAD DATA LOCAL INFILE". It appears that the - // configuration option may overwrite the local option? Set the local - // option to the desired (safe) value anyway in case this starts working - // properly in some future version of PHP/MySQLi. - - $conn->options(MYSQLI_OPT_LOCAL_INFILE, 0); - if ($this->getPersistent()) { $host = 'p:'.$host; } @$conn->real_connect( $host, $user, $pass, $database, $port); $errno = $conn->connect_errno; if ($errno) { $error = $conn->connect_error; $this->throwConnectionException($errno, $error, $user, $host); } + // See T13238. Attempt to prevent "LOAD DATA LOCAL INFILE", which allows a + // malicious server to ask the client for any file. At time of writing, + // this option MUST be set after "real_connect()" on all PHP versions. + $conn->options(MYSQLI_OPT_LOCAL_INFILE, 0); + $this->connectionOpen = true; $ok = @$conn->set_charset('utf8mb4'); if (!$ok) { $ok = $conn->set_charset('binary'); } return $conn; } protected function rawQuery($raw_query) { $conn = $this->requireConnection(); $time_limit = $this->getQueryTimeout(); // If we have a query time limit, run this query synchronously but use // the async API. This allows us to kill queries which take too long // without requiring any configuration on the server side. if ($time_limit && $this->supportsAsyncQueries()) { $conn->query($raw_query, MYSQLI_ASYNC); $read = array($conn); $error = array($conn); $reject = array($conn); $result = mysqli::poll($read, $error, $reject, $time_limit); if ($result === false) { $this->closeConnection(); throw new Exception( pht('Failed to poll mysqli connection!')); } else if ($result === 0) { $this->closeConnection(); throw new AphrontQueryTimeoutQueryException( pht( 'Query timed out after %s second(s)!', new PhutilNumber($time_limit))); } return @$conn->reap_async_query(); } $trap = new PhutilErrorTrap(); $result = @$conn->query($raw_query); $err = $trap->getErrorsAsString(); $trap->destroy(); // See T13238 and PHI1014. Sometimes, the call to "$conn->query()" may fail // without setting an error code on the connection. One way to reproduce // this is to use "LOAD DATA LOCAL INFILE" with "mysqli.allow_local_infile" // disabled. // If we have no result and no error code, raise a synthetic query error // with whatever error message was raised as a local PHP warning. if (!$result) { $error_code = $this->getErrorCode($conn); if (!$error_code) { if (strlen($err)) { $message = $err; } else { $message = pht( 'Call to "mysqli->query()" failed, but did not set an error '. 'code or emit an error message.'); } $this->throwQueryCodeException(777777, $message); } } return $result; } protected function rawQueries(array $raw_queries) { $conn = $this->requireConnection(); $have_result = false; $results = array(); foreach ($raw_queries as $key => $raw_query) { if (!$have_result) { // End line in front of semicolon to allow single line comments at the // end of queries. $have_result = $conn->multi_query(implode("\n;\n\n", $raw_queries)); } else { $have_result = $conn->next_result(); } array_shift($raw_queries); $result = $conn->store_result(); if (!$result && !$this->getErrorCode($conn)) { $result = true; } $results[$key] = $this->processResult($result); } if ($conn->more_results()) { throw new Exception( pht('There are some results left in the result set.')); } return $results; } protected function freeResult($result) { $result->free_result(); } protected function fetchAssoc($result) { return $result->fetch_assoc(); } protected function getErrorCode($connection) { return $connection->errno; } protected function getErrorDescription($connection) { return $connection->error; } public function supportsAsyncQueries() { return defined('MYSQLI_ASYNC'); } public function asyncQuery($raw_query) { $this->checkWrite($raw_query); $async = $this->beginAsyncConnection(); $async->query($raw_query, MYSQLI_ASYNC); return $async; } public static function resolveAsyncQueries(array $conns, array $asyncs) { assert_instances_of($conns, __CLASS__); assert_instances_of($asyncs, 'mysqli'); $read = $error = $reject = array(); foreach ($asyncs as $async) { $read[] = $error[] = $reject[] = $async; } if (!mysqli::poll($read, $error, $reject, 0)) { return array(); } $results = array(); foreach ($read as $async) { $key = array_search($async, $asyncs, $strict = true); $conn = $conns[$key]; $conn->endAsyncConnection($async); $results[$key] = $conn->processResult($async->reap_async_query()); } return $results; } } diff --git a/src/auth/PhutilGoogleAuthAdapter.php b/src/auth/PhutilGoogleAuthAdapter.php index 1a739f1..56e1f0f 100644 --- a/src/auth/PhutilGoogleAuthAdapter.php +++ b/src/auth/PhutilGoogleAuthAdapter.php @@ -1,181 +1,105 @@ getOAuthAccountData('emails', array()); - foreach ($emails as $email) { - if (idx($email, 'type') == 'account') { - return idx($email, 'value'); - } - } - - throw new Exception( - pht( - 'Expected to retrieve an "account" email from Google Plus API call '. - 'to identify account, but failed.')); + return $this->getAccountEmail(); } public function getAccountEmail() { - return $this->getAccountID(); + return $this->getOAuthAccountData('email'); } public function getAccountName() { // Guess account name from email address, this is just a hint anyway. $email = $this->getAccountEmail(); $email = explode('@', $email); $email = head($email); return $email; } public function getAccountImageURI() { - $image = $this->getOAuthAccountData('image', array()); - $uri = idx($image, 'url'); + $uri = $this->getOAuthAccountData('picture'); // Change the "sz" parameter ("size") from the default to 100 to ask for // a 100x100px image. if ($uri !== null) { $uri = new PhutilURI($uri); $uri->setQueryParam('sz', 100); $uri = (string)$uri; } return $uri; } public function getAccountURI() { - return $this->getOAuthAccountData('url'); + return $this->getOAuthAccountData('link'); } public function getAccountRealName() { - $name = $this->getOAuthAccountData('name', array()); - - // TODO: This could probably be made cleaner by looking up the API, but - // this should work to unbreak logins. - - $parts = array(); - $parts[] = idx($name, 'givenName'); - unset($name['givenName']); - $parts[] = idx($name, 'familyName'); - unset($name['familyName']); - $parts = array_merge($parts, $name); - $parts = array_filter($parts); - - return implode(' ', $parts); + return $this->getOAuthAccountData('name'); } protected function getAuthenticateBaseURI() { return 'https://accounts.google.com/o/oauth2/auth'; } protected function getTokenBaseURI() { return 'https://accounts.google.com/o/oauth2/token'; } public function getScope() { $scopes = array( 'email', 'profile', ); return implode(' ', $scopes); } public function getExtraAuthenticateParameters() { return array( 'response_type' => 'code', ); } public function getExtraTokenParameters() { return array( 'grant_type' => 'authorization_code', ); } protected function loadOAuthAccountData() { - $uri = new PhutilURI('https://www.googleapis.com/plus/v1/people/me'); + $uri = new PhutilURI('https://www.googleapis.com/userinfo/v2/me'); $uri->setQueryParam('access_token', $this->getAccessToken()); $future = new HTTPSFuture($uri); list($status, $body) = $future->resolve(); if ($status->isError()) { - $this->tryToThrowSpecializedError($status, $body); throw $status; } try { - return phutil_json_decode($body); + $result = phutil_json_decode($body); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht('Expected valid JSON response from Google account data request.'), $ex); } - } - - private function tryToThrowSpecializedError($status, $raw_body) { - if (!($status instanceof HTTPFutureHTTPResponseStatus)) { - return; - } - if ($status->getStatusCode() != 403) { - return; - } - - $body = phutil_json_decode($raw_body); - if (!$body) { - return; - } - - if (empty($body['error']['errors'][0])) { - return; - } - - $error = $body['error']['errors'][0]; - $domain = idx($error, 'domain'); - $reason = idx($error, 'reason'); - - if ($domain == 'usageLimits' && $reason == 'accessNotConfigured') { - throw new PhutilAuthConfigurationException( - pht( - 'Google returned an "%s" error. This usually means you need to '. - 'enable the "Google+ API" in your Google Cloud Console, under '. - '"APIs".'. - "\n\n". - 'Around March 2014, Google made some API changes which require this '. - 'configuration adjustment.'. - "\n\n". - 'Normally, you can resolve this issue by going to %s, then '. - 'clicking "API Project", then "APIs & auth", then turning the '. - '"Google+ API" on. The names you see on the console may be '. - 'different depending on how your integration is set up. If you '. - 'are not sure, you can hunt through the projects until you find '. - 'the one associated with the right Application ID under '. - '"Credentials". The Application ID this install is using is "%s".'. - "\n\n". - '(If you are unable to log into Phabricator, you can use '. - '"%s" to recover access to an administrator account.)'. - "\n\n". - 'Full HTTP Response'. - "\n\n%s", - 'accessNotConfigured', - 'https://console.developers.google.com/', - $this->getClientID(), - 'bin/auth recover', - $raw_body)); - } + return $result; } }