@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at upstream/main 766 lines 23 kB view raw
1<?php 2 3final class PhabricatorConduitAPIController 4 extends PhabricatorConduitController { 5 6 public function shouldRequireLogin() { 7 return false; 8 } 9 10 public function handleRequest(AphrontRequest $request) { 11 $method = $request->getURIData('method'); 12 // PhabricatorConduitMethodCallLog limits 'method' to 'text64' so truncate 13 // the method name. This entire call will fail anyway; truncation allows us 14 // to at least show a meaningful error message instead of returning a raw 15 // DB write error while still logging the failed call in the Call Logs. 16 $limit = 64; 17 if (strlen($method) > $limit) { 18 $method = substr($method, 0, $limit); 19 } 20 21 $time_start = microtime(true); 22 23 $api_request = null; 24 $method_implementation = null; 25 26 $log = new PhabricatorConduitMethodCallLog(); 27 $log->setMethod($method); 28 $metadata = array(); 29 30 $multimeter = MultimeterControl::getInstance(); 31 if ($multimeter) { 32 $multimeter->setEventContext('api.'.$method); 33 } 34 35 try { 36 37 list($metadata, $params, $strictly_typed) = $this->decodeConduitParams( 38 $request, 39 $method); 40 41 $call = new ConduitCall($method, $params, $strictly_typed); 42 $method_implementation = $call->getMethodImplementation(); 43 44 $result = null; 45 46 // TODO: The relationship between ConduitAPIRequest and ConduitCall is a 47 // little odd here and could probably be improved. Specifically, the 48 // APIRequest is a sub-object of the Call, which does not parallel the 49 // role of AphrontRequest (which is an independent object). 50 // In particular, the setUser() and getUser() existing independently on 51 // the Call and APIRequest is very awkward. 52 53 $api_request = $call->getAPIRequest(); 54 55 $allow_unguarded_writes = false; 56 $auth_error = null; 57 $conduit_username = '-'; 58 if ($call->shouldRequireAuthentication()) { 59 $auth_error = $this->authenticateUser($api_request, $metadata, $method); 60 // If we've explicitly authenticated the user here and either done 61 // CSRF validation or are using a non-web authentication mechanism. 62 $allow_unguarded_writes = true; 63 64 if ($auth_error === null) { 65 $conduit_user = $api_request->getUser(); 66 if ($conduit_user && $conduit_user->getPHID()) { 67 $conduit_username = $conduit_user->getUsername(); 68 } 69 $call->setUser($api_request->getUser()); 70 } 71 } 72 73 $access_log = PhabricatorAccessLog::getLog(); 74 if ($access_log) { 75 $access_log->setData( 76 array( 77 'u' => $conduit_username, 78 'm' => $method, 79 )); 80 } 81 82 if ($call->shouldAllowUnguardedWrites()) { 83 $allow_unguarded_writes = true; 84 } 85 86 if ($auth_error === null) { 87 if ($allow_unguarded_writes) { 88 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 89 } 90 91 try { 92 $result = $call->execute(); 93 $error_code = null; 94 $error_info = null; 95 } catch (ConduitException $ex) { 96 $result = null; 97 $error_code = $ex->getMessage(); 98 if ($ex->getErrorDescription()) { 99 $error_info = $ex->getErrorDescription(); 100 } else { 101 $error_info = $call->getErrorDescription($error_code); 102 } 103 } 104 if ($allow_unguarded_writes) { 105 unset($unguarded); 106 } 107 } else { 108 list($error_code, $error_info) = $auth_error; 109 } 110 } catch (Exception $ex) { 111 $result = null; 112 113 if ($ex instanceof ConduitException) { 114 $error_code = 'ERR-CONDUIT-CALL'; 115 } else { 116 $error_code = 'ERR-CONDUIT-CORE'; 117 118 // See T13581. When a Conduit method raises an uncaught exception 119 // other than a "ConduitException", log it. 120 phlog($ex); 121 } 122 123 $error_info = $ex->getMessage(); 124 } 125 126 $log 127 ->setCallerPHID( 128 isset($conduit_user) 129 ? $conduit_user->getPHID() 130 : null) 131 ->setError((string)$error_code) 132 ->setDuration(phutil_microseconds_since($time_start)); 133 134 if (!PhabricatorEnv::isReadOnly()) { 135 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 136 $log->save(); 137 unset($unguarded); 138 } 139 140 $response = id(new ConduitAPIResponse()) 141 ->setResult($result) 142 ->setErrorCode($error_code) 143 ->setErrorInfo($error_info); 144 145 switch ($request->getStr('output')) { 146 case 'human': 147 return $this->buildHumanReadableResponse( 148 $method, 149 $api_request, 150 $response->toDictionary(), 151 $method_implementation); 152 case 'json': 153 default: 154 $response = id(new AphrontJSONResponse()) 155 ->setAddJSONShield(false) 156 ->setContent($response->toDictionary()); 157 158 $capabilities = $this->getConduitCapabilities(); 159 if ($capabilities) { 160 $capabilities = implode(' ', $capabilities); 161 $response->addHeader('X-Conduit-Capabilities', $capabilities); 162 } 163 164 return $response; 165 } 166 } 167 168 /** 169 * Authenticate the client making the request to a Phabricator user account. 170 * 171 * @param ConduitAPIRequest $api_request Request being executed. 172 * @param array $metadata Dictionary of request metadata. 173 * @param string $method 174 * @return null|array Null to indicate successful authentication, or 175 * an error code and error message pair. 176 */ 177 private function authenticateUser( 178 ConduitAPIRequest $api_request, 179 array $metadata, 180 $method) { 181 182 $request = $this->getRequest(); 183 184 if ($request->getUser()->getPHID()) { 185 $request->validateCSRF(); 186 return $this->validateAuthenticatedUser( 187 $api_request, 188 $request->getUser()); 189 } 190 191 $auth_type = idx($metadata, 'auth.type'); 192 if ($auth_type === ConduitClient::AUTH_ASYMMETRIC) { 193 $host = idx($metadata, 'auth.host'); 194 if (!$host) { 195 return array( 196 'ERR-INVALID-AUTH', 197 pht( 198 'Request is missing required "%s" parameter.', 199 'auth.host'), 200 ); 201 } 202 203 // TODO: Validate that we are the host! 204 205 $raw_key = idx($metadata, 'auth.key'); 206 $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($raw_key); 207 $ssl_public_key = $public_key->toPKCS8(); 208 209 // First, verify the signature. 210 try { 211 $protocol_data = $metadata; 212 ConduitClient::verifySignature( 213 $method, 214 $api_request->getAllParameters(), 215 $protocol_data, 216 $ssl_public_key); 217 } catch (Exception $ex) { 218 return array( 219 'ERR-INVALID-AUTH', 220 pht( 221 'Signature verification failure. %s', 222 $ex->getMessage()), 223 ); 224 } 225 226 // If the signature is valid, find the user or device which is 227 // associated with this public key. 228 229 $stored_key = id(new PhabricatorAuthSSHKeyQuery()) 230 ->setViewer(PhabricatorUser::getOmnipotentUser()) 231 ->withKeys(array($public_key)) 232 ->withIsActive(true) 233 ->executeOne(); 234 if (!$stored_key) { 235 $key_summary = id(new PhutilUTF8StringTruncator()) 236 ->setMaximumBytes(64) 237 ->truncateString($raw_key); 238 return array( 239 'ERR-INVALID-AUTH', 240 pht( 241 'No user or device is associated with the public key "%s".', 242 $key_summary), 243 ); 244 } 245 246 $object = $stored_key->getObject(); 247 248 if ($object instanceof PhabricatorUser) { 249 $user = $object; 250 } else { 251 if ($object->isDisabled()) { 252 return array( 253 'ERR-INVALID-AUTH', 254 pht( 255 'The key which signed this request is associated with a '. 256 'disabled device ("%s").', 257 $object->getName()), 258 ); 259 } 260 261 if (!$stored_key->getIsTrusted()) { 262 return array( 263 'ERR-INVALID-AUTH', 264 pht( 265 'The key which signed this request is not trusted. Only '. 266 'trusted keys can be used to sign API calls.'), 267 ); 268 } 269 270 if (!PhabricatorEnv::isClusterRemoteAddress()) { 271 return array( 272 'ERR-INVALID-AUTH', 273 pht( 274 'This request originates from outside of the cluster address '. 275 'range. Requests signed with trusted device keys must '. 276 'originate from within the cluster.'), 277 ); 278 } 279 280 $user = PhabricatorUser::getOmnipotentUser(); 281 282 // Flag this as an intracluster request. 283 $api_request->setIsClusterRequest(true); 284 } 285 286 return $this->validateAuthenticatedUser( 287 $api_request, 288 $user); 289 } else if ($auth_type === null) { 290 // No specified authentication type, continue with other authentication 291 // methods below. 292 } else { 293 return array( 294 'ERR-INVALID-AUTH', 295 pht( 296 'Provided "%s" ("%s") is not recognized.', 297 'auth.type', 298 $auth_type), 299 ); 300 } 301 302 $token_string = idx($metadata, 'token', ''); 303 if (strlen($token_string)) { 304 305 if (strlen($token_string) != 32) { 306 return array( 307 'ERR-INVALID-AUTH', 308 pht( 309 'API token "%s" has the wrong length. API tokens should be '. 310 '32 characters long.', 311 $token_string), 312 ); 313 } 314 315 $type = head(explode('-', $token_string)); 316 $valid_types = PhabricatorConduitToken::getAllTokenTypes(); 317 $valid_types = array_fuse($valid_types); 318 if (empty($valid_types[$type])) { 319 return array( 320 'ERR-INVALID-AUTH', 321 pht( 322 'API token "%s" has the wrong format. API tokens should be '. 323 '32 characters long and begin with one of these prefixes: %s.', 324 $token_string, 325 implode(', ', $valid_types)), 326 ); 327 } 328 329 $token = id(new PhabricatorConduitTokenQuery()) 330 ->setViewer(PhabricatorUser::getOmnipotentUser()) 331 ->withTokens(array($token_string)) 332 ->withExpired(false) 333 ->executeOne(); 334 if (!$token) { 335 $token = id(new PhabricatorConduitTokenQuery()) 336 ->setViewer(PhabricatorUser::getOmnipotentUser()) 337 ->withTokens(array($token_string)) 338 ->withExpired(true) 339 ->executeOne(); 340 if ($token) { 341 return array( 342 'ERR-INVALID-AUTH', 343 pht( 344 'API token "%s" was previously valid, but has expired.', 345 $token_string), 346 ); 347 } else { 348 return array( 349 'ERR-INVALID-AUTH', 350 pht( 351 'API token "%s" is not valid.', 352 $token_string), 353 ); 354 } 355 } 356 357 // If this is a "cli-" token, it expires shortly after it is generated 358 // by default. Once it is actually used, we extend its lifetime and make 359 // it permanent. This allows stray tokens to get cleaned up automatically 360 // if they aren't being used. 361 if ($token->getTokenType() == PhabricatorConduitToken::TYPE_COMMANDLINE) { 362 if ($token->getExpires()) { 363 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 364 $token->setExpires(null); 365 $token->save(); 366 unset($unguarded); 367 } 368 } 369 370 // If this is a "clr-" token, Phabricator must be configured in cluster 371 // mode and the remote address must be a cluster node. 372 if ($token->getTokenType() == PhabricatorConduitToken::TYPE_CLUSTER) { 373 if (!PhabricatorEnv::isClusterRemoteAddress()) { 374 return array( 375 'ERR-INVALID-AUTH', 376 pht( 377 'This request originates from outside of the cluster address '. 378 'range. Requests signed with cluster API tokens must '. 379 'originate from within the cluster.'), 380 ); 381 } 382 383 // Flag this as an intracluster request. 384 $api_request->setIsClusterRequest(true); 385 } 386 387 $user = $token->getObject(); 388 if (!($user instanceof PhabricatorUser)) { 389 return array( 390 'ERR-INVALID-AUTH', 391 pht('API token is not associated with a valid user.'), 392 ); 393 } 394 395 return $this->validateAuthenticatedUser( 396 $api_request, 397 $user); 398 } 399 400 $access_token = idx($metadata, 'access_token'); 401 if ($access_token) { 402 $token = id(new PhabricatorOAuthServerAccessToken()) 403 ->loadOneWhere('token = %s', $access_token); 404 if (!$token) { 405 return array( 406 'ERR-INVALID-AUTH', 407 pht('Access token does not exist.'), 408 ); 409 } 410 411 $oauth_server = new PhabricatorOAuthServer(); 412 $authorization = $oauth_server->authorizeToken($token); 413 if (!$authorization) { 414 return array( 415 'ERR-INVALID-AUTH', 416 pht('Access token is invalid or expired.'), 417 ); 418 } 419 420 $user = id(new PhabricatorPeopleQuery()) 421 ->setViewer(PhabricatorUser::getOmnipotentUser()) 422 ->withPHIDs(array($token->getUserPHID())) 423 ->executeOne(); 424 if (!$user) { 425 return array( 426 'ERR-INVALID-AUTH', 427 pht('Access token is for invalid user.'), 428 ); 429 } 430 431 $ok = $this->authorizeOAuthMethodAccess($authorization, $method); 432 if (!$ok) { 433 return array( 434 'ERR-OAUTH-ACCESS', 435 pht('You do not have authorization to call this method.'), 436 ); 437 } 438 439 $api_request->setOAuthToken($token); 440 441 return $this->validateAuthenticatedUser( 442 $api_request, 443 $user); 444 } 445 446 447 // For intracluster requests, use a public user if no authentication 448 // information is provided. We could do this safely for any request, 449 // but making the API fully public means there's no way to disable badly 450 // behaved clients. 451 if (PhabricatorEnv::isClusterRemoteAddress()) { 452 if (PhabricatorEnv::getEnvConfig('policy.allow-public')) { 453 $api_request->setIsClusterRequest(true); 454 455 $user = new PhabricatorUser(); 456 return $this->validateAuthenticatedUser( 457 $api_request, 458 $user); 459 } 460 } 461 462 463 // Handle sessionless auth. 464 // TODO: This is super messy. 465 // TODO: Remove this in favor of token-based auth. 466 467 if (isset($metadata['authUser'])) { 468 $user = id(new PhabricatorUser())->loadOneWhere( 469 'userName = %s', 470 $metadata['authUser']); 471 if (!$user) { 472 return array( 473 'ERR-INVALID-AUTH', 474 pht('Authentication is invalid.'), 475 ); 476 } 477 $token = idx($metadata, 'authToken'); 478 $signature = idx($metadata, 'authSignature'); 479 $certificate = $user->getConduitCertificate(); 480 $hash = sha1($token.$certificate); 481 if (!phutil_hashes_are_identical($hash, $signature)) { 482 return array( 483 'ERR-INVALID-AUTH', 484 pht('Authentication is invalid.'), 485 ); 486 } 487 return $this->validateAuthenticatedUser( 488 $api_request, 489 $user); 490 } 491 492 // Handle session-based auth. 493 // TODO: Remove this in favor of token-based auth. 494 495 $session_key = idx($metadata, 'sessionKey'); 496 if (!$session_key) { 497 return array( 498 'ERR-INVALID-SESSION', 499 pht('Session key is not present.'), 500 ); 501 } 502 503 $user = id(new PhabricatorAuthSessionEngine()) 504 ->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key); 505 506 if (!$user) { 507 return array( 508 'ERR-INVALID-SESSION', 509 pht('Session key is invalid.'), 510 ); 511 } 512 513 return $this->validateAuthenticatedUser( 514 $api_request, 515 $user); 516 } 517 518 private function validateAuthenticatedUser( 519 ConduitAPIRequest $request, 520 PhabricatorUser $user) { 521 522 if (!$user->canEstablishAPISessions()) { 523 return array( 524 'ERR-INVALID-AUTH', 525 pht('User account is not permitted to use the API.'), 526 ); 527 } 528 529 $request->setUser($user); 530 531 id(new PhabricatorAuthSessionEngine()) 532 ->willServeRequestForUser($user); 533 534 return null; 535 } 536 537 private function buildHumanReadableResponse( 538 $method, 539 ?ConduitAPIRequest $request = null, 540 $result = null, 541 ?ConduitAPIMethod $method_implementation = null) { 542 543 $param_rows = array(); 544 $param_rows[] = array('Method', $this->renderAPIValue($method)); 545 if ($request) { 546 foreach ($request->getAllParameters() as $key => $value) { 547 $param_rows[] = array( 548 $key, 549 $this->renderAPIValue($value), 550 ); 551 } 552 } 553 554 $param_table = new AphrontTableView($param_rows); 555 $param_table->setColumnClasses( 556 array( 557 'header', 558 'wide', 559 )); 560 561 $result_rows = array(); 562 foreach ($result as $key => $value) { 563 $result_rows[] = array( 564 $key, 565 $this->renderAPIValue($value), 566 ); 567 } 568 569 $result_table = new AphrontTableView($result_rows); 570 $result_table->setColumnClasses( 571 array( 572 'header', 573 'wide', 574 )); 575 576 $param_panel = id(new PHUIObjectBoxView()) 577 ->setHeaderText(pht('Method Parameters')) 578 ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 579 ->setTable($param_table); 580 581 $result_panel = id(new PHUIObjectBoxView()) 582 ->setHeaderText(pht('Method Result')) 583 ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 584 ->setTable($result_table); 585 586 $method_uri = $this->getApplicationURI('method/'.$method.'/'); 587 588 $crumbs = $this->buildApplicationCrumbs() 589 ->addTextCrumb($method, $method_uri) 590 ->addTextCrumb(pht('Call')) 591 ->setBorder(true); 592 593 $example_panel = null; 594 if ($request && $method_implementation) { 595 $params = $request->getAllParameters(); 596 $example_panel = $this->renderExampleBox( 597 $method_implementation, 598 $params); 599 } 600 601 $title = pht('Method Call Result'); 602 $header = id(new PHUIHeaderView()) 603 ->setHeader($title) 604 ->setHeaderIcon('fa-exchange'); 605 606 $view = id(new PHUITwoColumnView()) 607 ->setHeader($header) 608 ->setFooter(array( 609 $param_panel, 610 $result_panel, 611 $example_panel, 612 )); 613 614 $title = pht('Method Call Result'); 615 616 return $this->newPage() 617 ->setTitle($title) 618 ->setCrumbs($crumbs) 619 ->appendChild($view); 620 621 } 622 623 private function renderAPIValue($value) { 624 $json = new PhutilJSON(); 625 if (is_array($value)) { 626 $value = $json->encodeFormatted($value); 627 } 628 629 $value = phutil_tag( 630 'pre', 631 array('style' => 'white-space: pre-wrap;'), 632 $value); 633 634 return $value; 635 } 636 637 private function decodeConduitParams( 638 AphrontRequest $request, 639 $method) { 640 641 $content_type = $request->getHTTPHeader('Content-Type'); 642 643 if ($content_type == 'application/json') { 644 throw new Exception( 645 pht('Use form-encoded data to submit parameters to Conduit endpoints. '. 646 'Sending a JSON-encoded body and setting \'Content-Type\': '. 647 '\'application/json\' is not currently supported.')); 648 } 649 650 // Look for parameters from the Conduit API Console, which are encoded 651 // as HTTP POST parameters in an array, e.g.: 652 // 653 // params[name]=value&params[name2]=value2 654 // 655 // The fields are individually JSON encoded, since we require users to 656 // enter JSON so that we avoid type ambiguity. 657 658 $params = $request->getArr('params', null); 659 if ($params !== null) { 660 foreach ($params as $key => $value) { 661 if ($value == '') { 662 // Interpret empty string null (e.g., the user didn't type anything 663 // into the box). 664 $value = 'null'; 665 } 666 $decoded_value = json_decode($value, true); 667 if ($decoded_value === null && strtolower($value) != 'null') { 668 // When json_decode() fails, it returns null. This almost certainly 669 // indicates that a user was using the web UI and didn't put quotes 670 // around a string value. We can either do what we think they meant 671 // (treat it as a string) or fail. For now, err on the side of 672 // caution and fail. In the future, if we make the Conduit API 673 // actually do type checking, it might be reasonable to treat it as 674 // a string if the parameter type is string. 675 throw new Exception( 676 pht( 677 "The value for parameter '%s' is not valid JSON. All ". 678 "parameters must be encoded as JSON values, including strings ". 679 "(which means you need to surround them in double quotes). ". 680 "Check your syntax. Value was: %s.", 681 $key, 682 $value)); 683 } 684 $params[$key] = $decoded_value; 685 } 686 687 $metadata = idx($params, '__conduit__', array()); 688 unset($params['__conduit__']); 689 690 return array($metadata, $params, true); 691 } 692 693 // Otherwise, look for a single parameter called 'params' which has the 694 // entire param dictionary JSON encoded. 695 $params_json = $request->getStr('params'); 696 if (phutil_nonempty_string($params_json)) { 697 $params = null; 698 try { 699 $params = phutil_json_decode($params_json); 700 } catch (PhutilJSONParserException $ex) { 701 throw new Exception( 702 pht( 703 "Invalid parameter information was passed to method '%s'.", 704 $method), 705 0, 706 $ex); 707 } 708 709 $metadata = idx($params, '__conduit__', array()); 710 unset($params['__conduit__']); 711 712 return array($metadata, $params, true); 713 } 714 715 // If we do not have `params`, assume this is a simple HTTP request with 716 // HTTP key-value pairs. 717 $params = array(); 718 $metadata = array(); 719 foreach ($request->getPassthroughRequestData() as $key => $value) { 720 $meta_key = ConduitAPIMethod::getParameterMetadataKey($key); 721 if ($meta_key !== null) { 722 $metadata[$meta_key] = $value; 723 } else { 724 $params[$key] = $value; 725 } 726 } 727 728 return array($metadata, $params, false); 729 } 730 731 private function authorizeOAuthMethodAccess( 732 PhabricatorOAuthClientAuthorization $authorization, 733 $method_name) { 734 735 $method = ConduitAPIMethod::getConduitMethod($method_name); 736 if (!$method) { 737 return false; 738 } 739 740 $required_scope = $method->getRequiredScope(); 741 switch ($required_scope) { 742 case ConduitAPIMethod::SCOPE_ALWAYS: 743 return true; 744 case ConduitAPIMethod::SCOPE_NEVER: 745 return false; 746 } 747 748 $authorization_scope = $authorization->getScope(); 749 if (!empty($authorization_scope[$required_scope])) { 750 return true; 751 } 752 753 return false; 754 } 755 756 private function getConduitCapabilities() { 757 $capabilities = array(); 758 759 if (AphrontRequestStream::supportsGzip()) { 760 $capabilities[] = 'gzip'; 761 } 762 763 return $capabilities; 764 } 765 766}