Path: blob/master/src/applications/conduit/controller/PhabricatorConduitAPIController.php
12262 views
<?php12final class PhabricatorConduitAPIController3extends PhabricatorConduitController {45public function shouldRequireLogin() {6return false;7}89public function handleRequest(AphrontRequest $request) {10$method = $request->getURIData('method');11$time_start = microtime(true);1213$api_request = null;14$method_implementation = null;1516$log = new PhabricatorConduitMethodCallLog();17$log->setMethod($method);18$metadata = array();1920$multimeter = MultimeterControl::getInstance();21if ($multimeter) {22$multimeter->setEventContext('api.'.$method);23}2425try {2627list($metadata, $params, $strictly_typed) = $this->decodeConduitParams(28$request,29$method);3031$call = new ConduitCall($method, $params, $strictly_typed);32$method_implementation = $call->getMethodImplementation();3334$result = null;3536// TODO: The relationship between ConduitAPIRequest and ConduitCall is a37// little odd here and could probably be improved. Specifically, the38// APIRequest is a sub-object of the Call, which does not parallel the39// role of AphrontRequest (which is an indepenent object).40// In particular, the setUser() and getUser() existing independently on41// the Call and APIRequest is very awkward.4243$api_request = $call->getAPIRequest();4445$allow_unguarded_writes = false;46$auth_error = null;47$conduit_username = '-';48if ($call->shouldRequireAuthentication()) {49$auth_error = $this->authenticateUser($api_request, $metadata, $method);50// If we've explicitly authenticated the user here and either done51// CSRF validation or are using a non-web authentication mechanism.52$allow_unguarded_writes = true;5354if ($auth_error === null) {55$conduit_user = $api_request->getUser();56if ($conduit_user && $conduit_user->getPHID()) {57$conduit_username = $conduit_user->getUsername();58}59$call->setUser($api_request->getUser());60}61}6263$access_log = PhabricatorAccessLog::getLog();64if ($access_log) {65$access_log->setData(66array(67'u' => $conduit_username,68'm' => $method,69));70}7172if ($call->shouldAllowUnguardedWrites()) {73$allow_unguarded_writes = true;74}7576if ($auth_error === null) {77if ($allow_unguarded_writes) {78$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();79}8081try {82$result = $call->execute();83$error_code = null;84$error_info = null;85} catch (ConduitException $ex) {86$result = null;87$error_code = $ex->getMessage();88if ($ex->getErrorDescription()) {89$error_info = $ex->getErrorDescription();90} else {91$error_info = $call->getErrorDescription($error_code);92}93}94if ($allow_unguarded_writes) {95unset($unguarded);96}97} else {98list($error_code, $error_info) = $auth_error;99}100} catch (Exception $ex) {101$result = null;102103if ($ex instanceof ConduitException) {104$error_code = 'ERR-CONDUIT-CALL';105} else {106$error_code = 'ERR-CONDUIT-CORE';107108// See T13581. When a Conduit method raises an uncaught exception109// other than a "ConduitException", log it.110phlog($ex);111}112113$error_info = $ex->getMessage();114}115116$log117->setCallerPHID(118isset($conduit_user)119? $conduit_user->getPHID()120: null)121->setError((string)$error_code)122->setDuration(phutil_microseconds_since($time_start));123124if (!PhabricatorEnv::isReadOnly()) {125$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();126$log->save();127unset($unguarded);128}129130$response = id(new ConduitAPIResponse())131->setResult($result)132->setErrorCode($error_code)133->setErrorInfo($error_info);134135switch ($request->getStr('output')) {136case 'human':137return $this->buildHumanReadableResponse(138$method,139$api_request,140$response->toDictionary(),141$method_implementation);142case 'json':143default:144$response = id(new AphrontJSONResponse())145->setAddJSONShield(false)146->setContent($response->toDictionary());147148$capabilities = $this->getConduitCapabilities();149if ($capabilities) {150$capabilities = implode(' ', $capabilities);151$response->addHeader('X-Conduit-Capabilities', $capabilities);152}153154return $response;155}156}157158/**159* Authenticate the client making the request to a Phabricator user account.160*161* @param ConduitAPIRequest Request being executed.162* @param dict Request metadata.163* @return null|pair Null to indicate successful authentication, or164* an error code and error message pair.165*/166private function authenticateUser(167ConduitAPIRequest $api_request,168array $metadata,169$method) {170171$request = $this->getRequest();172173if ($request->getUser()->getPHID()) {174$request->validateCSRF();175return $this->validateAuthenticatedUser(176$api_request,177$request->getUser());178}179180$auth_type = idx($metadata, 'auth.type');181if ($auth_type === ConduitClient::AUTH_ASYMMETRIC) {182$host = idx($metadata, 'auth.host');183if (!$host) {184return array(185'ERR-INVALID-AUTH',186pht(187'Request is missing required "%s" parameter.',188'auth.host'),189);190}191192// TODO: Validate that we are the host!193194$raw_key = idx($metadata, 'auth.key');195$public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($raw_key);196$ssl_public_key = $public_key->toPKCS8();197198// First, verify the signature.199try {200$protocol_data = $metadata;201ConduitClient::verifySignature(202$method,203$api_request->getAllParameters(),204$protocol_data,205$ssl_public_key);206} catch (Exception $ex) {207return array(208'ERR-INVALID-AUTH',209pht(210'Signature verification failure. %s',211$ex->getMessage()),212);213}214215// If the signature is valid, find the user or device which is216// associated with this public key.217218$stored_key = id(new PhabricatorAuthSSHKeyQuery())219->setViewer(PhabricatorUser::getOmnipotentUser())220->withKeys(array($public_key))221->withIsActive(true)222->executeOne();223if (!$stored_key) {224$key_summary = id(new PhutilUTF8StringTruncator())225->setMaximumBytes(64)226->truncateString($raw_key);227return array(228'ERR-INVALID-AUTH',229pht(230'No user or device is associated with the public key "%s".',231$key_summary),232);233}234235$object = $stored_key->getObject();236237if ($object instanceof PhabricatorUser) {238$user = $object;239} else {240if ($object->isDisabled()) {241return array(242'ERR-INVALID-AUTH',243pht(244'The key which signed this request is associated with a '.245'disabled device ("%s").',246$object->getName()),247);248}249250if (!$stored_key->getIsTrusted()) {251return array(252'ERR-INVALID-AUTH',253pht(254'The key which signed this request is not trusted. Only '.255'trusted keys can be used to sign API calls.'),256);257}258259if (!PhabricatorEnv::isClusterRemoteAddress()) {260return array(261'ERR-INVALID-AUTH',262pht(263'This request originates from outside of the cluster address '.264'range. Requests signed with trusted device keys must '.265'originate from within the cluster.'),266);267}268269$user = PhabricatorUser::getOmnipotentUser();270271// Flag this as an intracluster request.272$api_request->setIsClusterRequest(true);273}274275return $this->validateAuthenticatedUser(276$api_request,277$user);278} else if ($auth_type === null) {279// No specified authentication type, continue with other authentication280// methods below.281} else {282return array(283'ERR-INVALID-AUTH',284pht(285'Provided "%s" ("%s") is not recognized.',286'auth.type',287$auth_type),288);289}290291$token_string = idx($metadata, 'token');292if ($token_string !== null && strlen($token_string)) {293294if (strlen($token_string) != 32) {295return array(296'ERR-INVALID-AUTH',297pht(298'API token "%s" has the wrong length. API tokens should be '.299'32 characters long.',300$token_string),301);302}303304$type = head(explode('-', $token_string));305$valid_types = PhabricatorConduitToken::getAllTokenTypes();306$valid_types = array_fuse($valid_types);307if (empty($valid_types[$type])) {308return array(309'ERR-INVALID-AUTH',310pht(311'API token "%s" has the wrong format. API tokens should be '.312'32 characters long and begin with one of these prefixes: %s.',313$token_string,314implode(', ', $valid_types)),315);316}317318$token = id(new PhabricatorConduitTokenQuery())319->setViewer(PhabricatorUser::getOmnipotentUser())320->withTokens(array($token_string))321->withExpired(false)322->executeOne();323if (!$token) {324$token = id(new PhabricatorConduitTokenQuery())325->setViewer(PhabricatorUser::getOmnipotentUser())326->withTokens(array($token_string))327->withExpired(true)328->executeOne();329if ($token) {330return array(331'ERR-INVALID-AUTH',332pht(333'API token "%s" was previously valid, but has expired.',334$token_string),335);336} else {337return array(338'ERR-INVALID-AUTH',339pht(340'API token "%s" is not valid.',341$token_string),342);343}344}345346// If this is a "cli-" token, it expires shortly after it is generated347// by default. Once it is actually used, we extend its lifetime and make348// it permanent. This allows stray tokens to get cleaned up automatically349// if they aren't being used.350if ($token->getTokenType() == PhabricatorConduitToken::TYPE_COMMANDLINE) {351if ($token->getExpires()) {352$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();353$token->setExpires(null);354$token->save();355unset($unguarded);356}357}358359// If this is a "clr-" token, Phabricator must be configured in cluster360// mode and the remote address must be a cluster node.361if ($token->getTokenType() == PhabricatorConduitToken::TYPE_CLUSTER) {362if (!PhabricatorEnv::isClusterRemoteAddress()) {363return array(364'ERR-INVALID-AUTH',365pht(366'This request originates from outside of the cluster address '.367'range. Requests signed with cluster API tokens must '.368'originate from within the cluster.'),369);370}371372// Flag this as an intracluster request.373$api_request->setIsClusterRequest(true);374}375376$user = $token->getObject();377if (!($user instanceof PhabricatorUser)) {378return array(379'ERR-INVALID-AUTH',380pht('API token is not associated with a valid user.'),381);382}383384return $this->validateAuthenticatedUser(385$api_request,386$user);387}388389$access_token = idx($metadata, 'access_token');390if ($access_token) {391$token = id(new PhabricatorOAuthServerAccessToken())392->loadOneWhere('token = %s', $access_token);393if (!$token) {394return array(395'ERR-INVALID-AUTH',396pht('Access token does not exist.'),397);398}399400$oauth_server = new PhabricatorOAuthServer();401$authorization = $oauth_server->authorizeToken($token);402if (!$authorization) {403return array(404'ERR-INVALID-AUTH',405pht('Access token is invalid or expired.'),406);407}408409$user = id(new PhabricatorPeopleQuery())410->setViewer(PhabricatorUser::getOmnipotentUser())411->withPHIDs(array($token->getUserPHID()))412->executeOne();413if (!$user) {414return array(415'ERR-INVALID-AUTH',416pht('Access token is for invalid user.'),417);418}419420$ok = $this->authorizeOAuthMethodAccess($authorization, $method);421if (!$ok) {422return array(423'ERR-OAUTH-ACCESS',424pht('You do not have authorization to call this method.'),425);426}427428$api_request->setOAuthToken($token);429430return $this->validateAuthenticatedUser(431$api_request,432$user);433}434435436// For intracluster requests, use a public user if no authentication437// information is provided. We could do this safely for any request,438// but making the API fully public means there's no way to disable badly439// behaved clients.440if (PhabricatorEnv::isClusterRemoteAddress()) {441if (PhabricatorEnv::getEnvConfig('policy.allow-public')) {442$api_request->setIsClusterRequest(true);443444$user = new PhabricatorUser();445return $this->validateAuthenticatedUser(446$api_request,447$user);448}449}450451452// Handle sessionless auth.453// TODO: This is super messy.454// TODO: Remove this in favor of token-based auth.455456if (isset($metadata['authUser'])) {457$user = id(new PhabricatorUser())->loadOneWhere(458'userName = %s',459$metadata['authUser']);460if (!$user) {461return array(462'ERR-INVALID-AUTH',463pht('Authentication is invalid.'),464);465}466$token = idx($metadata, 'authToken');467$signature = idx($metadata, 'authSignature');468$certificate = $user->getConduitCertificate();469$hash = sha1($token.$certificate);470if (!phutil_hashes_are_identical($hash, $signature)) {471return array(472'ERR-INVALID-AUTH',473pht('Authentication is invalid.'),474);475}476return $this->validateAuthenticatedUser(477$api_request,478$user);479}480481// Handle session-based auth.482// TODO: Remove this in favor of token-based auth.483484$session_key = idx($metadata, 'sessionKey');485if (!$session_key) {486return array(487'ERR-INVALID-SESSION',488pht('Session key is not present.'),489);490}491492$user = id(new PhabricatorAuthSessionEngine())493->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key);494495if (!$user) {496return array(497'ERR-INVALID-SESSION',498pht('Session key is invalid.'),499);500}501502return $this->validateAuthenticatedUser(503$api_request,504$user);505}506507private function validateAuthenticatedUser(508ConduitAPIRequest $request,509PhabricatorUser $user) {510511if (!$user->canEstablishAPISessions()) {512return array(513'ERR-INVALID-AUTH',514pht('User account is not permitted to use the API.'),515);516}517518$request->setUser($user);519520id(new PhabricatorAuthSessionEngine())521->willServeRequestForUser($user);522523return null;524}525526private function buildHumanReadableResponse(527$method,528ConduitAPIRequest $request = null,529$result = null,530ConduitAPIMethod $method_implementation = null) {531532$param_rows = array();533$param_rows[] = array('Method', $this->renderAPIValue($method));534if ($request) {535foreach ($request->getAllParameters() as $key => $value) {536$param_rows[] = array(537$key,538$this->renderAPIValue($value),539);540}541}542543$param_table = new AphrontTableView($param_rows);544$param_table->setColumnClasses(545array(546'header',547'wide',548));549550$result_rows = array();551foreach ($result as $key => $value) {552$result_rows[] = array(553$key,554$this->renderAPIValue($value),555);556}557558$result_table = new AphrontTableView($result_rows);559$result_table->setColumnClasses(560array(561'header',562'wide',563));564565$param_panel = id(new PHUIObjectBoxView())566->setHeaderText(pht('Method Parameters'))567->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)568->setTable($param_table);569570$result_panel = id(new PHUIObjectBoxView())571->setHeaderText(pht('Method Result'))572->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)573->setTable($result_table);574575$method_uri = $this->getApplicationURI('method/'.$method.'/');576577$crumbs = $this->buildApplicationCrumbs()578->addTextCrumb($method, $method_uri)579->addTextCrumb(pht('Call'))580->setBorder(true);581582$example_panel = null;583if ($request && $method_implementation) {584$params = $request->getAllParameters();585$example_panel = $this->renderExampleBox(586$method_implementation,587$params);588}589590$title = pht('Method Call Result');591$header = id(new PHUIHeaderView())592->setHeader($title)593->setHeaderIcon('fa-exchange');594595$view = id(new PHUITwoColumnView())596->setHeader($header)597->setFooter(array(598$param_panel,599$result_panel,600$example_panel,601));602603$title = pht('Method Call Result');604605return $this->newPage()606->setTitle($title)607->setCrumbs($crumbs)608->appendChild($view);609610}611612private function renderAPIValue($value) {613$json = new PhutilJSON();614if (is_array($value)) {615$value = $json->encodeFormatted($value);616}617618$value = phutil_tag(619'pre',620array('style' => 'white-space: pre-wrap;'),621$value);622623return $value;624}625626private function decodeConduitParams(627AphrontRequest $request,628$method) {629630$content_type = $request->getHTTPHeader('Content-Type');631632if ($content_type == 'application/json') {633throw new Exception(634pht('Use form-encoded data to submit parameters to Conduit endpoints. '.635'Sending a JSON-encoded body and setting \'Content-Type\': '.636'\'application/json\' is not currently supported.'));637}638639// Look for parameters from the Conduit API Console, which are encoded640// as HTTP POST parameters in an array, e.g.:641//642// params[name]=value¶ms[name2]=value2643//644// The fields are individually JSON encoded, since we require users to645// enter JSON so that we avoid type ambiguity.646647$params = $request->getArr('params', null);648if ($params !== null) {649foreach ($params as $key => $value) {650if ($value == '') {651// Interpret empty string null (e.g., the user didn't type anything652// into the box).653$value = 'null';654}655$decoded_value = json_decode($value, true);656if ($decoded_value === null && strtolower($value) != 'null') {657// When json_decode() fails, it returns null. This almost certainly658// indicates that a user was using the web UI and didn't put quotes659// around a string value. We can either do what we think they meant660// (treat it as a string) or fail. For now, err on the side of661// caution and fail. In the future, if we make the Conduit API662// actually do type checking, it might be reasonable to treat it as663// a string if the parameter type is string.664throw new Exception(665pht(666"The value for parameter '%s' is not valid JSON. All ".667"parameters must be encoded as JSON values, including strings ".668"(which means you need to surround them in double quotes). ".669"Check your syntax. Value was: %s.",670$key,671$value));672}673$params[$key] = $decoded_value;674}675676$metadata = idx($params, '__conduit__', array());677unset($params['__conduit__']);678679return array($metadata, $params, true);680}681682// Otherwise, look for a single parameter called 'params' which has the683// entire param dictionary JSON encoded.684$params_json = $request->getStr('params');685if (phutil_nonempty_string($params_json)) {686$params = null;687try {688$params = phutil_json_decode($params_json);689} catch (PhutilJSONParserException $ex) {690throw new PhutilProxyException(691pht(692"Invalid parameter information was passed to method '%s'.",693$method),694$ex);695}696697$metadata = idx($params, '__conduit__', array());698unset($params['__conduit__']);699700return array($metadata, $params, true);701}702703// If we do not have `params`, assume this is a simple HTTP request with704// HTTP key-value pairs.705$params = array();706$metadata = array();707foreach ($request->getPassthroughRequestData() as $key => $value) {708$meta_key = ConduitAPIMethod::getParameterMetadataKey($key);709if ($meta_key !== null) {710$metadata[$meta_key] = $value;711} else {712$params[$key] = $value;713}714}715716return array($metadata, $params, false);717}718719private function authorizeOAuthMethodAccess(720PhabricatorOAuthClientAuthorization $authorization,721$method_name) {722723$method = ConduitAPIMethod::getConduitMethod($method_name);724if (!$method) {725return false;726}727728$required_scope = $method->getRequiredScope();729switch ($required_scope) {730case ConduitAPIMethod::SCOPE_ALWAYS:731return true;732case ConduitAPIMethod::SCOPE_NEVER:733return false;734}735736$authorization_scope = $authorization->getScope();737if (!empty($authorization_scope[$required_scope])) {738return true;739}740741return false;742}743744private function getConduitCapabilities() {745$capabilities = array();746747if (AphrontRequestStream::supportsGzip()) {748$capabilities[] = 'gzip';749}750751return $capabilities;752}753754}755756757