diff --git a/conf/default.conf.php b/conf/default.conf.php index 5841624ff3..eda79625fe 100644 --- a/conf/default.conf.php +++ b/conf/default.conf.php @@ -22,6 +22,10 @@ return array( // Example: "http://phabricator.example.com/" 'phabricator.base-uri' => null, + // The Conduit URI for API access to this install. Normally this is just + // the 'base-uri' plus "/api/" (e.g. "http://phabricator.example.com/api/"), + // but make sure you specify 'https' if you have HTTPS configured. + 'phabricator.conduit-uri' => null, 'phabricator.csrf-key' => '0b7ec0592e0a2829d8b71df2fa269b2c6172eca3', diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index f26745631e..6366705e02 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -197,6 +197,7 @@ phutil_register_library_map(array( 'PhabricatorTypeaheadDatasourceController' => 'applications/typeahead/controller/base', 'PhabricatorUser' => 'applications/people/storage/user', 'PhabricatorUserDAO' => 'applications/people/storage/base', + 'PhabricatorUserSettingsController' => 'applications/people/controller/settings', 'PhabricatorXHProfController' => 'applications/xhprof/controller/base', 'PhabricatorXHProfProfileController' => 'applications/xhprof/controller/profile', 'PhabricatorXHProfProfileSymbolView' => 'applications/xhprof/view/symbol', @@ -373,6 +374,7 @@ phutil_register_library_map(array( 'PhabricatorTypeaheadDatasourceController' => 'PhabricatorController', 'PhabricatorUser' => 'PhabricatorUserDAO', 'PhabricatorUserDAO' => 'PhabricatorLiskDAO', + 'PhabricatorUserSettingsController' => 'PhabricatorPeopleController', 'PhabricatorXHProfController' => 'PhabricatorController', 'PhabricatorXHProfProfileController' => 'PhabricatorXHProfController', 'PhabricatorXHProfProfileSymbolView' => 'AphrontView', diff --git a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php index 29912550e1..26ce23278d 100644 --- a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php @@ -135,6 +135,10 @@ class AphrontDefaultApplicationConfiguration ), '/~/' => 'DarkConsoleController', + + '/settings/' => array( + '(?:page/(?[^/]+)/)?$' => 'PhabricatorUserSettingsController', + ), ); } diff --git a/src/aphront/request/AphrontRequest.php b/src/aphront/request/AphrontRequest.php index 9359a18b6b..cdd46d5097 100644 --- a/src/aphront/request/AphrontRequest.php +++ b/src/aphront/request/AphrontRequest.php @@ -146,4 +146,8 @@ class AphrontRequest { return id(new PhutilURI($this->getPath()))->setQueryParams($get); } + final public function isDialogFormPost() { + return $this->isFormPost() && $this->getStr('__dialog__'); + } + } diff --git a/src/applications/conduit/controller/api/PhabricatorConduitAPIController.php b/src/applications/conduit/controller/api/PhabricatorConduitAPIController.php index 9cd95681c4..5b00396236 100644 --- a/src/applications/conduit/controller/api/PhabricatorConduitAPIController.php +++ b/src/applications/conduit/controller/api/PhabricatorConduitAPIController.php @@ -81,14 +81,44 @@ class PhabricatorConduitAPIController $api_request = new ConduitAPIRequest($params); - try { - $result = $method_handler->executeMethod($api_request); - $error_code = null; - $error_info = null; - } catch (ConduitException $ex) { - $result = null; - $error_code = $ex->getMessage(); - $error_info = $method_handler->getErrorDescription($error_code); + if ($method_handler->shouldRequireAuthentication()) { + $session_key = idx($metadata, 'sessionKey'); + if (!$session_key) { + $auth_okay = false; + $error_code = 'ERR-NO-CERTIFICATE'; + $error_info = "This server requires authentication but your client ". + "is not configured with an authentication certificate."; + } else { + $user = new PhabricatorUser(); + $session = queryfx_one( + $user->establishConnection('r'), + 'SELECT * FROM %T WHERE sessionKey = %s', + PhabricatorUser::SESSION_TABLE, + $session_key); + if (!$session) { + $auth_okay = false; + $error_code = 'ERR-INVALID-SESSION'; + $error_info = 'Session key is invalid.'; + } else { + // TODO: Make sessions timeout. + $auth_okay = true; + } + } + // TODO: When we session, read connectionID from the session table. + } else { + $auth_okay = true; + } + + if ($auth_okay) { + try { + $result = $method_handler->executeMethod($api_request); + $error_code = null; + $error_info = null; + } catch (ConduitException $ex) { + $result = null; + $error_code = $ex->getMessage(); + $error_info = $method_handler->getErrorDescription($error_code); + } } } catch (Exception $ex) { $result = null; diff --git a/src/applications/conduit/controller/api/__init__.php b/src/applications/conduit/controller/api/__init__.php index 1e6db7a2be..14eeb227e2 100644 --- a/src/applications/conduit/controller/api/__init__.php +++ b/src/applications/conduit/controller/api/__init__.php @@ -11,6 +11,8 @@ phutil_require_module('phabricator', 'applications/conduit/controller/base'); phutil_require_module('phabricator', 'applications/conduit/method/base'); phutil_require_module('phabricator', 'applications/conduit/protocol/request'); phutil_require_module('phabricator', 'applications/conduit/storage/methodcalllog'); +phutil_require_module('phabricator', 'applications/people/storage/user'); +phutil_require_module('phabricator', 'storage/queryfx'); phutil_require_module('phabricator', 'view/control/table'); phutil_require_module('phabricator', 'view/layout/panel'); diff --git a/src/applications/conduit/method/base/ConduitAPIMethod.php b/src/applications/conduit/method/base/ConduitAPIMethod.php index 8ddfa136a4..5f38e4220f 100644 --- a/src/applications/conduit/method/base/ConduitAPIMethod.php +++ b/src/applications/conduit/method/base/ConduitAPIMethod.php @@ -45,6 +45,10 @@ abstract class ConduitAPIMethod { return 'ConduitAPI_'.$method_fragment.'_Method'; } + public function shouldRequireAuthentication() { + return true; + } + public static function getAPIMethodNameFromClassName($class_name) { $match = null; $is_valid = preg_match( diff --git a/src/applications/conduit/method/conduit/connect/ConduitAPI_conduit_connect_Method.php b/src/applications/conduit/method/conduit/connect/ConduitAPI_conduit_connect_Method.php index daa12ac7bc..8154fb5aaa 100644 --- a/src/applications/conduit/method/conduit/connect/ConduitAPI_conduit_connect_Method.php +++ b/src/applications/conduit/method/conduit/connect/ConduitAPI_conduit_connect_Method.php @@ -18,6 +18,10 @@ class ConduitAPI_conduit_connect_Method extends ConduitAPIMethod { + public function shouldRequireAuthentication() { + return false; + } + public function getMethodDescription() { return "Connect a session-based client."; } @@ -28,6 +32,8 @@ class ConduitAPI_conduit_connect_Method extends ConduitAPIMethod { 'clientVersion' => 'required int', 'clientDescription' => 'optional string', 'user' => 'optional string', + 'authToken' => 'optional int', + 'authSignature' => 'optional string', ); } @@ -47,6 +53,17 @@ class ConduitAPI_conduit_connect_Method extends ConduitAPIMethod { "a Facebook host, or see ". " for ". "laptop instructions.", + "ERR-INVALID-USER" => + "The username you are attempting to authenticate with is not valid.", + "ERR-INVALID-CERTIFICATE" => + "Your authentication certificate for this server is invalid.", + "ERR-INVALID-TOKEN" => + "The challenge token you are authenticating with is outside of the ". + "allowed time range. Either your system clock is out of whack or ". + "you're executing a replay attack.", + "ERR-NO-CERTIFICATE" => + "This server requires authentication but your client is not ". + "configured with an authentication certificate." ); } @@ -55,13 +72,14 @@ class ConduitAPI_conduit_connect_Method extends ConduitAPIMethod { $client = $request->getValue('client'); $client_version = (int)$request->getValue('clientVersion'); $client_description = (string)$request->getValue('clientDescription'); + $username = (string)$request->getValue('user'); // Log the connection, regardless of the outcome of checks below. $connection = new PhabricatorConduitConnectionLog(); $connection->setClient($client); $connection->setClientVersion($client_version); $connection->setClientDescription($client_description); - $connection->setUsername((string)$request->getValue('user')); + $connection->setUsername($username); $connection->save(); switch ($client) { @@ -80,8 +98,58 @@ class ConduitAPI_conduit_connect_Method extends ConduitAPIMethod { throw new ConduitException('ERR-UNKNOWN-CLIENT'); } + $token = $request->getValue('authToken'); + $signature = $request->getValue('authSignature'); + + $user = id(new PhabricatorUser())->loadOneWhere( + 'username = %s', + $username); + if (!$user) { + throw new ConduitException('ERR-INVALID-USER'); + } + + $session_key = null; + if ($token && $signature) { + if (abs($token - time()) > 60 * 15) { + throw new ConduitException('ERR-INVALID-TOKEN'); + } + $valid = sha1($token.$user->getConduitCertificate()); + if ($valid != $signature) { + throw new ConduitException('ERR-INVALID-CERTIFICATE'); + } + + $sessions = queryfx_all( + $user->establishConnection('r'), + 'SELECT * FROM %T WHERE userPHID = %s AND type LIKE %>', + PhabricatorUser::SESSION_TABLE, + $user->getPHID(), + 'conduit-'); + + $session_type = null; + + $sessions = ipull($sessions, null, 'type'); + for ($ii = 1; $ii <= 3; $ii++) { + if (empty($sessions['conduit-'.$ii])) { + $session_type = 'conduit-'.$ii; + break; + } + } + + if (!$session_type) { + $sessions = isort($sessions, 'sessionStart'); + $oldest = reset($sessions); + $session_type = $oldest['type']; + } + + $session_key = $user->establishSession($session_type); + } else { + throw new ConduitException('ERR-NO-CERTIFICATE'); + } + return array( - 'connectionID' => $connection->getID(), + 'connectionID' => $connection->getID(), + 'sessionKey' => $session_key, + 'userPHID' => $user->getPHID(), ); } diff --git a/src/applications/conduit/method/conduit/connect/__init__.php b/src/applications/conduit/method/conduit/connect/__init__.php index 5d3459f000..6906e56c9f 100644 --- a/src/applications/conduit/method/conduit/connect/__init__.php +++ b/src/applications/conduit/method/conduit/connect/__init__.php @@ -9,6 +9,10 @@ phutil_require_module('phabricator', 'applications/conduit/method/base'); phutil_require_module('phabricator', 'applications/conduit/protocol/exception'); phutil_require_module('phabricator', 'applications/conduit/storage/connectionlog'); +phutil_require_module('phabricator', 'applications/people/storage/user'); +phutil_require_module('phabricator', 'storage/queryfx'); + +phutil_require_module('phutil', 'utils'); phutil_require_source('ConduitAPI_conduit_connect_Method.php'); diff --git a/src/applications/people/controller/settings/PhabricatorUserSettingsController.php b/src/applications/people/controller/settings/PhabricatorUserSettingsController.php new file mode 100644 index 0000000000..384e0a50d7 --- /dev/null +++ b/src/applications/people/controller/settings/PhabricatorUserSettingsController.php @@ -0,0 +1,168 @@ +page = idx($data, 'page'); + } + + public function processRequest() { + + $request = $this->getRequest(); + $user = $request->getUser(); + + $pages = array( +// 'personal' => 'Profile', +// 'password' => 'Password', +// 'facebook' => 'Facebook Account', + 'arcanist' => 'Arcanist Certificate', + ); + + if (empty($pages[$this->page])) { + $this->page = key($pages); + } + + if ($request->isFormPost()) { + switch ($this->page) { + case 'arcanist': + + if (!$request->isDialogFormPost()) { + $dialog = new AphrontDialogView(); + $dialog->setUser($user); + $dialog->setTitle('Really regenerate session?'); + $dialog->setSubmitURI('/settings/page/arcanist/'); + $dialog->addSubmitButton('Regenerate'); + $dialog->addCancelbutton('/settings/page/arcanist/'); + $dialog->appendChild( + '

Really destroy the old certificate? Any established '. + 'sessions will be terminated.'); + + return id(new AphrontDialogResponse()) + ->setDialog($dialog); + } + + $conn = $user->establishConnection('w'); + queryfx( + $conn, + 'DELETE FROM %T WHERE userPHID = %s AND type LIKE %>', + PhabricatorUser::SESSION_TABLE, + $user->getPHID(), + 'conduit'); + // This implicitly regenerates the certificate. + $user->setConduitCertificate(null); + $user->save(); + return id(new AphrontRedirectResponse()) + ->setURI('/settings/page/arcanist/?regenerated=true'); + } + } + + switch ($this->page) { + case 'arcanist': + $content = $this->renderArcanistCertificateForm(); + break; + default: + $content = 'derp derp'; + break; + } + + + $sidenav = new AphrontSideNavView(); + foreach ($pages as $page => $name) { + $sidenav->addNavItem( + phutil_render_tag( + 'a', + array( + 'href' => '/settings/page/'.$page.'/', + 'class' => ($page == $this->page) + ? 'aphront-side-nav-selected' + : null, + ), + phutil_escape_html($name))); + } + + $sidenav->appendChild($content); + + return $this->buildStandardPageResponse( + $sidenav, + array( + 'title' => 'Account Settings', + )); + } + + private function renderArcanistCertificateForm() { + $request = $this->getRequest(); + $user = $request->getUser(); + + if ($request->getStr('regenerated')) { + $notice = new AphrontErrorView(); + $notice->setSeverity(AphrontErrorView::SEVERITY_NOTICE); + $notice->setTitle('Certificate Regenerated'); + $notice->appendChild( + '

Your old certificate has been destroyed and you have been issued '. + 'a new certificate. Sessions established under the old certificate '. + 'are no longer valid.

'); + $notice = $notice->render(); + } else { + $notice = null; + } + + $host = PhabricatorEnv::getEnvConfig('phabricator.conduit-uri'); + + $cert_form = new AphrontFormView(); + $cert_form + ->setUser($user) + ->appendChild( + '

Copy and paste this certificate '. + 'into your ~/.arcconfig in the "hosts" section to enable '. + 'Arcanist to authenticate against this host.

') + ->appendChild( + id(new AphrontFormTextAreaControl()) + ->setLabel('Certificate') + ->setHeight(AphrontFormTextAreaControl::HEIGHT_SHORT) + ->setValue($user->getConduitCertificate())); + + $cert = new AphrontPanelView(); + $cert->setHeader('Arcanist Certificate'); + $cert->appendChild($cert_form); + $cert->setWidth(AphrontPanelView::WIDTH_FORM); + + $regen_form = new AphrontFormView(); + $regen_form + ->setUser($user) + ->setWorkflow(true) + ->setAction('/settings/page/arcanist/') + ->appendChild( + '

You can regenerate this '. + 'certificate, which will invalidate the old certificate and create '. + 'a new one.

') + ->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue('Regenerate Certificate')); + + $regen = new AphrontPanelView(); + $regen->setHeader('Regenerate Certificate'); + $regen->appendChild($regen_form); + $regen->setWidth(AphrontPanelView::WIDTH_FORM); + + return $notice.$cert->render().$regen->render(); + } + +} diff --git a/src/applications/people/controller/settings/__init__.php b/src/applications/people/controller/settings/__init__.php new file mode 100644 index 0000000000..b6600f1bcd --- /dev/null +++ b/src/applications/people/controller/settings/__init__.php @@ -0,0 +1,27 @@ +profileImagePHID, @@ -56,6 +60,34 @@ class PhabricatorUser extends PhabricatorUserDAO { return $this; } + public function save() { + if (!$this->conduitCertificate) { + $this->conduitCertificate = $this->generateConduitCertificate(); + } + return parent::save(); + } + + private function generateConduitCertificate() { + $entropy = $this->generateEntropy($bytes = 256); + $entropy = base64_encode($entropy); + $entropy = substr($entropy, 0, 255); + return $entropy; + } + + private function generateEntropy($bytes) { + $urandom = fopen('/dev/urandom', 'r'); + if (!$urandom) { + throw new Exception("Failed to open /dev/urandom!"); + } + + $entropy = fread($urandom, $bytes); + if (strlen($entropy) != $bytes) { + throw new Exception("Failed to read /dev/urandom!"); + } + + return $entropy; + } + public function comparePassword($password) { $password = $this->hashPassword($password); return ($password === $this->getPasswordHash()); @@ -105,26 +137,19 @@ class PhabricatorUser extends PhabricatorUserDAO { public function establishSession($session_type) { $conn_w = $this->establishConnection('w'); - $urandom = fopen('/dev/urandom', 'r'); - if (!$urandom) { - throw new Exception("Failed to open /dev/urandom!"); - } - - $entropy = fread($urandom, 20); - if (strlen($entropy) != 20) { - throw new Exception("Failed to read /dev/urandom!"); - } + $entropy = $this->generateEntropy($bytes = 20); $session_key = sha1($entropy); queryfx( $conn_w, - 'INSERT INTO phabricator_session '. + 'INSERT INTO %T '. '(userPHID, type, sessionKey, sessionStart)'. ' VALUES '. '(%s, %s, %s, UNIX_TIMESTAMP()) '. 'ON DUPLICATE KEY UPDATE '. 'sessionKey = VALUES(sessionKey), '. 'sessionStart = VALUES(sessionStart)', + self::SESSION_TABLE, $this->getPHID(), $session_type, $session_key); diff --git a/src/view/dialog/AphrontDialogView.php b/src/view/dialog/AphrontDialogView.php index 510574b9db..f530d486e3 100755 --- a/src/view/dialog/AphrontDialogView.php +++ b/src/view/dialog/AphrontDialogView.php @@ -113,6 +113,7 @@ class AphrontDialogView extends AphrontView { ), ''. ''. + ''. $hidden_inputs. '
'. phutil_escape_html($this->title). diff --git a/src/view/form/base/AphrontFormView.php b/src/view/form/base/AphrontFormView.php index e6e0e73840..4722a32f7d 100755 --- a/src/view/form/base/AphrontFormView.php +++ b/src/view/form/base/AphrontFormView.php @@ -24,6 +24,7 @@ final class AphrontFormView extends AphrontView { private $data = array(); private $encType; private $user; + private $workflow; public function setUser(PhabricatorUser $user) { $this->user = $user; @@ -50,15 +51,21 @@ final class AphrontFormView extends AphrontView { return $this; } + public function setWorkflow($workflow) { + $this->workflow = $workflow; + return $this; + } + public function render() { require_celerity_resource('aphront-form-view-css'); - return phutil_render_tag( + return javelin_render_tag( 'form', array( 'action' => $this->action, 'method' => $this->method, 'class' => 'aphront-form-view', 'enctype' => $this->encType, + 'sigil' => $this->workflow ? 'workflow' : null, ), $this->renderDataInputs(). $this->renderChildren()); diff --git a/src/view/form/base/__init__.php b/src/view/form/base/__init__.php index 981c2408dd..b4cba977cd 100644 --- a/src/view/form/base/__init__.php +++ b/src/view/form/base/__init__.php @@ -7,6 +7,7 @@ phutil_require_module('phabricator', 'infrastructure/celerity/api'); +phutil_require_module('phabricator', 'infrastructure/javelin/markup'); phutil_require_module('phabricator', 'view/base'); phutil_require_module('phutil', 'markup'); diff --git a/src/view/form/error/AphrontErrorView.php b/src/view/form/error/AphrontErrorView.php index edddb30d15..e3dfafe98a 100755 --- a/src/view/form/error/AphrontErrorView.php +++ b/src/view/form/error/AphrontErrorView.php @@ -20,7 +20,7 @@ final class AphrontErrorView extends AphrontView { const SEVERITY_ERROR = 'error'; const SEVERITY_WARNING = 'warning'; - const SEVERITY_NOTE = 'note'; + const SEVERITY_NOTICE = 'notice'; const WIDTH_DEFAULT = 'default'; const WIDTH_WIDE = 'wide'; diff --git a/webroot/rsrc/css/aphront/error-view.css b/webroot/rsrc/css/aphront/error-view.css index aa09d6da50..ae362dbb31 100644 --- a/webroot/rsrc/css/aphront/error-view.css +++ b/webroot/rsrc/css/aphront/error-view.css @@ -28,3 +28,9 @@ border: 1px solid #888800; background: #ffffdd; } + +.aphront-error-severity-notice { + border: 1px solid #000088; + background: #e3e3ff; +} +