PhabricatorConduitAPIController.php 19.8 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
24
25
26
  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;

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

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

epriestley's avatar
epriestley committed
34
35
    try {

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

38
      $call = new ConduitCall($method, $params);
39

40
41
      $result = null;

epriestley's avatar
epriestley committed
42
43
44
45
46
47
      // 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.
48

epriestley's avatar
epriestley committed
49
      $api_request = $call->getAPIRequest();
epriestley's avatar
epriestley committed
50

51
      $allow_unguarded_writes = false;
52
      $auth_error = null;
53
      $conduit_username = '-';
54
55
      if ($call->shouldRequireAuthentication()) {
        $metadata['scope'] = $call->getRequiredScope();
56
        $auth_error = $this->authenticateUser($api_request, $metadata);
57
58
59
        // 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
60
61
62
63

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

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

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

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

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

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

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

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

Emil Hesslow's avatar
Emil Hesslow committed
163
164
165
166
167
168
169
170
171
172
173
  /**
   * 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) {

174
175
176
177
178
    $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
179
    if (!$api_request->getUser()->getIsAdmin()) {
180
      throw new Exception('Only administrators can use actAsUser');
Emil Hesslow's avatar
Emil Hesslow committed
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
    }

    $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);
  }

196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
  /**
   * 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()) {
211
      $request->validateCSRF();
212
213
214
      return $this->validateAuthenticatedUser(
        $api_request,
        $request->getUser());
215
216
    }

217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
    $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);
232
      $ssl_public_key = $public_key->toPKCS8();
233
234
235
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

      // 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 {
276
277
278
279
280
281
282
283
284
        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.'),
          );
        }

285
286
287
288
289
290
        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
291
292
              'device keys must originate from within the cluster.'),
          );
293
294
295
        }

        $user = PhabricatorUser::getOmnipotentUser();
epriestley's avatar
epriestley committed
296
297
298

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

      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),
      );
    }

316
317
318
319
320
321
322
323
    $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 '.
324
325
            '32 characters long.',
            $token_string),
326
327
328
329
        );
      }

      $type = head(explode('-', $token_string));
330
331
332
333
334
335
336
337
338
339
      $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)),
340
341
342
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
          );
      }

      $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),
          );
        }
      }

371
372
373
374
375
376
377
378
379
380
381
382
383
      // 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);
        }
      }

384
385
386
387
388
389
390
391
392
      // 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
393
394
              'tokens must originate from within the cluster.'),
          );
395
        }
epriestley's avatar
epriestley committed
396
397
398

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

401
402
403
404
405
406
407
408
409
410
411
412
413
414
      $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);
    }

415
    // handle oauth
416
417
    $access_token = idx($metadata, 'access_token');
    $method_scope = idx($metadata, 'scope');
418
419
    if ($access_token &&
        $method_scope != PhabricatorOAuthServerScope::SCOPE_NOT_ACCESSIBLE) {
420
421
422
      $token = id(new PhabricatorOAuthServerAccessToken())
        ->loadOneWhere('token = %s',
                       $access_token);
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
      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.',
        );
438
      }
439
440
441
442
443
444
445
446
447
448
449
450

      // 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.',
        );
      }
451
452
453
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
454
455
    }

456
457
458
459
    // Handle sessionless auth.
    // TODO: This is super messy.
    // TODO: Remove this in favor of token-based auth.

epriestley's avatar
epriestley committed
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
    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.',
        );
      }
479
480
481
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
epriestley's avatar
epriestley committed
482
483
    }

484
485
486
    // Handle session-based auth.
    // TODO: Remove this in favor of token-based auth.

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

495
    $user = id(new PhabricatorAuthSessionEngine())
496
      ->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key);
497
498
499
500

    if (!$user) {
      return array(
        'ERR-INVALID-SESSION',
501
        'Session key is invalid.',
502
503
504
      );
    }

505
506
507
508
509
510
511
512
513
    return $this->validateAuthenticatedUser(
      $api_request,
      $user);
  }

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

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

    $request->setUser($user);
522
523
524
    return null;
  }

epriestley's avatar
epriestley committed
525
526
527
528
529
530
  private function buildHumanReadableResponse(
    $method,
    ConduitAPIRequest $request = null,
    $result = null) {

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

    $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
551
        $key,
552
        $this->renderAPIValue($value),
epriestley's avatar
epriestley committed
553
554
555
556
557
558
559
560
561
562
      );
    }

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

Chad Little's avatar
Chad Little committed
563
564
    $param_panel = new PHUIObjectBoxView();
    $param_panel->setHeaderText(pht('Method Parameters'));
epriestley's avatar
epriestley committed
565
566
    $param_panel->appendChild($param_table);

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

571
572
    $method_uri = $this->getApplicationURI('method/'.$method.'/');

573
574
575
    $crumbs = $this->buildApplicationCrumbs()
      ->addTextCrumb($method, $method_uri)
      ->addTextCrumb(pht('Call'));
576
577

    return $this->buildApplicationPage(
epriestley's avatar
epriestley committed
578
      array(
579
        $crumbs,
Chad Little's avatar
Chad Little committed
580
581
        $param_panel,
        $result_panel,
epriestley's avatar
epriestley committed
582
583
      ),
      array(
Chad Little's avatar
Chad Little committed
584
        'title' => pht('Method Call Result'),
epriestley's avatar
epriestley committed
585
586
587
      ));
  }

588
589
590
591
592
593
  private function renderAPIValue($value) {
    $json = new PhutilJSON();
    if (is_array($value)) {
      $value = $json->encodeFormatted($value);
    }

594
595
596
597
    $value = phutil_tag(
      'pre',
      array('style' => 'white-space: pre-wrap;'),
      $value);
598
599
600
601

    return $value;
  }

602
603
604
  private function decodeConduitParams(
    AphrontRequest $request,
    $method) {
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639

    // 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;
      }

640
641
642
643
      $metadata = idx($params, '__conduit__', array());
      unset($params['__conduit__']);

      return array($metadata, $params);
644
645
646
    }

    // Otherwise, look for a single parameter called 'params' which has the
647
    // entire param dictionary JSON encoded.
648
    $params_json = $request->getStr('params');
649
    if (strlen($params_json)) {
650
651
652
653
654
655
656
657
658
      $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);
659
      }
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677

      $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;
      }
678
679
    }

680
    return array($metadata, $params);
681
  }
Joshua Spence's avatar
Joshua Spence committed
682

epriestley's avatar
epriestley committed
683
}