PhabricatorConduitAPIController.php 20.2 KB
Newer Older
epriestley's avatar
epriestley committed
1
2
<?php

3
final class PhabricatorConduitAPIController
epriestley's avatar
epriestley committed
4
5
  extends PhabricatorConduitController {

epriestley's avatar
epriestley committed
6
7
8
9
  public function shouldRequireLogin() {
    return false;
  }

epriestley's avatar
epriestley committed
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  private $method;

  public function willProcessRequest(array $data) {
    $this->method = $data['method'];
    return $this;
  }

  public function processRequest() {
    $time_start = microtime(true);
    $request = $this->getRequest();

    $method = $this->method;

    $api_request = null;
24
    $method_implementation = null;
epriestley's avatar
epriestley committed
25
26
27

    $log = new PhabricatorConduitMethodCallLog();
    $log->setMethod($method);
28
    $metadata = array();
epriestley's avatar
epriestley committed
29

30
31
32
33
34
    $multimeter = MultimeterControl::getInstance();
    if ($multimeter) {
      $multimeter->setEventContext('api.'.$method);
    }

epriestley's avatar
epriestley committed
35
36
    try {

37
      list($metadata, $params) = $this->decodeConduitParams($request, $method);
epriestley's avatar
epriestley committed
38

39
      $call = new ConduitCall($method, $params);
40
      $method_implementation = $call->getMethodImplementation();
41

42
43
      $result = null;

epriestley's avatar
epriestley committed
44
45
46
47
48
49
      // TODO: The relationship between ConduitAPIRequest and ConduitCall is a
      // little odd here and could probably be improved. Specifically, the
      // APIRequest is a sub-object of the Call, which does not parallel the
      // role of AphrontRequest (which is an indepenent object).
      // In particular, the setUser() and getUser() existing independently on
      // the Call and APIRequest is very awkward.
50

epriestley's avatar
epriestley committed
51
      $api_request = $call->getAPIRequest();
epriestley's avatar
epriestley committed
52

53
      $allow_unguarded_writes = false;
54
      $auth_error = null;
55
      $conduit_username = '-';
56
57
      if ($call->shouldRequireAuthentication()) {
        $metadata['scope'] = $call->getRequiredScope();
58
        $auth_error = $this->authenticateUser($api_request, $metadata);
59
60
61
        // If we've explicitly authenticated the user here and either done
        // CSRF validation or are using a non-web authentication mechanism.
        $allow_unguarded_writes = true;
Emil Hesslow's avatar
Emil Hesslow committed
62
63
64
65

        if (isset($metadata['actAsUser'])) {
          $this->actAsUser($api_request, $metadata['actAsUser']);
        }
66

67
68
69
70
71
        if ($auth_error === null) {
          $conduit_user = $api_request->getUser();
          if ($conduit_user && $conduit_user->getPHID()) {
            $conduit_username = $conduit_user->getUsername();
          }
72
          $call->setUser($api_request->getUser());
73
        }
74
75
76
77
      }

      $access_log = PhabricatorAccessLog::getLog();
      if ($access_log) {
78
79
80
81
82
83
84
        $access_log->setData(
          array(
            'u' => $conduit_username,
            'm' => $method,
          ));
      }

85
      if ($call->shouldAllowUnguardedWrites()) {
86
        $allow_unguarded_writes = true;
87
88
      }

89
      if ($auth_error === null) {
90
91
92
        if ($allow_unguarded_writes) {
          $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
        }
Emil Hesslow's avatar
Emil Hesslow committed
93

94
        try {
95
          $result = $call->execute();
96
97
98
99
100
          $error_code = null;
          $error_info = null;
        } catch (ConduitException $ex) {
          $result = null;
          $error_code = $ex->getMessage();
101
102
103
          if ($ex->getErrorDescription()) {
            $error_info = $ex->getErrorDescription();
          } else {
104
            $error_info = $call->getErrorDescription($error_code);
105
          }
106
        }
107
108
109
        if ($allow_unguarded_writes) {
          unset($unguarded);
        }
110
111
      } else {
        list($error_code, $error_info) = $auth_error;
epriestley's avatar
epriestley committed
112
113
      }
    } catch (Exception $ex) {
114
115
116
      if (!($ex instanceof ConduitMethodNotFoundException)) {
        phlog($ex);
      }
epriestley's avatar
epriestley committed
117
      $result = null;
118
119
120
      $error_code = ($ex instanceof ConduitException
        ? 'ERR-CONDUIT-CALL'
        : 'ERR-CONDUIT-CORE');
epriestley's avatar
epriestley committed
121
122
123
124
125
126
127
128
129
130
131
132
      $error_info = $ex->getMessage();
    }

    $time_end = microtime(true);

    $connection_id = null;
    if (idx($metadata, 'connectionID')) {
      $connection_id = $metadata['connectionID'];
    } else if (($method == 'conduit.connect') && $result) {
      $connection_id = idx($result, 'connectionID');
    }

epriestley's avatar
epriestley committed
133
134
135
136
137
138
139
140
141
142
143
144
    $log
      ->setCallerPHID(
        isset($conduit_user)
          ? $conduit_user->getPHID()
          : null)
      ->setConnectionID($connection_id)
      ->setError((string)$error_code)
      ->setDuration(1000000 * ($time_end - $time_start));

    $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
    $log->save();
    unset($unguarded);
epriestley's avatar
epriestley committed
145

146
147
148
149
    $response = id(new ConduitAPIResponse())
      ->setResult($result)
      ->setErrorCode($error_code)
      ->setErrorInfo($error_info);
epriestley's avatar
epriestley committed
150
151
152
153
154
155

    switch ($request->getStr('output')) {
      case 'human':
        return $this->buildHumanReadableResponse(
          $method,
          $api_request,
156
157
          $response->toDictionary(),
          $method_implementation);
epriestley's avatar
epriestley committed
158
159
      case 'json':
      default:
160
        return id(new AphrontJSONResponse())
161
          ->setAddJSONShield(false)
162
          ->setContent($response->toDictionary());
epriestley's avatar
epriestley committed
163
164
165
    }
  }

Emil Hesslow's avatar
Emil Hesslow committed
166
167
168
169
170
171
172
173
174
175
176
  /**
   * Change the api request user to the user that we want to act as.
   * Only admins can use actAsUser
   *
   * @param   ConduitAPIRequest Request being executed.
   * @param   string            The username of the user we want to act as
   */
  private function actAsUser(
    ConduitAPIRequest $api_request,
    $user_name) {

177
178
179
180
181
    $config_key = 'security.allow-conduit-act-as-user';
    if (!PhabricatorEnv::getEnvConfig($config_key)) {
      throw new Exception('security.allow-conduit-act-as-user is disabled');
    }

Emil Hesslow's avatar
Emil Hesslow committed
182
    if (!$api_request->getUser()->getIsAdmin()) {
183
      throw new Exception('Only administrators can use actAsUser');
Emil Hesslow's avatar
Emil Hesslow committed
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
    }

    $user = id(new PhabricatorUser())->loadOneWhere(
      'userName = %s',
      $user_name);

    if (!$user) {
      throw new Exception(
        "The actAsUser username '{$user_name}' is not a valid user."
      );
    }

    $api_request->setUser($user);
  }

199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
  /**
   * Authenticate the client making the request to a Phabricator user account.
   *
   * @param   ConduitAPIRequest Request being executed.
   * @param   dict              Request metadata.
   * @return  null|pair         Null to indicate successful authentication, or
   *                            an error code and error message pair.
   */
  private function authenticateUser(
    ConduitAPIRequest $api_request,
    array $metadata) {

    $request = $this->getRequest();

    if ($request->getUser()->getPHID()) {
214
      $request->validateCSRF();
215
216
217
      return $this->validateAuthenticatedUser(
        $api_request,
        $request->getUser());
218
219
    }

220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
    $auth_type = idx($metadata, 'auth.type');
    if ($auth_type === ConduitClient::AUTH_ASYMMETRIC) {
      $host = idx($metadata, 'auth.host');
      if (!$host) {
        return array(
          'ERR-INVALID-AUTH',
          pht(
            'Request is missing required "auth.host" parameter.'),
        );
      }

      // TODO: Validate that we are the host!

      $raw_key = idx($metadata, 'auth.key');
      $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($raw_key);
235
      $ssl_public_key = $public_key->toPKCS8();
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278

      // First, verify the signature.
      try {
        $protocol_data = $metadata;

        // TODO: We should stop writing this into the protocol data when
        // processing a request.
        unset($protocol_data['scope']);

        ConduitClient::verifySignature(
          $this->method,
          $api_request->getAllParameters(),
          $protocol_data,
          $ssl_public_key);
      } catch (Exception $ex) {
        return array(
          'ERR-INVALID-AUTH',
          pht(
            'Signature verification failure. %s',
            $ex->getMessage()),
        );
      }

      // If the signature is valid, find the user or device which is
      // associated with this public key.

      $stored_key = id(new PhabricatorAuthSSHKeyQuery())
        ->setViewer(PhabricatorUser::getOmnipotentUser())
        ->withKeys(array($public_key))
        ->executeOne();
      if (!$stored_key) {
        return array(
          'ERR-INVALID-AUTH',
          pht(
            'No user or device is associated with that public key.'),
        );
      }

      $object = $stored_key->getObject();

      if ($object instanceof PhabricatorUser) {
        $user = $object;
      } else {
279
280
281
282
283
284
285
286
287
        if (!$stored_key->getIsTrusted()) {
          return array(
            'ERR-INVALID-AUTH',
            pht(
              'The key which signed this request is not trusted. Only '.
              'trusted keys can be used to sign API calls.'),
          );
        }

288
289
290
291
292
293
        if (!PhabricatorEnv::isClusterRemoteAddress()) {
          return array(
            'ERR-INVALID-AUTH',
            pht(
              'This request originates from outside of the Phabricator '.
              'cluster address range. Requests signed with trusted '.
Joshua Spence's avatar
Joshua Spence committed
294
295
              'device keys must originate from within the cluster.'),
          );
296
297
298
        }

        $user = PhabricatorUser::getOmnipotentUser();
epriestley's avatar
epriestley committed
299
300
301

        // Flag this as an intracluster request.
        $api_request->setIsClusterRequest(true);
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
      }

      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
    } else if ($auth_type === null) {
      // No specified authentication type, continue with other authentication
      // methods below.
    } else {
      return array(
        'ERR-INVALID-AUTH',
        pht(
          'Provided "auth.type" ("%s") is not recognized.',
          $auth_type),
      );
    }

319
320
321
322
323
324
325
326
    $token_string = idx($metadata, 'token');
    if (strlen($token_string)) {

      if (strlen($token_string) != 32) {
        return array(
          'ERR-INVALID-AUTH',
          pht(
            'API token "%s" has the wrong length. API tokens should be '.
327
328
            '32 characters long.',
            $token_string),
329
330
331
332
        );
      }

      $type = head(explode('-', $token_string));
333
334
335
336
337
338
339
340
341
342
      $valid_types = PhabricatorConduitToken::getAllTokenTypes();
      $valid_types = array_fuse($valid_types);
      if (empty($valid_types[$type])) {
        return array(
          'ERR-INVALID-AUTH',
          pht(
            'API token "%s" has the wrong format. API tokens should be '.
            '32 characters long and begin with one of these prefixes: %s.',
            $token_string,
            implode(', ', $valid_types)),
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
          );
      }

      $token = id(new PhabricatorConduitTokenQuery())
        ->setViewer(PhabricatorUser::getOmnipotentUser())
        ->withTokens(array($token_string))
        ->withExpired(false)
        ->executeOne();
      if (!$token) {
        $token = id(new PhabricatorConduitTokenQuery())
          ->setViewer(PhabricatorUser::getOmnipotentUser())
          ->withTokens(array($token_string))
          ->withExpired(true)
          ->executeOne();
        if ($token) {
          return array(
            'ERR-INVALID-AUTH',
            pht(
              'API token "%s" was previously valid, but has expired.',
              $token_string),
          );
        } else {
          return array(
            'ERR-INVALID-AUTH',
            pht(
              'API token "%s" is not valid.',
              $token_string),
          );
        }
      }

374
375
376
377
378
379
380
381
382
383
384
385
386
      // If this is a "cli-" token, it expires shortly after it is generated
      // by default. Once it is actually used, we extend its lifetime and make
      // it permanent. This allows stray tokens to get cleaned up automatically
      // if they aren't being used.
      if ($token->getTokenType() == PhabricatorConduitToken::TYPE_COMMANDLINE) {
        if ($token->getExpires()) {
          $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
            $token->setExpires(null);
            $token->save();
          unset($unguarded);
        }
      }

387
388
389
390
391
392
393
394
395
      // If this is a "clr-" token, Phabricator must be configured in cluster
      // mode and the remote address must be a cluster node.
      if ($token->getTokenType() == PhabricatorConduitToken::TYPE_CLUSTER) {
        if (!PhabricatorEnv::isClusterRemoteAddress()) {
          return array(
            'ERR-INVALID-AUTH',
            pht(
              'This request originates from outside of the Phabricator '.
              'cluster address range. Requests signed with cluster API '.
Joshua Spence's avatar
Joshua Spence committed
396
397
              'tokens must originate from within the cluster.'),
          );
398
        }
epriestley's avatar
epriestley committed
399
400
401

        // Flag this as an intracluster request.
        $api_request->setIsClusterRequest(true);
402
403
      }

404
405
406
407
408
409
410
411
412
413
414
415
416
417
      $user = $token->getObject();
      if (!($user instanceof PhabricatorUser)) {
        return array(
          'ERR-INVALID-AUTH',
          pht(
            'API token is not associated with a valid user.'),
        );
      }

      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
    }

418
    // handle oauth
419
420
    $access_token = idx($metadata, 'access_token');
    $method_scope = idx($metadata, 'scope');
421
422
    if ($access_token &&
        $method_scope != PhabricatorOAuthServerScope::SCOPE_NOT_ACCESSIBLE) {
423
424
425
      $token = id(new PhabricatorOAuthServerAccessToken())
        ->loadOneWhere('token = %s',
                       $access_token);
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
      if (!$token) {
        return array(
          'ERR-INVALID-AUTH',
          'Access token does not exist.',
        );
      }

      $oauth_server = new PhabricatorOAuthServer();
      $valid = $oauth_server->validateAccessToken($token,
                                                  $method_scope);
      if (!$valid) {
        return array(
          'ERR-INVALID-AUTH',
          'Access token is invalid.',
        );
441
      }
442
443
444
445
446
447
448
449
450
451
452
453

      // valid token, so let's log in the user!
      $user_phid = $token->getUserPHID();
      $user = id(new PhabricatorUser())
        ->loadOneWhere('phid = %s',
                       $user_phid);
      if (!$user) {
        return array(
          'ERR-INVALID-AUTH',
          'Access token is for invalid user.',
        );
      }
454
455
456
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
457
458
    }

459
460
461
462
    // Handle sessionless auth.
    // TODO: This is super messy.
    // TODO: Remove this in favor of token-based auth.

epriestley's avatar
epriestley committed
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
    if (isset($metadata['authUser'])) {
      $user = id(new PhabricatorUser())->loadOneWhere(
        'userName = %s',
        $metadata['authUser']);
      if (!$user) {
        return array(
          'ERR-INVALID-AUTH',
          'Authentication is invalid.',
        );
      }
      $token = idx($metadata, 'authToken');
      $signature = idx($metadata, 'authSignature');
      $certificate = $user->getConduitCertificate();
      if (sha1($token.$certificate) !== $signature) {
        return array(
          'ERR-INVALID-AUTH',
          'Authentication is invalid.',
        );
      }
482
483
484
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
epriestley's avatar
epriestley committed
485
486
    }

487
488
489
    // Handle session-based auth.
    // TODO: Remove this in favor of token-based auth.

490
491
492
    $session_key = idx($metadata, 'sessionKey');
    if (!$session_key) {
      return array(
493
        'ERR-INVALID-SESSION',
Joshua Spence's avatar
Joshua Spence committed
494
        'Session key is not present.',
495
496
497
      );
    }

498
    $user = id(new PhabricatorAuthSessionEngine())
499
      ->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key);
500
501
502
503

    if (!$user) {
      return array(
        'ERR-INVALID-SESSION',
504
        'Session key is invalid.',
505
506
507
      );
    }

508
509
510
511
512
513
514
515
516
    return $this->validateAuthenticatedUser(
      $api_request,
      $user);
  }

  private function validateAuthenticatedUser(
    ConduitAPIRequest $request,
    PhabricatorUser $user) {

517
    if (!$user->isUserActivated()) {
518
519
      return array(
        'ERR-USER-DISABLED',
520
521
        pht('User account is not activated.'),
      );
522
523
524
    }

    $request->setUser($user);
525
526
527
    return null;
  }

