@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<?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¶ms[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}