diff --git a/resources/sql/autopatches/20141212.conduittoken.sql b/resources/sql/autopatches/20141212.conduittoken.sql new file mode 100644 --- /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 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -206,6 +206,7 @@ '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 @@ '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 @@ 'ConduitPingConduitAPIMethod' => 'ConduitAPIMethod', 'ConduitQueryConduitAPIMethod' => 'ConduitAPIMethod', 'ConduitSSHWorkflow' => 'PhabricatorSSHWorkflow', + 'ConduitTokenGarbageCollector' => 'PhabricatorGarbageCollector', 'ConpherenceActionMenuEventListener' => 'PhabricatorEventListener', 'ConpherenceConduitAPIMethod' => 'ConduitAPIMethod', 'ConpherenceConfigOptions' => 'PhabricatorApplicationConfigOptions', @@ -4541,7 +4548,15 @@ ), '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 --- a/src/applications/conduit/application/PhabricatorConduitApplication.php +++ b/src/applications/conduit/application/PhabricatorConduitApplication.php @@ -46,6 +46,10 @@ '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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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.'); + } + +}