From 39f2bbaeea1b0cf54023f3a6d05f9dedc0499625 Mon Sep 17 00:00:00 2001 From: epriestley Date: Mon, 15 Dec 2014 11:14:23 -0800 Subject: [PATCH] Add Conduit Tokens to make authentication in Conduit somewhat more sane Summary: Ref T5955. Summary of intended changes: **Improve Granularity of Authorization**: Currently, users have one Conduit Certificate. This isn't very flexible, and means that you can't ever generate an API token with limited permissions or IP block controls (see T6706). This moves toward a world where you can generate multiple tokens, revoke them individually, and assign disparate privileges to them. **Standardize Token Management**: This moves Conduit to work the same way that sessions, OAuth authorizations, and temporary tokens already work, instead of being this crazy bizarre mess. **Make Authentication Faster**: Authentication currently requires a handshake (conduit.connect) to establish a session, like the web UI. This is unnecessary from a security point of view and puts an extra round trip in front of all Conduit activity. Essentially no other API anywhere works like this. **Make Authentication Simpler**: The handshake is complex, and involves deriving hashes. The session is also complex, and creates issues like T4377. Handshake and session management require different inputs. **Make Token Management Simpler**: The certificate is this huge long thing right now, which is not necessary from a security perspective. There are separate Arcanist handshake tokens, but they have a different set of issues. We can move forward to a token management world where neither of these problems exist. **Lower Protocol Barrier**: The simplest possible API client is very complex right now. It should be `curl`. Simplifying authentication is a necessary step toward this. **Unblock T2783**: T2783 is blocked on nodes in the cluster making authenticated API calls to other nodes. This provides a simpler way forward than the handshake mess (or enormous-hack-mess) which would currently be required. Test Plan: - Generated tokens. - Generated tokens for a bot account. - Terminated tokens (and for a bot account). - Terminated all tokens (and for a bot account). - Ran GC and saw it reap all the expired tokens. NOTE: These tokens can not actually be used to authenticate yet! {F249658} Reviewers: btrahan Reviewed By: btrahan Subscribers: epriestley Maniphest Tasks: T5955 Differential Revision: https://secure.phabricator.com/D10985 --- .../sql/autopatches/20141212.conduittoken.sql | 12 ++ src/__phutil_library_map__.php | 15 +++ .../PhabricatorConduitApplication.php | 4 + .../PhabricatorConduitTokenEditController.php | 100 +++++++++++++++ ...ricatorConduitTokenTerminateController.php | 96 +++++++++++++++ .../ConduitTokenGarbageCollector.php | 19 +++ .../query/PhabricatorConduitTokenQuery.php | 102 +++++++++++++++ .../PhabricatorConduitSettingsPanel.php | 116 ++++++++++++++++++ .../storage/PhabricatorConduitToken.php | 106 ++++++++++++++++ 9 files changed, 570 insertions(+) create mode 100644 resources/sql/autopatches/20141212.conduittoken.sql create mode 100644 src/applications/conduit/controller/PhabricatorConduitTokenEditController.php create mode 100644 src/applications/conduit/controller/PhabricatorConduitTokenTerminateController.php create mode 100644 src/applications/conduit/garbagecollector/ConduitTokenGarbageCollector.php create mode 100644 src/applications/conduit/query/PhabricatorConduitTokenQuery.php create mode 100644 src/applications/conduit/settings/PhabricatorConduitSettingsPanel.php create mode 100644 src/applications/conduit/storage/PhabricatorConduitToken.php diff --git a/resources/sql/autopatches/20141212.conduittoken.sql b/resources/sql/autopatches/20141212.conduittoken.sql new file mode 100644 index 0000000000..d3abda89a6 --- /dev/null +++ b/resources/sql/autopatches/20141212.conduittoken.sql @@ -0,0 +1,12 @@ +CREATE TABLE {$NAMESPACE}_conduit.conduit_token ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + objectPHID VARBINARY(64) NOT NULL, + tokenType VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL, + token VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL, + expires INT UNSIGNED, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + KEY `key_object` (objectPHID, tokenType), + UNIQUE KEY `key_token` (token), + KEY `key_expires` (expires) +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 2c4ce46800..2441c55351 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -206,6 +206,7 @@ phutil_register_library_map(array( 'ConduitPingConduitAPIMethod' => 'applications/conduit/method/ConduitPingConduitAPIMethod.php', 'ConduitQueryConduitAPIMethod' => 'applications/conduit/method/ConduitQueryConduitAPIMethod.php', 'ConduitSSHWorkflow' => 'applications/conduit/ssh/ConduitSSHWorkflow.php', + 'ConduitTokenGarbageCollector' => 'applications/conduit/garbagecollector/ConduitTokenGarbageCollector.php', 'ConpherenceActionMenuEventListener' => 'applications/conpherence/events/ConpherenceActionMenuEventListener.php', 'ConpherenceConduitAPIMethod' => 'applications/conpherence/conduit/ConpherenceConduitAPIMethod.php', 'ConpherenceConfigOptions' => 'applications/conpherence/config/ConpherenceConfigOptions.php', @@ -1428,7 +1429,12 @@ phutil_register_library_map(array( 'PhabricatorConduitMethodCallLog' => 'applications/conduit/storage/PhabricatorConduitMethodCallLog.php', 'PhabricatorConduitMethodQuery' => 'applications/conduit/query/PhabricatorConduitMethodQuery.php', 'PhabricatorConduitSearchEngine' => 'applications/conduit/query/PhabricatorConduitSearchEngine.php', + 'PhabricatorConduitSettingsPanel' => 'applications/conduit/settings/PhabricatorConduitSettingsPanel.php', + 'PhabricatorConduitToken' => 'applications/conduit/storage/PhabricatorConduitToken.php', 'PhabricatorConduitTokenController' => 'applications/conduit/controller/PhabricatorConduitTokenController.php', + 'PhabricatorConduitTokenEditController' => 'applications/conduit/controller/PhabricatorConduitTokenEditController.php', + 'PhabricatorConduitTokenQuery' => 'applications/conduit/query/PhabricatorConduitTokenQuery.php', + 'PhabricatorConduitTokenTerminateController' => 'applications/conduit/controller/PhabricatorConduitTokenTerminateController.php', 'PhabricatorConfigAllController' => 'applications/config/controller/PhabricatorConfigAllController.php', 'PhabricatorConfigApplication' => 'applications/config/application/PhabricatorConfigApplication.php', 'PhabricatorConfigColumnSchema' => 'applications/config/schema/PhabricatorConfigColumnSchema.php', @@ -3212,6 +3218,7 @@ phutil_register_library_map(array( 'ConduitPingConduitAPIMethod' => 'ConduitAPIMethod', 'ConduitQueryConduitAPIMethod' => 'ConduitAPIMethod', 'ConduitSSHWorkflow' => 'PhabricatorSSHWorkflow', + 'ConduitTokenGarbageCollector' => 'PhabricatorGarbageCollector', 'ConpherenceActionMenuEventListener' => 'PhabricatorEventListener', 'ConpherenceConduitAPIMethod' => 'ConduitAPIMethod', 'ConpherenceConfigOptions' => 'PhabricatorApplicationConfigOptions', @@ -4541,7 +4548,15 @@ phutil_register_library_map(array( ), 'PhabricatorConduitMethodQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorConduitSearchEngine' => 'PhabricatorApplicationSearchEngine', + 'PhabricatorConduitSettingsPanel' => 'PhabricatorSettingsPanel', + 'PhabricatorConduitToken' => array( + 'PhabricatorConduitDAO', + 'PhabricatorPolicyInterface', + ), 'PhabricatorConduitTokenController' => 'PhabricatorConduitController', + 'PhabricatorConduitTokenEditController' => 'PhabricatorConduitController', + 'PhabricatorConduitTokenQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorConduitTokenTerminateController' => 'PhabricatorConduitController', 'PhabricatorConfigAllController' => 'PhabricatorConfigController', 'PhabricatorConfigApplication' => 'PhabricatorApplication', 'PhabricatorConfigColumnSchema' => 'PhabricatorConfigStorageSchema', diff --git a/src/applications/conduit/application/PhabricatorConduitApplication.php b/src/applications/conduit/application/PhabricatorConduitApplication.php index f13b7bc3cd..8a1c97dca3 100644 --- a/src/applications/conduit/application/PhabricatorConduitApplication.php +++ b/src/applications/conduit/application/PhabricatorConduitApplication.php @@ -46,6 +46,10 @@ final class PhabricatorConduitApplication extends PhabricatorApplication { 'log/' => 'PhabricatorConduitLogController', 'log/view/(?P[^/]+)/' => 'PhabricatorConduitLogController', 'token/' => 'PhabricatorConduitTokenController', + 'token/edit/(?:(?P\d+)/)?' => + 'PhabricatorConduitTokenEditController', + 'token/terminate/(?:(?P\d+)/)?' => + 'PhabricatorConduitTokenTerminateController', ), '/api/(?P[^/]+)' => 'PhabricatorConduitAPIController', ); diff --git a/src/applications/conduit/controller/PhabricatorConduitTokenEditController.php b/src/applications/conduit/controller/PhabricatorConduitTokenEditController.php new file mode 100644 index 0000000000..17a6de0f75 --- /dev/null +++ b/src/applications/conduit/controller/PhabricatorConduitTokenEditController.php @@ -0,0 +1,100 @@ +getViewer(); + + $id = $request->getURIData('id'); + if ($id) { + $token = id(new PhabricatorConduitTokenQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->withExpired(false) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$token) { + return new Aphront404Response(); + } + + $object = $token->getObject(); + + $is_new = false; + $title = pht('View API Token'); + } else { + $object = id(new PhabricatorObjectQuery()) + ->setViewer($viewer) + ->withPHIDs(array($request->getStr('objectPHID'))) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$object) { + return new Aphront404Response(); + } + + $token = PhabricatorConduitToken::initializeNewToken( + $object->getPHID(), + PhabricatorConduitToken::TYPE_STANDARD); + + $is_new = true; + $title = pht('Generate API Token'); + $submit_button = pht('Generate Token'); + } + + if ($viewer->getPHID() == $object->getPHID()) { + $panel_uri = '/settings/panel/apitokens/'; + } else { + $panel_uri = '/settings/'.$object->getID().'/panel/apitokens/'; + } + + id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( + $viewer, + $request, + $panel_uri); + + if ($request->isFormPost()) { + $token->save(); + + if ($is_new) { + $token_uri = '/conduit/token/edit/'.$token->getID().'/'; + } else { + $token_uri = $panel_uri; + } + + return id(new AphrontRedirectResponse())->setURI($token_uri); + } + + $dialog = $this->newDialog() + ->setTitle($title) + ->addHiddenInput('objectPHID', $object->getPHID()); + + if ($is_new) { + $dialog + ->appendParagraph(pht('Generate a new API token?')) + ->addSubmitButton($submit_button) + ->addCancelButton($panel_uri); + } else { + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Token')) + ->setValue($token->getToken())); + + $dialog + ->appendForm($form) + ->addCancelButton($panel_uri, pht('Done')); + } + + return $dialog; + } + +} diff --git a/src/applications/conduit/controller/PhabricatorConduitTokenTerminateController.php b/src/applications/conduit/controller/PhabricatorConduitTokenTerminateController.php new file mode 100644 index 0000000000..f41b417c87 --- /dev/null +++ b/src/applications/conduit/controller/PhabricatorConduitTokenTerminateController.php @@ -0,0 +1,96 @@ +getViewer(); + + $object_phid = $request->getStr('objectPHID'); + $id = $request->getURIData('id'); + if ($id) { + $token = id(new PhabricatorConduitTokenQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->withExpired(false) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$token) { + return new Aphront404Response(); + } + + $tokens = array($token); + $object_phid = $token->getObjectPHID(); + + $title = pht('Terminate API Token'); + $body = pht( + 'Really terminate this token? Any system using this token '. + 'will no longer be able to make API requests.'); + $submit_button = pht('Terminate Token'); + $panel_uri = '/settings/panel/apitokens/'; + } else { + $tokens = id(new PhabricatorConduitTokenQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(array($object_phid)) + ->withExpired(false) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->execute(); + + $title = pht('Terminate API Tokens'); + $body = pht( + 'Really terminate all active API tokens? Any systems using these '. + 'tokens will no longer be able to make API requests.'); + $submit_button = pht('Terminate Tokens'); + } + + $panel_uri = '/settings/panel/apitokens/'; + if ($object_phid != $viewer->getPHID()) { + $object = id(new PhabricatorObjectQuery()) + ->setViewer($viewer) + ->withPHIDs(array($object_phid)) + ->executeOne(); + if (!$object) { + return new Aphront404Response(); + } + $panel_uri = '/settings/'.$object->getID().'/panel/apitokens/'; + } + + id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( + $viewer, + $request, + $panel_uri); + + if (!$tokens) { + return $this->newDialog() + ->setTitle(pht('No Tokens to Terminate')) + ->appendParagraph( + pht('There are no API tokens to terminate.')) + ->addCancelButton($panel_uri); + } + + if ($request->isFormPost()) { + foreach ($tokens as $token) { + $token + ->setExpires(PhabricatorTime::getNow() - 60) + ->save(); + } + return id(new AphrontRedirectResponse())->setURI($panel_uri); + } + + return $this->newDialog() + ->setTitle($title) + ->addHiddenInput('objectPHID', $object_phid) + ->appendParagraph($body) + ->addSubmitButton($submit_button) + ->addCancelButton($panel_uri); + } + +} diff --git a/src/applications/conduit/garbagecollector/ConduitTokenGarbageCollector.php b/src/applications/conduit/garbagecollector/ConduitTokenGarbageCollector.php new file mode 100644 index 0000000000..f57a23e3bb --- /dev/null +++ b/src/applications/conduit/garbagecollector/ConduitTokenGarbageCollector.php @@ -0,0 +1,19 @@ +establishConnection('w'); + queryfx( + $conn_w, + 'DELETE FROM %T WHERE expires <= %d + ORDER BY dateCreated ASC LIMIT 100', + $table->getTableName(), + PhabricatorTime::getNow()); + + return ($conn_w->getAffectedRows() == 100); + } + +} diff --git a/src/applications/conduit/query/PhabricatorConduitTokenQuery.php b/src/applications/conduit/query/PhabricatorConduitTokenQuery.php new file mode 100644 index 0000000000..d6e151bb0a --- /dev/null +++ b/src/applications/conduit/query/PhabricatorConduitTokenQuery.php @@ -0,0 +1,102 @@ +expired = $expired; + return $this; + } + + public function withIDs(array $ids) { + $this->ids = $ids; + return $this; + } + + public function withObjectPHIDs(array $phids) { + $this->objectPHIDs = $phids; + return $this; + } + + public function loadPage() { + $table = new PhabricatorConduitToken(); + $conn_r = $table->establishConnection('r'); + + $data = queryfx_all( + $conn_r, + 'SELECT * FROM %T %Q %Q %Q', + $table->getTableName(), + $this->buildWhereClause($conn_r), + $this->buildOrderClause($conn_r), + $this->buildLimitClause($conn_r)); + + return $table->loadAllFromArray($data);; + } + + private function buildWhereClause(AphrontDatabaseConnection $conn_r) { + $where = array(); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn_r, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->objectPHIDs !== null) { + $where[] = qsprintf( + $conn_r, + 'objectPHID IN (%Ls)', + $this->objectPHIDs); + } + + if ($this->expired !== null) { + if ($this->expired) { + $where[] = qsprintf( + $conn_r, + 'expires <= %d', + PhabricatorTime::getNow()); + } else { + $where[] = qsprintf( + $conn_r, + 'expires IS NULL OR expires > %d', + PhabricatorTime::getNow()); + } + } + + $where[] = $this->buildPagingClause($conn_r); + + return $this->formatWhereClause($where); + } + + protected function willFilterPage(array $tokens) { + $object_phids = mpull($tokens, 'getObjectPHID'); + $objects = id(new PhabricatorObjectQuery()) + ->setViewer($this->getViewer()) + ->setParentQuery($this) + ->withPHIDs($object_phids) + ->execute(); + $objects = mpull($objects, null, 'getPHID'); + + foreach ($tokens as $key => $token) { + $object = idx($objects, $token->getObjectPHID(), null); + if (!$object) { + $this->didRejectResult($token); + unset($tokens[$key]); + continue; + } + $token->attachObject($object); + } + + return $tokens; + } + + public function getQueryApplicationClass() { + return 'PhabricatorConduitApplication'; + } + +} diff --git a/src/applications/conduit/settings/PhabricatorConduitSettingsPanel.php b/src/applications/conduit/settings/PhabricatorConduitSettingsPanel.php new file mode 100644 index 0000000000..40c982477b --- /dev/null +++ b/src/applications/conduit/settings/PhabricatorConduitSettingsPanel.php @@ -0,0 +1,116 @@ +getViewer(); + $user = $this->getUser(); + + $tokens = id(new PhabricatorConduitTokenQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(array($user->getPHID())) + ->withExpired(false) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->execute(); + + $rows = array(); + foreach ($tokens as $token) { + $rows[] = array( + javelin_tag( + 'a', + array( + 'href' => '/conduit/token/edit/'.$token->getID().'/', + 'sigil' => 'workflow', + ), + substr($token->getToken(), 0, 8).'...'), + PhabricatorConduitToken::getTokenTypeName($token->getTokenType()), + phabricator_datetime($token->getDateCreated(), $viewer), + ($token->getExpires() + ? phabricator_datetime($token->getExpires(), $viewer) + : pht('Never')), + javelin_tag( + 'a', + array( + 'class' => 'button small grey', + 'href' => '/conduit/token/terminate/'.$token->getID().'/', + 'sigil' => 'workflow', + ), + pht('Terminate')), + ); + } + + $table = new AphrontTableView($rows); + $table->setNoDataString(pht("You don't have any active API tokens.")); + $table->setHeaders( + array( + pht('Token'), + pht('Type'), + pht('Created'), + pht('Expires'), + null, + )); + $table->setColumnClasses( + array( + 'wide pri', + '', + 'right', + 'right', + 'action', + )); + + $generate_icon = id(new PHUIIconView()) + ->setIconFont('fa-plus'); + $generate_button = id(new PHUIButtonView()) + ->setText(pht('Generate API Token')) + ->setHref('/conduit/token/edit/?objectPHID='.$user->getPHID()) + ->setTag('a') + ->setWorkflow(true) + ->setIcon($generate_icon); + + $terminate_icon = id(new PHUIIconView()) + ->setIconFont('fa-exclamation-triangle'); + $terminate_button = id(new PHUIButtonView()) + ->setText(pht('Terminate All Tokens')) + ->setHref('/conduit/token/terminate/?objectPHID='.$user->getPHID()) + ->setTag('a') + ->setWorkflow(true) + ->setIcon($terminate_icon); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Active API Tokens')) + ->addActionLink($generate_button) + ->addActionLink($terminate_button); + + $panel = id(new PHUIObjectBoxView()) + ->setHeader($header) + ->appendChild($table); + + return $panel; + } + +} diff --git a/src/applications/conduit/storage/PhabricatorConduitToken.php b/src/applications/conduit/storage/PhabricatorConduitToken.php new file mode 100644 index 0000000000..4f2f0f241a --- /dev/null +++ b/src/applications/conduit/storage/PhabricatorConduitToken.php @@ -0,0 +1,106 @@ + array( + 'tokenType' => 'text32', + 'token' => 'text32', + 'expires' => 'epoch?', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_object' => array( + 'columns' => array('objectPHID', 'tokenType'), + ), + 'key_token' => array( + 'columns' => array('token'), + 'unique' => true, + ), + 'key_expires' => array( + 'columns' => array('expires'), + ), + ), + ) + parent::getConfiguration(); + } + + public static function initializeNewToken($object_phid, $token_type) { + $token = new PhabricatorConduitToken(); + $token->objectPHID = $object_phid; + $token->tokenType = $token_type; + $token->expires = $token->getTokenExpires($token_type); + + $secret = $token_type.'-'.Filesystem::readRandomCharacters(32); + $secret = substr($secret, 0, 32); + $token->token = $secret; + + return $token; + } + + public static function getTokenTypeName($type) { + $map = array( + self::TYPE_STANDARD => pht('Standard API Token'), + self::TYPE_TEMPORARY => pht('Temporary API Token'), + ); + + return idx($map, $type, $type); + } + + private function getTokenExpires($token_type) { + switch ($token_type) { + case self::TYPE_STANDARD: + return null; + case self::TYPE_TEMPORARY: + return PhabricatorTime::getNow() + phutil_units('24h in seconds'); + default: + throw new Exception( + pht('Unknown Conduit token type "%s"!', $token_type)); + } + } + + public function getObject() { + return $this->assertAttached($this->object); + } + + public function attachObject(PhabricatorUser $object) { + $this->object = $object; + return $this; + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + return $this->getObject()->getPolicy($capability); + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return $this->getObject()->hasAutomaticCapability($capability, $viewer); + } + + public function describeAutomaticCapability($capability) { + return pht( + 'Conduit tokens inherit the policies of the user they authenticate.'); + } + +}