diff --git a/auth/PhabricatorBMOAuthProvider.php b/auth/PhabricatorBMOAuthProvider.php index 4ff9cf6..1022d63 100644 --- a/auth/PhabricatorBMOAuthProvider.php +++ b/auth/PhabricatorBMOAuthProvider.php @@ -1,388 +1,389 @@ provider config const CONFIG_KEY_DEBUG_MODE = 'debug_mode'; const CONFIG_KEY_APP_NAME = 'app_name'; const CONFIG_KEY_TRANSACTION_CODE_LENGTH = 'transaction_code_length'; // Need to add this to avoid error during auth addition activation protected $adapter; protected $providerConfig; /* * Required provider setup methods */ public function getProviderName() { return pht('BMO Auth Delegation'); } public function getDescriptionForCreate() { return pht( 'Configure a connection to bugzilla.mozilla.org so that users may use '. 'BMO credentials to log in to Phabricator.' ); } /* * Methods for getting additional config information for this provider * These config options are seen on the "Edit Auth Provider" form in admin */ public function getConfigurationHelp() { return pht( 'This extension was written by the Mozilla Conduit team. Please '. 'contact someone in the #phabricator channel if you have '. 'config questions.' ); } public function getDefaultProviderConfig() { return parent::getDefaultProviderConfig() ->setProperty(self::CONFIG_KEY_DEBUG_MODE, 0) ->setProperty(self::CONFIG_KEY_APP_NAME, 'MozPhabricator') ->setProperty(self::CONFIG_KEY_TRANSACTION_CODE_LENGTH, 32); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $form ->appendChild( id(new AphrontFormCheckboxControl()) ->addCheckbox( self::CONFIG_KEY_DEBUG_MODE, '1', hsprintf( '%s %s', pht('Debug Mode'), pht('ONLY USE DURING DEVELOPMENT') ), (isset($values[self::CONFIG_KEY_DEBUG_MODE]) && $values[self::CONFIG_KEY_DEBUG_MODE] === '1') ) ) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('App Name')) ->setPlaceholder(pht('App name as shown in Bugzilla auth page')) ->setName(self::CONFIG_KEY_APP_NAME) ->setValue(self::getArrayValueOrDefault($values, self::CONFIG_KEY_APP_NAME)) ->setError(self::getArrayValueOrDefault($issues, self::CONFIG_KEY_APP_NAME)) ) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Transaction Code Length')) ->setPlaceholder(pht('Example: 32, 64, ...')) ->setName(self::CONFIG_KEY_TRANSACTION_CODE_LENGTH) ->setValue( self::getArrayValueOrDefault($values, self::CONFIG_KEY_TRANSACTION_CODE_LENGTH) ) ->setError( self::getArrayValueOrDefault($issues, self::CONFIG_KEY_TRANSACTION_CODE_LENGTH) ) ); } public function getArrayValueOrDefault($values, $key, $default = '') { return trim(isset($values[$key]) ? $values[$key] : $default); } public function readFormValuesFromProvider() { $config = $this->getConfig(); return array( self::CONFIG_KEY_DEBUG_MODE => $config->getProperty(self::CONFIG_KEY_DEBUG_MODE), self::CONFIG_KEY_APP_NAME => $config->getProperty(self::CONFIG_KEY_APP_NAME), self::CONFIG_KEY_TRANSACTION_CODE_LENGTH => (int)$config->getProperty(self::CONFIG_KEY_TRANSACTION_CODE_LENGTH) ); } public function readFormValuesFromRequest(AphrontRequest $request) { return array( self::CONFIG_KEY_DEBUG_MODE => $request->getStr(self::CONFIG_KEY_DEBUG_MODE), self::CONFIG_KEY_APP_NAME => $request->getStr(self::CONFIG_KEY_APP_NAME), self::CONFIG_KEY_TRANSACTION_CODE_LENGTH => (int)$request->getStr(self::CONFIG_KEY_TRANSACTION_CODE_LENGTH) ); } public function processEditForm(AphrontRequest $request, array $values) { $errors = array(); $issues = array(); if(!strlen(self::getArrayValueOrDefault($values, self::CONFIG_KEY_APP_NAME))) { $errors[] = pht('Application Name is required.'); $issues[self::CONFIG_KEY_APP_NAME] = pht('Required'); } $trans_code_length = (int) self::getArrayValueOrDefault( $values, self::CONFIG_KEY_TRANSACTION_CODE_LENGTH ); if($trans_code_length < 16 || $trans_code_length > 64) { $errors[] = pht('Transaction Code Length must be between 16 and 64.'); $issues[self::CONFIG_KEY_TRANSACTION_CODE_LENGTH] = pht('Invalid'); } if(!count($errors)) { $config = $this->getProviderConfig(); $config->setProviderDomain($this->getProviderDomain()); } return array($errors, $issues, $values); } public function getAdapter() { if (!$this->adapter) { $this->adapter = $adapter = new PhabricatorBMOAuthAdapter(); $this->configureAdapter($adapter); } return $this->adapter; } public function getConfig() { if (!$this->providerConfig) { $this->providerConfig = $this->getProviderConfig(); } return $this->providerConfig; } public function configureAdapter($adapter) { $config = $this->getConfig(); $adapter->setAdapterType(self::ADAPTER_TYPE); $adapter->setAdapterDomain($config->getProviderDomain()); $adapter->setAuthenticateURI( id(new PhutilURI(PhabricatorEnv::getEnvConfig('bugzilla.url'))) ->setPath('/auth.cgi') ->setQueryParam( 'description', $config->getProperty(self::CONFIG_KEY_APP_NAME) ) ); } /* * Methods to guide the user through the auth and login processes */ protected function renderLoginForm(AphrontRequest $request, $mode) { require_celerity_resource('bmo-auth-css'); require_celerity_resource('bmo-auth-js'); $adapter = $this->getAdapter(); $csrf = $this->getAuthCSRFCode($request); $login_uri = $this->getLoginURI(); $uri = new PhutilURI($adapter->getAuthenticateURI()); $uri->setQueryParam('callback', PhabricatorEnv::getURI($login_uri).'?secret='.$csrf); return $this->renderStandardLoginButton($request, $mode, array( 'method' => 'GET', 'uri' => (string) $uri, 'sigil' => 'bmo-login' )); } public function processLoginRequest( PhabricatorAuthLoginController $controller) { $request = $controller->getRequest(); // When Bugzilla sends us a "back channel" POST during registration... if ($request->isHTTPPost()) { return $this->processLoginRequestBackChannelPost($controller, $request); } // The last step in Bugzilla's verification process return $this->processLoginRequestConfirmationGet($controller, $request); } private function processLoginRequestBackChannelPost($controller) { $config = $this->getConfig(); $request = $controller->getRequest(); $account = null; $response = null; // Get the `client_api_key` and `client_api_login` from Bugzilla's POST data $post_info = array(); try { $post_info = phutil_json_decode(PhabricatorStartup::getRawInput()); } catch(Exception $ex) { $this->throwException( pht('Phabricator BMO Authentication failed due to '. 'invalid JSON from Bugzilla.') ); } // Throw exception if either key is not provided by Bugzilla if(!isset($post_info['client_api_key']) || !isset($post_info['client_api_login'])) { $this->throwException( pht('Phabricator BMO Authentication failed due to '. 'incomplete JSON from Bugzilla.') ); } // Generate a transaction code which we'll receive back from Bugzilla // To confirm the API and Client Login information which we saved $trans_code = $this->generateAuthToken(); $csrf = $request->getStr('secret'); if(!strlen($csrf)) { $this->throwException( pht('No CSRF was provided by Bugzilla in the URL.') ); } // Create a temporary auth token to save the user info JSON provided // by the POST from Bugzilla. Implicitly validates CSRF. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); id(new PhabricatorAuthTemporaryToken()) ->setTokenResource($trans_code) ->setTokenCode($csrf) ->setTokenType(self::TOKEN_TYPE) ->setTokenExpires(time() + phutil_units('1 hour in seconds')) ->setTemporaryTokenProperty('api_key', $post_info['client_api_key']) ->setTemporaryTokenProperty('client_login', $post_info['client_api_login']) ->save(); unset($unguarded); // Return a JSON object with a `result` key // This result represents the transaction key we'll use to look up // the JSON information in the next phase of requests $json_obj = array('result' => $trans_code); // Render the response JSON $response = id(new AphrontJSONResponse()) ->setAddJSONShield(false) ->setHTTPResponseCode(200) ->setContent($json_obj); return array($account, $response); } private function processLoginRequestConfirmationGet($controller, $request) { $response = null; $csrf = $request->getStr('secret'); // Verify with CSRF as an additional security measure $this->verifyAuthCSRFCode($request, $csrf); // Match result token and client_api_login to find client_api_key $provided_trans_code = $request->getStr('callback_result'); $provided_api_login = $request->getStr('client_api_login'); $token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTokenResources(array($provided_trans_code)) ->withTokenCodes(array($csrf)) ->withTokenTypes(array(self::TOKEN_TYPE)) ->withExpired(false) ->executeOne(); // No token means we've received invalid information from Bugzilla if(!$token) { $this->throwException(pht('No temporary token found for this transaction code (%s) or CSRF token (%s).', $provided_trans_code, $csrf)); } // Compare result token and client_api_login to original response $token_props = $token->getProperties(); if($token_props['client_login'] != $provided_api_login) { $this->throwException( pht('Token\'s API Login does not match Bugzilla API Login.') ); } // Call BMO Who Am I REST resource to validate API Key (X-Bugzilla-API-Key) $adapter = $this->getAdapter(); $config = $this->getConfig(); $api_key = $token_props['api_key']; $future_uri = id(new PhutilURI(PhabricatorEnv::getEnvConfig('bugzilla.url'))) ->setPath('/rest/whoami'); $future = id(new HTTPSFuture((string) $future_uri)) ->setMethod('GET') ->addHeader('X-Bugzilla-API-Key', $api_key) ->addHeader('Accept', 'application/json') ->setTimeout(PhabricatorEnv::getEnvConfig('bugzilla.timeout')); // Resolve the async HTTPSFuture request and extract JSON body $whoami_body = ''; try { list($whoami_body) = $future->resolvex(); } catch (HTTPFutureResponseStatus $ex) { $this->throwException(pht('Bugzilla WhoAmI request failed to resolve.')); } $user_json = array(); try { $user_json = phutil_json_decode($whoami_body); } catch(Exception $e) { $this->throwException( pht('JSON from Bugzilla WhoAmI could not be parsed: '.$whoami_body) ); } // If there's no "id" key in the JSON, we know something is wrong if(!isset($user_json['id'])) { $this->throwException(pht('No user ID was provided by Bugzilla.')); } // Clean up! Delete temporary token used for this login $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $token->delete(); unset($unguarded); // Provide the adapter with massaged user data $this->setAccountDetails($user_json); // Create or load the user account and refresh the page $account = $this->loadOrCreateAccount($user_json['id']); return array($account, $response); } private function setAccountDetails($user_json) { $adapter = $this->getAdapter(); // Save the user information, then creating or loading the account // Example: Array ( [real_name] => Vagrant User [name] => vagrant@bmo-web.vm [id] => 1 ) $real_name = $user_json['real_name']; $bugzilla_nick = $adapter->parseBugzillaNick($real_name); if($bugzilla_nick) { - $real_name = trim(str_replace(':'.$bugzilla_nick, '', $real_name)); + $real_name = str_replace(':'.$bugzilla_nick, '', $real_name); + $real_name = trim(str_replace(array('()', '[]'), '', $real_name)); } $adapter->setAccountName($bugzilla_nick ?: str_replace(' ', '', $real_name)); $adapter->setAccountRealName($real_name); $adapter->setAccountID($user_json['id']); $adapter->setAccountEmail($user_json['name']); } public function throwException($text) { $config = $this->getConfig(); throw new Exception(pht( $config->getProperty(self::CONFIG_KEY_DEBUG_MODE) ? $text : self::GENERIC_ERROR )); } public function generateAuthToken() { $config = $this->getConfig(); return Filesystem::readRandomCharacters( $config->getProperty(self::CONFIG_KEY_TRANSACTION_CODE_LENGTH) ); } public function getProviderDomain() { list($bmo_protocol, $bmo_host) = explode('://', PhabricatorEnv::getEnvConfig('bugzilla.url')); return $bmo_host; } }