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

    try {

31
      $params = $this->decodeConduitParams($request, $method);
epriestley's avatar
epriestley committed
32
33
34
      $metadata = idx($params, '__conduit__', array());
      unset($params['__conduit__']);

35
36
      $call = new ConduitCall(
        $method, $params, idx($metadata, 'isProxied', false));
37

38
39
      $result = null;

40
41
42
43
      // 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
44
45
      $api_request = new ConduitAPIRequest($params);

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

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

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

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

78
      if ($call->shouldAllowUnguardedWrites()) {
79
        $allow_unguarded_writes = true;
80
81
      }

82
      if ($auth_error === null) {
83
84
85
        if ($allow_unguarded_writes) {
          $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
        }
Emil Hesslow's avatar
Emil Hesslow committed
86

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

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

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

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

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

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

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

212
213
214
215
216
217
218
219
220
221
222
223
224
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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
    $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);
      $ssl_public_key = $public_key->toPCKS8();

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

290
291
    // handle oauth
    $access_token = $request->getStr('access_token');
292
293
294
    $method_scope = $metadata['scope'];
    if ($access_token &&
        $method_scope != PhabricatorOAuthServerScope::SCOPE_NOT_ACCESSIBLE) {
295
296
297
      $token = id(new PhabricatorOAuthServerAccessToken())
        ->loadOneWhere('token = %s',
                       $access_token);
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
      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.',
        );
313
      }
314
315
316
317
318
319
320
321
322
323
324
325

      // 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.',
        );
      }
326
327
328
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
329
330
    }

epriestley's avatar
epriestley committed
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
    // Handle sessionless auth. TOOD: This is super messy.
    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.',
        );
      }
351
352
353
      return $this->validateAuthenticatedUser(
        $api_request,
        $user);
epriestley's avatar
epriestley committed
354
355
    }

356
357
358
    $session_key = idx($metadata, 'sessionKey');
    if (!$session_key) {
      return array(
359
        'ERR-INVALID-SESSION',
Joshua Spence's avatar
Joshua Spence committed
360
        'Session key is not present.',
361
362
363
      );
    }

364
    $user = id(new PhabricatorAuthSessionEngine())
365
      ->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key);
366
367
368
369

    if (!$user) {
      return array(
        'ERR-INVALID-SESSION',
370
        'Session key is invalid.',
371
372
373
      );
    }

374
375
376
377
378
379
380
381
382
    return $this->validateAuthenticatedUser(
      $api_request,
      $user);
  }

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

383
    if (!$user->isUserActivated()) {
384
385
      return array(
        'ERR-USER-DISABLED',
386
387
        pht('User account is not activated.'),
      );
388
389
390
    }

    $request->setUser($user);
391
392
393
    return null;
  }

epriestley's avatar
epriestley committed
394
395
396
397
398
399
  private function buildHumanReadableResponse(
    $method,
    ConduitAPIRequest $request = null,
    $result = null) {

    $param_rows = array();
400
    $param_rows[] = array('Method', $this->renderAPIValue($method));
epriestley's avatar
epriestley committed
401
402
403
    if ($request) {
      foreach ($request->getAllParameters() as $key => $value) {
        $param_rows[] = array(
epriestley's avatar
epriestley committed
404
          $key,
405
          $this->renderAPIValue($value),
epriestley's avatar
epriestley committed
406
407
408
409
410
        );
      }
    }

    $param_table = new AphrontTableView($param_rows);
411
    $param_table->setDeviceReadyTable(true);
epriestley's avatar
epriestley committed
412
413
414
415
416
417
418
419
420
    $param_table->setColumnClasses(
      array(
        'header',
        'wide',
      ));

    $result_rows = array();
    foreach ($result as $key => $value) {
      $result_rows[] = array(
epriestley's avatar
epriestley committed
421
        $key,
422
        $this->renderAPIValue($value),
epriestley's avatar
epriestley committed
423
424
425
426
      );
    }

    $result_table = new AphrontTableView($result_rows);
427
    $result_table->setDeviceReadyTable(true);
epriestley's avatar
epriestley committed
428
429
430
431
432
433
434
435
436
437
438
439
440
441
    $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
442
    $param_head = id(new PHUIHeaderView())
443
444
      ->setHeader(pht('Method Parameters'));

Chad Little's avatar
Chad Little committed
445
    $result_head = id(new PHUIHeaderView())
446
447
448
449
      ->setHeader(pht('Method Result'));

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

450
451
452
    $crumbs = $this->buildApplicationCrumbs()
      ->addTextCrumb($method, $method_uri)
      ->addTextCrumb(pht('Call'));
453
454

    return $this->buildApplicationPage(
epriestley's avatar
epriestley committed
455
      array(
456
457
458
459
460
        $crumbs,
        $param_head,
        $param_table,
        $result_head,
        $result_table,
epriestley's avatar
epriestley committed
461
462
463
464
465
466
      ),
      array(
        'title' => 'Method Call Result',
      ));
  }

467
468
469
470
471
472
  private function renderAPIValue($value) {
    $json = new PhutilJSON();
    if (is_array($value)) {
      $value = $json->encodeFormatted($value);
    }

473
474
475
476
    $value = phutil_tag(
      'pre',
      array('style' => 'white-space: pre-wrap;'),
      $value);
477
478
479
480

    return $value;
  }

481
482
483
  private function decodeConduitParams(
    AphrontRequest $request,
    $method) {
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527

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

      return $params;
    }

    // Otherwise, look for a single parameter called 'params' which has the
    // entire param dictionary JSON encoded. This is the usual case for remote
    // requests.

    $params_json = $request->getStr('params');
    if (!strlen($params_json)) {
528
529
530
531
532
533
534
535
536
537
538
539
      if ($request->getBool('allowEmptyParams')) {
        // TODO: This is a bit messy, but otherwise you can't call
        // "conduit.ping" from the web console.
        $params = array();
      } else {
        throw new Exception(
          "Request has no 'params' key. This may mean that an extension like ".
          "Suhosin has dropped data from the request. Check the PHP ".
          "configuration on your server. If you are developing a Conduit ".
          "client, you MUST provide a 'params' parameter when making a ".
          "Conduit request, even if the value is empty (e.g., provide '{}').");
      }
540
541
542
543
544
    } else {
      $params = json_decode($params_json, true);
      if (!is_array($params)) {
        throw new Exception(
          "Invalid parameter information was passed to method ".
545
546
          "'{$method}', could not decode JSON serialization. Data: ".
          $params_json);
547
548
549
550
551
      }
    }

    return $params;
  }
Joshua Spence's avatar
Joshua Spence committed
552

epriestley's avatar
epriestley committed
553
}