diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index d8468aa3ab..6eb5c8befe 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -903,6 +903,7 @@ phutil_register_library_map(array( 'PhabricatorAuthProviderLDAP' => 'applications/auth/provider/PhabricatorAuthProviderLDAP.php', 'PhabricatorAuthProviderOAuth' => 'applications/auth/provider/PhabricatorAuthProviderOAuth.php', 'PhabricatorAuthProviderOAuth1' => 'applications/auth/provider/PhabricatorAuthProviderOAuth1.php', + 'PhabricatorAuthProviderOAuth1JIRA' => 'applications/auth/provider/PhabricatorAuthProviderOAuth1JIRA.php', 'PhabricatorAuthProviderOAuth1Twitter' => 'applications/auth/provider/PhabricatorAuthProviderOAuth1Twitter.php', 'PhabricatorAuthProviderOAuthAmazon' => 'applications/auth/provider/PhabricatorAuthProviderOAuthAmazon.php', 'PhabricatorAuthProviderOAuthAsana' => 'applications/auth/provider/PhabricatorAuthProviderOAuthAsana.php', @@ -2957,6 +2958,7 @@ phutil_register_library_map(array( 'PhabricatorAuthProviderLDAP' => 'PhabricatorAuthProvider', 'PhabricatorAuthProviderOAuth' => 'PhabricatorAuthProvider', 'PhabricatorAuthProviderOAuth1' => 'PhabricatorAuthProvider', + 'PhabricatorAuthProviderOAuth1JIRA' => 'PhabricatorAuthProviderOAuth1', 'PhabricatorAuthProviderOAuth1Twitter' => 'PhabricatorAuthProviderOAuth1', 'PhabricatorAuthProviderOAuthAmazon' => 'PhabricatorAuthProviderOAuth', 'PhabricatorAuthProviderOAuthAsana' => 'PhabricatorAuthProviderOAuth', diff --git a/src/applications/auth/controller/config/PhabricatorAuthEditController.php b/src/applications/auth/controller/config/PhabricatorAuthEditController.php index e96e16d3fe..6856eef0aa 100644 --- a/src/applications/auth/controller/config/PhabricatorAuthEditController.php +++ b/src/applications/auth/controller/config/PhabricatorAuthEditController.php @@ -97,8 +97,12 @@ final class PhabricatorAuthEditController if (!$errors) { if ($is_new) { - $config->setProviderType($provider->getProviderType()); - $config->setProviderDomain($provider->getProviderDomain()); + if (!strlen($config->getProviderType())) { + $config->setProviderType($provider->getProviderType()); + } + if (!strlen($config->getProviderDomain())) { + $config->setProviderDomain($provider->getProviderDomain()); + } } $xactions[] = id(new PhabricatorAuthProviderConfigTransaction()) @@ -134,8 +138,15 @@ final class PhabricatorAuthEditController ->setContinueOnNoEffect(true) ->applyTransactions($config, $xactions); - return id(new AphrontRedirectResponse())->setURI( - $this->getApplicationURI()); + + if ($provider->hasSetupStep() && $is_new) { + $id = $config->getID(); + $next_uri = $this->getApplicationURI('config/edit/'.$id.'/'); + } else { + $next_uri = $this->getApplicationURI(); + } + + return id(new AphrontRedirectResponse())->setURI($next_uri); } } else { $properties = $provider->readFormValuesFromProvider(); diff --git a/src/applications/auth/provider/PhabricatorAuthProvider.php b/src/applications/auth/provider/PhabricatorAuthProvider.php index 4f7e0bad5d..b02594f068 100644 --- a/src/applications/auth/provider/PhabricatorAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorAuthProvider.php @@ -347,4 +347,17 @@ abstract class PhabricatorAuthProvider { $account_view)); } + + /** + * Return true to use a two-step configuration (setup, configure) instead of + * the default single-step configuration. In practice, this means that + * creating a new provider instance will redirect back to the edit page + * instead of the provider list. + * + * @return bool True if this provider uses two-step configuration. + */ + public function hasSetupStep() { + return false; + } + } diff --git a/src/applications/auth/provider/PhabricatorAuthProviderOAuth1.php b/src/applications/auth/provider/PhabricatorAuthProviderOAuth1.php index 4a3f3ce883..3f161c9eee 100644 --- a/src/applications/auth/provider/PhabricatorAuthProviderOAuth1.php +++ b/src/applications/auth/provider/PhabricatorAuthProviderOAuth1.php @@ -26,9 +26,10 @@ abstract class PhabricatorAuthProviderOAuth1 extends PhabricatorAuthProvider { protected function configureAdapter(PhutilAuthAdapterOAuth1 $adapter) { $config = $this->getProviderConfig(); $adapter->setConsumerKey($config->getProperty(self::PROPERTY_CONSUMER_KEY)); - $adapter->setConsumerSecret( - new PhutilOpaqueEnvelope( - $config->getProperty(self::PROPERTY_CONSUMER_SECRET))); + $secret = $config->getProperty(self::PROPERTY_CONSUMER_SECRET); + if (strlen($secret)) { + $adapter->setConsumerSecret(new PhutilOpaqueEnvelope($secret)); + } $adapter->setCallbackURI($this->getLoginURI()); return $adapter; } diff --git a/src/applications/auth/provider/PhabricatorAuthProviderOAuth1JIRA.php b/src/applications/auth/provider/PhabricatorAuthProviderOAuth1JIRA.php new file mode 100644 index 0000000000..b0f6f69f31 --- /dev/null +++ b/src/applications/auth/provider/PhabricatorAuthProviderOAuth1JIRA.php @@ -0,0 +1,248 @@ +isSetup()) { + return pht( + "**Step 1 of 2**: Provide the name and URI for your JIRA install.\n\n". + "In the next step, you will configure JIRA."); + } else { + $login_uri = $this->getLoginURI(); + return pht( + "**Step 2 of 2**: In this step, you will configure JIRA.\n\n". + "**Create a JIRA Application**: Log into JIRA and go to ". + "**Administration**, then **Add-ons**, then **Application Links**. ". + "Click the button labeled **Add Application Link**, and use these ". + "settings to create an application:\n\n". + " - **Server URL**: `%s`\n". + " - Then, click **Next**. On the second page:\n". + " - **Application Name**: `Phabricator`\n". + " - **Application Type**: `Generic Application`\n". + " - Then, click **Create**.\n\n". + "**Configure Your Application**: Find the application you just ". + "created in the table, and click the **Configure** link under ". + "**Actions**. Select **Incoming Authentication** and click the ". + "**OAuth** tab (it may be selected by default). Then, use these ". + "settings:\n\n". + " - **Consumer Key**: Set this to the \"Consumer Key\" value in the ". + "form above.\n". + " - **Consumer Name**: `Phabricator`\n". + " - **Public Key**: Set this to the \"Public Key\" value in the ". + "form above.\n". + " - **Consumer Callback URL**: `%s`\n". + "Click **Save** in JIRA. Authentication should now be configured, ". + "and this provider should work correctly.", + PhabricatorEnv::getProductionURI('/'), + $login_uri); + } + } + + protected function newOAuthAdapter() { + $config = $this->getProviderConfig(); + + return id(new PhutilAuthAdapterOAuthJIRA()) + ->setAdapterDomain($config->getProviderDomain()) + ->setJIRABaseURI($config->getProperty(self::PROPERTY_JIRA_URI)) + ->setPrivateKey( + new PhutilOpaqueEnvelope( + $config->getProperty(self::PROPERTY_PRIVATE_KEY))); + } + + protected function getLoginIcon() { + return 'Jira'; + } + + private function isSetup() { + return !$this->getProviderConfig()->getID(); + } + + const PROPERTY_JIRA_NAME = 'oauth1:jira:name'; + const PROPERTY_JIRA_URI = 'oauth1:jira:uri'; + const PROPERTY_PUBLIC_KEY = 'oauth1:jira:key:public'; + const PROPERTY_PRIVATE_KEY = 'oauth1:jira:key:private'; + + + public function readFormValuesFromProvider() { + $config = $this->getProviderConfig(); + $uri = $config->getProperty(self::PROPERTY_JIRA_URI); + + return array( + self::PROPERTY_JIRA_NAME => $this->getProviderDomain(), + self::PROPERTY_JIRA_URI => $uri, + ); + } + + public function readFormValuesFromRequest(AphrontRequest $request) { + $is_setup = $this->isSetup(); + if ($is_setup) { + $name = $request->getStr(self::PROPERTY_JIRA_NAME); + } else { + $name = $this->getProviderDomain(); + } + + return array( + self::PROPERTY_JIRA_NAME => $name, + self::PROPERTY_JIRA_URI => $request->getStr(self::PROPERTY_JIRA_URI), + ); + } + + public function processEditForm( + AphrontRequest $request, + array $values) { + $errors = array(); + $issues = array(); + + $is_setup = $this->isSetup(); + + $key_name = self::PROPERTY_JIRA_NAME; + $key_uri = self::PROPERTY_JIRA_URI; + + if (!strlen($values[$key_name])) { + $errors[] = pht('JIRA instance name is required.'); + $issues[$key_name] = pht('Required'); + } else if (!preg_match('/^[a-z0-9.]+$/', $values[$key_name])) { + $errors[] = pht( + 'JIRA instance name must contain only lowercase letters, digits, and '. + 'period.'); + $issues[$key_name] = pht('Invalid'); + } + + if (!strlen($values[$key_uri])) { + $errors[] = pht('JIRA base URI is required.'); + $issues[$key_uri] = pht('Required'); + } else { + $uri = new PhutilURI($values[$key_uri]); + if (!$uri->getProtocol()) { + $errors[] = pht( + 'JIRA base URI should include protocol (like "https://").'); + $issues[$key_uri] = pht('Invalid'); + } + } + + if (!$errors && $is_setup) { + $config = $this->getProviderConfig(); + + $config->setProviderDomain($values[$key_name]); + + $consumer_key = 'phjira.'.Filesystem::readRandomCharacters(16); + list($public, $private) = PhutilAuthAdapterOAuthJIRA::newJIRAKeypair(); + + $config->setProperty(self::PROPERTY_PUBLIC_KEY, $public); + $config->setProperty(self::PROPERTY_PRIVATE_KEY, $private); + $config->setProperty(self::PROPERTY_CONSUMER_KEY, $consumer_key); + } + + return array($errors, $issues, $values); + } + + public function extendEditForm( + AphrontRequest $request, + AphrontFormView $form, + array $values, + array $issues) { + + if (!function_exists('openssl_pkey_new')) { + // TODO: This could be a bit prettier. + throw new Exception( + pht( + "The PHP 'openssl' extension is not installed. You must install ". + "this extension in order to add a JIRA authentication provider, ". + "because JIRA OAuth requests use the RSA-SHA1 signing algorithm. ". + "Install the 'openssl' extension, restart your webserver, and try ". + "again.")); + } + + $is_setup = $this->isSetup(); + + $e_required = $request->isFormPost() ? null : true; + + $v_name = $values[self::PROPERTY_JIRA_NAME]; + if ($is_setup) { + $e_name = idx($issues, self::PROPERTY_JIRA_NAME, $e_required); + } else { + $e_name = null; + } + + $v_uri = $values[self::PROPERTY_JIRA_URI]; + $e_uri = idx($issues, self::PROPERTY_JIRA_URI, $e_required); + + if ($is_setup) { + $form + ->appendRemarkupInstructions( + pht( + "**JIRA Instance Name**\n\n". + "Choose a permanent name for this instance of JIRA. Phabricator ". + "uses this name internally to keep track of this instance of ". + "JIRA, in case the URL changes later.\n\n". + "Use lowercase letters, digits, and period. For example, ". + "`jira`, `jira.mycompany` or `jira.engineering` are reasonable ". + "names.")) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('JIRA Instance Name')) + ->setValue($v_name) + ->setName(self::PROPERTY_JIRA_NAME) + ->setError($e_name)); + } else { + $form + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel(pht('JIRA Instance Name')) + ->setValue($v_name)); + } + + $form + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('JIRA Base URI')) + ->setValue($v_uri) + ->setName(self::PROPERTY_JIRA_URI) + ->setCaption( + pht( + 'The URI where JIRA is installed. For example: %s', + phutil_tag('tt', array(), 'https://jira.mycompany.com/'))) + ->setError($e_uri)); + + if (!$is_setup) { + $config = $this->getProviderConfig(); + + + $ckey = $config->getProperty(self::PROPERTY_CONSUMER_KEY); + $ckey = phutil_tag('tt', array(), $ckey); + + $pkey = $config->getProperty(self::PROPERTY_PUBLIC_KEY); + $pkey = phutil_escape_html_newlines($pkey); + $pkey = phutil_tag('tt', array(), $pkey); + + $form + ->appendRemarkupInstructions( + pht( + 'NOTE: **To complete setup**, copy and paste these keys into JIRA '. + 'according to the instructions below.')) + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel(pht('Consumer Key')) + ->setValue($ckey)) + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel(pht('Public Key')) + ->setValue($pkey)); + } + + } + + + /** + * JIRA uses a setup step to generate public/private keys. + */ + public function hasSetupStep() { + return true; + } + +}