epriestley's avatar
epriestley committed
528
529
530
  private function buildHumanReadableResponse(
    $method,
    ConduitAPIRequest $request = null,
531
532
    $result = null,
    ConduitAPIMethod $method_implementation = null) {
epriestley's avatar
epriestley committed
533
534

    $param_rows = array();
535
    $param_rows[] = array('Method', $this->renderAPIValue($method));
epriestley's avatar
epriestley committed
536
537
538
    if ($request) {
      foreach ($request->getAllParameters() as $key => $value) {
        $param_rows[] = array(
epriestley's avatar
epriestley committed
539
          $key,
540
          $this->renderAPIValue($value),
epriestley's avatar
epriestley committed
541
542
543
544
545
546
547
548
549
550
551
552
553
554
        );
      }
    }

    $param_table = new AphrontTableView($param_rows);
    $param_table->setColumnClasses(
      array(
        'header',
        'wide',
      ));

    $result_rows = array();
    foreach ($result as $key => $value) {
      $result_rows[] = array(
epriestley's avatar
epriestley committed
555
        $key,
556
        $this->renderAPIValue($value),
epriestley's avatar
epriestley committed
557
558
559
560
561
562
563
564
565
566
      );
    }

    $result_table = new AphrontTableView($result_rows);
    $result_table->setColumnClasses(
      array(
        'header',
        'wide',
      ));

Chad Little's avatar
Chad Little committed
567
568
    $param_panel = new PHUIObjectBoxView();
    $param_panel->setHeaderText(pht('Method Parameters'));
epriestley's avatar
epriestley committed
569
570
    $param_panel->appendChild($param_table);

Chad Little's avatar
Chad Little committed
571
572
    $result_panel = new PHUIObjectBoxView();
    $result_panel->setHeaderText(pht('Method Result'));
epriestley's avatar
epriestley committed
573
574
    $result_panel->appendChild($result_table);

575
576
    $method_uri = $this->getApplicationURI('method/'.$method.'/');

577
578
579
    $crumbs = $this->buildApplicationCrumbs()
      ->addTextCrumb($method, $method_uri)
      ->addTextCrumb(pht('Call'));
580

581
582
583
584
585
586
587
588
    $example_panel = null;
    if ($request && $method_implementation) {
      $params = $request->getAllParameters();
      $example_panel = $this->renderExampleBox(
        $method_implementation,
        $params);
    }

589
    return $this->buildApplicationPage(
epriestley's avatar
epriestley committed
590
      array(
591
        $crumbs,
Chad Little's avatar
Chad Little committed
592
593
        $param_panel,
        $result_panel,
594
        $example_panel,
epriestley's avatar
epriestley committed
595
596
      ),
      array(
Chad Little's avatar
Chad Little committed
597
        'title' => pht('Method Call Result'),
epriestley's avatar
epriestley committed
598
599
600
      ));
  }

