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