PhabricatorConduitAPIController.php 18.7 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

    try {

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

33
      $call = new ConduitCall($method, $params);
34

35
36
      $result = null;

37
38
39
40
      // TODO: Straighten out the auth pathway here. We shouldn't be creating
      // a ConduitAPIRequest at this level, but some of the auth code expects
      // it. Landing a halfway version of this to unblock T945.

epriestley's avatar
epriestley committed
41
42
      $api_request = new ConduitAPIRequest($params);

43
      $allow_unguarded_writes = false;
44
      $auth_error = null;
45
      $conduit_username = '-';
46
47
      if ($call->shouldRequireAuthentication()) {
        $metadata['scope'] = $call->getRequiredScope();
48
        $auth_error = $this->authenticateUser($api_request, $metadata);
49
50
51
        // 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
52
53
54
55

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

57
58
59
60
61
        if ($auth_error === null) {
          $conduit_user = $api_request->getUser();
          if ($conduit_user && $conduit_user->getPHID()) {
            $conduit_username = $conduit_user->getUsername();
          }
62
          $call->setUser($api_request->getUser());
63
        }
64
65
66
67
      }

      $access_log = PhabricatorAccessLog::getLog();
      if ($access_log) {
68
69
70
71
72
73
74
        $access_log->setData(
          array(
            'u' => $conduit_username,
            'm' => $method,
          ));
      }

75
      if ($call->shouldAllowUnguardedWrites()) {
76
        $allow_unguarded_writes = true;
77
78
      }

79
      if ($auth_error === null) {
80
81
82
        if ($allow_unguarded_writes) {
          $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
        }
Emil Hesslow's avatar
Emil Hesslow committed
83

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

136
137
138
139
    $response = id(new ConduitAPIResponse())
      ->setResult($result)
      ->setErrorCode($error_code)
      ->setErrorInfo($error_info);
epriestley's avatar
epriestley committed
140
141
142
143
144
145

    switch ($request->getStr('output')) {
      case 'human':
        return $this->buildHumanReadableResponse(
          $method,
          $api_request,
146
          $response->toDictionary());
epriestley's avatar
epriestley committed
147
148
      case 'json':
      default:
149
        return id(new AphrontJSONResponse())
150
          ->setAddJSONShield(false)
151
          ->setContent($response->toDictionary());
epriestley's avatar
epriestley committed
152
153
154
    }
  }

Emil Hesslow's avatar
Emil Hesslow committed
155
156
157
158
159
160
161
162
163
164
165
  /**
   * 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) {

166
167
168
169
170
    $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
171
    if (!$api_request->getUser()->getIsAdmin()) {
172
      throw new Exception('Only administrators can use actAsUser');
Emil Hesslow's avatar
Emil Hesslow committed
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
    }

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

188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
  /**
   * 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()) {
203
      $request->validateCSRF();
204
205
206
      return $this->validateAuthenticatedUser(
        $api_request,
        $request->getUser());
207
208
    }

209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
    $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);
224
      $ssl_public_key = $public_key->toPKCS8();
225
226
227
228
229
230
231
232
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

      // 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 {
268
269
270
271
272
273
274
275
276
        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.'),
          );
        }

277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
        throw new Exception(
          pht('Not Implemented: Would authenticate Almanac device.'));
      }

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

296
297
298
299
300
301
302
303
    $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 '.
304
305
            '32 characters long.',
            $token_string),
306
307
308
309
        );
      }

      $type = head(explode('-', $token_string));
310
311
312
313
314
315
316
317
318
319
      $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)),
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
          );
      }

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

351
352
353
354
355
356
357
358
359
360
361
362
363
      // 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);
        }
      }

364
365
366
367
368
369
370
371
372
373
374
375
376
377
      $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);
    }

378
    // handle oauth
379
380
    $access_token = idx($metadata, 'access_token');
    $method_scope = idx($metadata, 'scope');
381
382
    if ($access_token &&
        $method_scope != PhabricatorOAuthServerScope::SCOPE_NOT_ACCESSIBLE) {
383
384
385
      $token = id(new PhabricatorOAuthServerAccessToken())
        ->loadOneWhere('token = %s',
                       $access_token);
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
      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.',
        );
401
      }
402
403
404
405
406
407
408
409
410
411
412
413

      // 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.',
        );
      }
414
415
416
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
417
418
    }

419
420
421
422
    // Handle sessionless auth.
    // TODO: This is super messy.
    // TODO: Remove this in favor of token-based auth.

epriestley's avatar
epriestley committed
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
    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.',
        );
      }
442
443
444
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
epriestley's avatar
epriestley committed
445
446
    }

447
448
449
    // Handle session-based auth.
    // TODO: Remove this in favor of token-based auth.

450
451
452
    $session_key = idx($metadata, 'sessionKey');
    if (!$session_key) {
      return array(
453
        'ERR-INVALID-SESSION',
Joshua Spence's avatar
Joshua Spence committed
454
        'Session key is not present.',
455
456
457
      );
    }

458
    $user = id(new PhabricatorAuthSessionEngine())
459
      ->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key);
460
461
462
463

    if (!$user) {
      return array(
        'ERR-INVALID-SESSION',
464
        'Session key is invalid.',
465
466
467
      );
    }

468
469
470
471
472
473
474
475
476
    return $this->validateAuthenticatedUser(
      $api_request,
      $user);
  }

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

477
    if (!$user->isUserActivated()) {
478
479
      return array(
        'ERR-USER-DISABLED',
480
481
        pht('User account is not activated.'),
      );
482
483
484
    }

    $request->setUser($user);
485
486
487
    return null;
  }

epriestley's avatar
epriestley committed
488
489
490
491
492
493
  private function buildHumanReadableResponse(
    $method,
    ConduitAPIRequest $request = null,
    $result = null) {

    $param_rows = array();
494
    $param_rows[] = array('Method', $this->renderAPIValue($method));
epriestley's avatar
epriestley committed
495
496
497
    if ($request) {
      foreach ($request->getAllParameters() as $key => $value) {
        $param_rows[] = array(
epriestley's avatar
epriestley committed
498
          $key,
499
          $this->renderAPIValue($value),
epriestley's avatar
epriestley committed
500
501
502
503
504
        );
      }
    }

    $param_table = new AphrontTableView($param_rows);
505
    $param_table->setDeviceReadyTable(true);
epriestley's avatar
epriestley committed
506
507
508
509
510
511
512
513
514
    $param_table->setColumnClasses(
      array(
        'header',
        'wide',
      ));

    $result_rows = array();
    foreach ($result as $key => $value) {
      $result_rows[] = array(
epriestley's avatar
epriestley committed
515
        $key,
516
        $this->renderAPIValue($value),
epriestley's avatar
epriestley committed
517
518
519
520
      );
    }

    $result_table = new AphrontTableView($result_rows);
521
    $result_table->setDeviceReadyTable(true);
epriestley's avatar
epriestley committed
522
523
524
525
526
527
528
529
530
531
532
533
534
535
    $result_table->setColumnClasses(
      array(
        'header',
        'wide',
      ));

    $param_panel = new AphrontPanelView();
    $param_panel->setHeader('Method Parameters');
    $param_panel->appendChild($param_table);

    $result_panel = new AphrontPanelView();
    $result_panel->setHeader('Method Result');
    $result_panel->appendChild($result_table);

Chad Little's avatar
Chad Little committed
536
    $param_head = id(new PHUIHeaderView())
537
538
      ->setHeader(pht('Method Parameters'));

Chad Little's avatar
Chad Little committed
539
    $result_head = id(new PHUIHeaderView())
540
541
542
543
      ->setHeader(pht('Method Result'));

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

544
545
546
    $crumbs = $this->buildApplicationCrumbs()
      ->addTextCrumb($method, $method_uri)
      ->addTextCrumb(pht('Call'));
547
548

    return $this->buildApplicationPage(
epriestley's avatar
epriestley committed
549
      array(
550
551
552
553
554
        $crumbs,
        $param_head,
        $param_table,
        $result_head,
        $result_table,
epriestley's avatar
epriestley committed
555
556
557
558
559
560
      ),
      array(
        'title' => 'Method Call Result',
      ));
  }

561
562
563
564
565
566
  private function renderAPIValue($value) {
    $json = new PhutilJSON();
    if (is_array($value)) {
      $value = $json->encodeFormatted($value);
    }

567
568
569
570
    $value = phutil_tag(
      'pre',
      array('style' => 'white-space: pre-wrap;'),
      $value);
571
572
573
574

    return $value;
  }

575
576
577
  private function decodeConduitParams(
    AphrontRequest $request,
    $method) {
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612

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

613
614
615
616
      $metadata = idx($params, '__conduit__', array());
      unset($params['__conduit__']);

      return array($metadata, $params);
617
618
619
    }

    // Otherwise, look for a single parameter called 'params' which has the
620
    // entire param dictionary JSON encoded.
621
    $params_json = $request->getStr('params');
622
    if (strlen($params_json)) {
623
624
625
626
      $params = json_decode($params_json, true);
      if (!is_array($params)) {
        throw new Exception(
          "Invalid parameter information was passed to method ".
627
628
          "'{$method}', could not decode JSON serialization. Data: ".
          $params_json);
629
      }
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647

      $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;
      }
648
649
    }

650
    return array($metadata, $params);
651
  }
Joshua Spence's avatar
Joshua Spence committed
652

epriestley's avatar
epriestley committed
653
}