601
602
603
604
605
606
  private function renderAPIValue($value) {
    $json = new PhutilJSON();
    if (is_array($value)) {
      $value = $json->encodeFormatted($value);
    }

607
608
609
610
    $value = phutil_tag(
      'pre',
      array('style' => 'white-space: pre-wrap;'),
      $value);
611
612
613
614

    return $value;
  }

615
616
617
  private function decodeConduitParams(
    AphrontRequest $request,
    $method) {
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652

    // Look for parameters from the Conduit API Console, which are encoded
    // as HTTP POST parameters in an array, e.g.:
    //
    //   params[name]=value&params[name2]=value2
    //
    // The fields are individually JSON encoded, since we require users to
    // enter JSON so that we avoid type ambiguity.

    $params = $request->getArr('params', null);
    if ($params !== null) {
      foreach ($params as $key => $value) {
        if ($value == '') {
          // Interpret empty string null (e.g., the user didn't type anything
          // into the box).
          $value = 'null';
        }
        $decoded_value = json_decode($value, true);
        if ($decoded_value === null && strtolower($value) != 'null') {
          // When json_decode() fails, it returns null. This almost certainly
          // indicates that a user was using the web UI and didn't put quotes
          // around a string value. We can either do what we think they meant
          // (treat it as a string) or fail. For now, err on the side of
          // caution and fail. In the future, if we make the Conduit API
          // actually do type checking, it might be reasonable to treat it as
          // a string if the parameter type is string.
          throw new Exception(
            "The value for parameter '{$key}' is not valid JSON. All ".
            "parameters must be encoded as JSON values, including strings ".
            "(which means you need to surround them in double quotes). ".
            "Check your syntax. Value was: {$value}");
        }
        $params[$key] = $decoded_value;
      }

653
654
655
656
      $metadata = idx($params, '__conduit__', array());
      unset($params['__conduit__']);

      return array($metadata, $params);
657
658
659
    }

    // Otherwise, look for a single parameter called 'params' which has the
660
    // entire param dictionary JSON encoded.
661
    $params_json = $request->getStr('params');
662
    if (strlen($params_json)) {
663
664
665
666
667
668
669
670
671
      $params = null;
      try {
        $params = phutil_json_decode($params_json);
      } catch (PhutilJSONParserException $ex) {
        throw new PhutilProxyException(
          pht(
            "Invalid parameter information was passed to method '%s'",
            $method),
          $ex);
672
      }
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690

      $metadata = idx($params, '__conduit__', array());
      unset($params['__conduit__']);

      return array($metadata, $params);
    }

    // If we do not have `params`, assume this is a simple HTTP request with
    // HTTP key-value pairs.
    $params = array();
    $metadata = array();
    foreach ($request->getPassthroughRequestData() as $key => $value) {
      $meta_key = ConduitAPIMethod::getParameterMetadataKey($key);
      if ($meta_key !== null) {
        $metadata[$meta_key] = $value;
      } else {
        $params[$key] = $value;
      }
691
692
    }

693
    return array($metadata, $params);
694
  }
Joshua Spence's avatar
Joshua Spence committed
695

epriestley's avatar
epriestley committed
696
}