diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index 345ace3df9..ec49f7f748 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -1,558 +1,566 @@ getID().'.'.$name; } public static function getAllFactors() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getFactorKey') ->execute(); } protected function newConfigForUser(PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfig()) ->setUserPHID($user->getPHID()) ->setFactorSecret(''); } protected function newResult() { return new PhabricatorAuthFactorResult(); } public function newIconView() { return id(new PHUIIconView()) ->setIcon('fa-mobile'); } public function canCreateNewProvider() { return true; } public function getProviderCreateDescription() { return null; } public function canCreateNewConfiguration( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return null; } public function getConfigurationListDetails( PhabricatorAuthFactorConfig $config, PhabricatorAuthFactorProvider $provider, PhabricatorUser $viewer) { return null; } public function newEditEngineFields( PhabricatorEditEngine $engine, PhabricatorAuthFactorProvider $provider) { return array(); } /** * Is this a factor which depends on the user's contact number? * * If a user has a "contact number" factor configured, they can not modify * or switch their primary contact number. * * @return bool True if this factor should lock contact numbers. */ public function isContactNumberFactor() { return false; } abstract public function getEnrollDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user); public function getEnrollButtonText( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return pht('Continue'); } public function getFactorOrder() { return 1000; } final public function newSortVector() { return id(new PhutilSortVector()) ->addInt($this->canCreateNewProvider() ? 0 : 1) ->addInt($this->getFactorOrder()) ->addString($this->getFactorName()); } protected function newChallenge( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer) { $engine = $config->getSessionEngine(); return PhabricatorAuthChallenge::initializeNewChallenge() ->setUserPHID($viewer->getPHID()) ->setSessionPHID($viewer->getSession()->getPHID()) ->setFactorPHID($config->getPHID()) ->setWorkflowKey($engine->getWorkflowKey()); } abstract public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $response); final public function getNewIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $now = PhabricatorTime::getNow(); // Factor implementations may need to perform writes in order to issue // challenges, particularly push factors like SMS. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $new_challenges = $this->newIssuedChallenges( $config, $viewer, $challenges); - if ($new_challenges instanceof PhabricatorAuthFactorResult) { + if ($this->isAuthResult($new_challenges)) { unset($unguarded); return $new_challenges; } assert_instances_of($new_challenges, 'PhabricatorAuthChallenge'); foreach ($new_challenges as $new_challenge) { $ttl = $new_challenge->getChallengeTTL(); if (!$ttl) { throw new Exception( pht('Newly issued MFA challenges must have a valid TTL!')); } if ($ttl < $now) { throw new Exception( pht( 'Newly issued MFA challenges must have a future TTL. This '. 'factor issued a bad TTL ("%s"). (Did you use a relative '. 'time instead of an epoch?)', $ttl)); } } foreach ($new_challenges as $challenge) { $challenge->save(); } unset($unguarded); return $new_challenges; } abstract protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromIssuedChallenges( $config, $viewer, $challenges); if ($result === null) { return $result; } - if (!($result instanceof PhabricatorAuthFactorResult)) { + if (!$this->isAuthResult($result)) { throw new Exception( pht( 'Expected "newResultFromIssuedChallenges()" to return null or '. 'an object of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } $result->setIssuedChallenges($challenges); return $result; } abstract protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromChallengeResponse( $config, $viewer, $request, $challenges); - if (!($result instanceof PhabricatorAuthFactorResult)) { + if (!$this->isAuthResult($result)) { throw new Exception( pht( 'Expected "newResultFromChallengeResponse()" to return an object '. 'of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } $result->setIssuedChallenges($challenges); return $result; } abstract protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges); final protected function newAutomaticControl( PhabricatorAuthFactorResult $result) { $is_error = $result->getIsError(); if ($is_error) { return $this->newErrorControl($result); } $is_continue = $result->getIsContinue(); if ($is_continue) { return $this->newContinueControl($result); } $is_answered = (bool)$result->getAnsweredChallenge(); if ($is_answered) { return $this->newAnsweredControl($result); } $is_wait = $result->getIsWait(); if ($is_wait) { return $this->newWaitControl($result); } return null; } private function newWaitControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = id(new PHUIIconView()) ->setIcon('fa-clock-o', 'red'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error) ->setError(pht('Wait')); } private function newAnsweredControl( PhabricatorAuthFactorResult $result) { $icon = id(new PHUIIconView()) ->setIcon('fa-check-circle-o', 'green'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild( pht('You responded to this challenge correctly.')); } private function newErrorControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = id(new PHUIIconView()) ->setIcon('fa-times', 'red'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error) ->setError(pht('Error')); } private function newContinueControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = id(new PHUIIconView()) ->setIcon('fa-commenting', 'green'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error); } /* -( Synchronizing New Factors )------------------------------------------ */ final protected function loadMFASyncToken( PhabricatorAuthFactorProvider $provider, AphrontRequest $request, AphrontFormView $form, PhabricatorUser $user) { // If the form included a synchronization key, load the corresponding // token. The user must synchronize to a key we generated because this // raises the barrier to theoretical attacks where an attacker might // provide a known key for factors like TOTP. // (We store and verify the hash of the key, not the key itself, to limit // how useful the data in the table is to an attacker.) $sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE; $sync_token = null; $sync_key = $request->getStr($this->getMFASyncTokenFormKey()); if (strlen($sync_key)) { $sync_key_digest = PhabricatorHash::digestWithNamedKey( $sync_key, PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); $sync_token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($sync_type)) ->withExpired(false) ->withTokenCodes(array($sync_key_digest)) ->executeOne(); } if (!$sync_token) { // Don't generate a new sync token if there are too many outstanding // tokens already. This is mostly relevant for push factors like SMS, // where generating a token has the side effect of sending a user a // message. $outstanding_limit = 10; $outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($sync_type)) ->withExpired(false) ->execute(); if (count($outstanding_tokens) > $outstanding_limit) { throw new Exception( pht( 'Your account has too many outstanding, incomplete MFA '. 'synchronization attempts. Wait an hour and try again.')); } $now = PhabricatorTime::getNow(); $sync_key = Filesystem::readRandomCharacters(32); $sync_key_digest = PhabricatorHash::digestWithNamedKey( $sync_key, PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); $sync_ttl = $this->getMFASyncTokenTTL(); $sync_token = id(new PhabricatorAuthTemporaryToken()) ->setIsNewTemporaryToken(true) ->setTokenResource($user->getPHID()) ->setTokenType($sync_type) ->setTokenCode($sync_key_digest) ->setTokenExpires($now + $sync_ttl); $properties = $this->newMFASyncTokenProperties( $provider, $user); + if ($this->isAuthResult($properties)) { + return $properties; + } + foreach ($properties as $key => $value) { $sync_token->setTemporaryTokenProperty($key, $value); } $sync_token->save(); } $form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key); return $sync_token; } protected function newMFASyncTokenProperties( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return array(); } private function getMFASyncTokenFormKey() { return 'sync.key'; } private function getMFASyncTokenTTL() { return phutil_units('1 hour in seconds'); } final protected function getChallengeForCurrentContext( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $session_phid = $viewer->getSession()->getPHID(); $engine = $config->getSessionEngine(); $workflow_key = $engine->getWorkflowKey(); foreach ($challenges as $challenge) { if ($challenge->getSessionPHID() !== $session_phid) { continue; } if ($challenge->getWorkflowKey() !== $workflow_key) { continue; } if ($challenge->getIsCompleted()) { continue; } if ($challenge->getIsReusedChallenge()) { continue; } return $challenge; } return null; } /** * @phutil-external-symbol class QRcode */ final protected function newQRCode($uri) { $root = dirname(phutil_get_library_root('phabricator')); require_once $root.'/externals/phpqrcode/phpqrcode.php'; $lines = QRcode::text($uri); $total_width = 240; $cell_size = floor($total_width / count($lines)); $rows = array(); foreach ($lines as $line) { $cells = array(); for ($ii = 0; $ii < strlen($line); $ii++) { if ($line[$ii] == '1') { $color = '#000'; } else { $color = '#fff'; } $cells[] = phutil_tag( 'td', array( 'width' => $cell_size, 'height' => $cell_size, 'style' => 'background: '.$color, ), ''); } $rows[] = phutil_tag('tr', array(), $cells); } return phutil_tag( 'table', array( 'style' => 'margin: 24px auto;', ), $rows); } final protected function getInstallDisplayName() { $uri = PhabricatorEnv::getURI('/'); $uri = new PhutilURI($uri); return $uri->getDomain(); } final protected function getChallengeResponseParameterName( PhabricatorAuthFactorConfig $config) { return $this->getParameterName($config, 'mfa.response'); } final protected function getChallengeResponseFromRequest( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $name = $this->getChallengeResponseParameterName($config); $value = $request->getStr($name); $value = (string)$value; $value = trim($value); return $value; } final protected function hasCSRF(PhabricatorAuthFactorConfig $config) { $engine = $config->getSessionEngine(); $request = $engine->getRequest(); if (!$request->isHTTPPost()) { return false; } return $request->validateCSRF(); } final protected function loadConfigurationsForProvider( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfigQuery()) ->setViewer($user) ->withUserPHIDs(array($user->getPHID())) ->withFactorProviderPHIDs(array($provider->getPHID())) ->execute(); } + final protected function isAuthResult($object) { + return ($object instanceof PhabricatorAuthFactorResult); + } + } diff --git a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php index 33543f41cb..187e011953 100644 --- a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php @@ -1,799 +1,813 @@ loadConfigurationsForProvider($provider, $user)) { return false; } return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $messages = array(); if ($this->loadConfigurationsForProvider($provider, $user)) { $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'You already have Duo authentication attached to your account '. 'for this provider.'), )); } return $messages; } public function getConfigurationListDetails( PhabricatorAuthFactorConfig $config, PhabricatorAuthFactorProvider $provider, PhabricatorUser $viewer) { $duo_user = $config->getAuthFactorConfigProperty('duo.username'); return pht('Duo Username: %s', $duo_user); } public function newEditEngineFields( PhabricatorEditEngine $engine, PhabricatorAuthFactorProvider $provider) { $viewer = $engine->getViewer(); $credential_phid = $provider->getAuthFactorProviderProperty( self::PROP_CREDENTIAL); $hostname = $provider->getAuthFactorProviderProperty(self::PROP_HOSTNAME); $usernames = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); $enroll = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); $credential_type = PassphrasePasswordCredentialType::CREDENTIAL_TYPE; $provides_type = PassphrasePasswordCredentialType::PROVIDES_TYPE; $credentials = id(new PassphraseCredentialQuery()) ->setViewer($viewer) ->withIsDestroyed(false) ->withProvidesTypes(array($provides_type)) ->execute(); $xaction_hostname = PhabricatorAuthFactorProviderDuoHostnameTransaction::TRANSACTIONTYPE; $xaction_credential = PhabricatorAuthFactorProviderDuoCredentialTransaction::TRANSACTIONTYPE; $xaction_usernames = PhabricatorAuthFactorProviderDuoUsernamesTransaction::TRANSACTIONTYPE; $xaction_enroll = PhabricatorAuthFactorProviderDuoEnrollTransaction::TRANSACTIONTYPE; return array( id(new PhabricatorTextEditField()) ->setLabel(pht('Duo API Hostname')) ->setKey('duo.hostname') ->setValue($hostname) ->setTransactionType($xaction_hostname) ->setIsRequired(true), id(new PhabricatorCredentialEditField()) ->setLabel(pht('Duo API Credential')) ->setKey('duo.credential') ->setValue($credential_phid) ->setTransactionType($xaction_credential) ->setCredentialType($credential_type) ->setCredentials($credentials), id(new PhabricatorSelectEditField()) ->setLabel(pht('Duo Username')) ->setKey('duo.usernames') ->setValue($usernames) ->setTransactionType($xaction_usernames) ->setOptions( array( 'username' => pht('Use Phabricator Username'), 'email' => pht('Use Primary Email Address'), )), id(new PhabricatorSelectEditField()) ->setLabel(pht('Create Accounts')) ->setKey('duo.enroll') ->setValue($enroll) ->setTransactionType($xaction_enroll) ->setOptions( array( 'deny' => pht('Require Existing Duo Account'), 'allow' => pht('Create New Duo Account'), )), ); } public function processAddFactorForm( PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { $token = $this->loadMFASyncToken($provider, $request, $form, $user); + if ($this->isAuthResult($token)) { + $form->appendChild($this->newAutomaticControl($token)); + return; + } $enroll = $token->getTemporaryTokenProperty('duo.enroll'); $duo_id = $token->getTemporaryTokenProperty('duo.user-id'); $duo_uri = $token->getTemporaryTokenProperty('duo.uri'); $duo_user = $token->getTemporaryTokenProperty('duo.username'); $is_external = ($enroll === 'external'); $is_auto = ($enroll === 'auto'); $is_blocked = ($enroll === 'blocked'); if (!$token->getIsNewTemporaryToken()) { if ($is_auto) { return $this->newDuoConfig($user, $duo_user); } else if ($is_external || $is_blocked) { $parameters = array( 'username' => $duo_user, ); $result = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $result_code = $result['response']['result']; switch ($result_code) { case 'auth': case 'allow': return $this->newDuoConfig($user, $duo_user); case 'enroll': if ($is_blocked) { // We'll render an equivalent static control below, so skip // rendering here. We explicitly don't want to give the user // an enroll workflow. break; } $duo_uri = $result['response']['enroll_portal_url']; $waiting_icon = id(new PHUIIconView()) ->setIcon('fa-mobile', 'red'); $waiting_control = id(new PHUIFormTimerControl()) ->setIcon($waiting_icon) ->setError(pht('Not Complete')) ->appendChild( pht( 'You have not completed Duo enrollment yet. '. 'Complete enrollment, then click continue.')); $form->appendControl($waiting_control); break; default: case 'deny': break; } } else { $parameters = array( 'user_id' => $duo_id, 'activation_code' => $duo_uri, ); $future = $this->newDuoFuture($provider) ->setMethod('enroll_status', $parameters); $result = $future->resolve(); $response = $result['response']; switch ($response) { case 'success': return $this->newDuoConfig($user, $duo_user); case 'waiting': $waiting_icon = id(new PHUIIconView()) ->setIcon('fa-mobile', 'red'); $waiting_control = id(new PHUIFormTimerControl()) ->setIcon($waiting_icon) ->setError(pht('Not Complete')) ->appendChild( pht( 'You have not activated this enrollment in the Duo '. 'application on your phone yet. Complete activation, then '. 'click continue.')); $form->appendControl($waiting_control); break; case 'invalid': default: throw new Exception( pht( 'This Duo enrollment attempt is invalid or has '. 'expired ("%s"). Cancel the workflow and try again.', $response)); } } } if ($is_blocked) { $blocked_icon = id(new PHUIIconView()) ->setIcon('fa-times', 'red'); $blocked_control = id(new PHUIFormTimerControl()) ->setIcon($blocked_icon) ->appendChild( pht( 'Your Duo account ("%s") has not completed Duo enrollment. '. 'Check your email and complete enrollment to continue.', phutil_tag('strong', array(), $duo_user))); $form->appendControl($blocked_control); } else if ($is_auto) { $auto_icon = id(new PHUIIconView()) ->setIcon('fa-check', 'green'); $auto_control = id(new PHUIFormTimerControl()) ->setIcon($auto_icon) ->appendChild( pht( 'Duo account ("%s") is fully enrolled.', phutil_tag('strong', array(), $duo_user))); $form->appendControl($auto_control); } else { $duo_button = phutil_tag( 'a', array( 'href' => $duo_uri, 'class' => 'button button-grey', 'target' => ($is_external ? '_blank' : null), ), pht('Enroll Duo Account: %s', $duo_user)); $duo_button = phutil_tag( 'div', array( 'class' => 'mfa-form-enroll-button', ), $duo_button); if ($is_external) { $form->appendRemarkupInstructions( pht( 'Complete enrolling your phone with Duo:')); $form->appendControl( id(new AphrontFormMarkupControl()) ->setValue($duo_button)); } else { $form->appendRemarkupInstructions( pht( 'Scan this QR code with the Duo application on your mobile '. 'phone:')); $qr_code = $this->newQRCode($duo_uri); $form->appendChild($qr_code); $form->appendRemarkupInstructions( pht( 'If you are currently using your phone to view this page, '. 'click this button to open the Duo application:')); $form->appendControl( id(new AphrontFormMarkupControl()) ->setValue($duo_button)); } $form->appendRemarkupInstructions( pht( 'Once you have completed setup on your phone, click continue.')); } } protected function newMFASyncTokenProperties( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $duo_user = $this->getDuoUsername($provider, $user); // Duo automatically normalizes usernames to lowercase. Just do that here // so that our value agrees more closely with Duo. $duo_user = phutil_utf8_strtolower($duo_user); $parameters = array( 'username' => $duo_user, ); $result = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $external_uri = null; $result_code = $result['response']['result']; + $status_message = $result['response']['status_msg']; switch ($result_code) { case 'auth': case 'allow': // If the user already has a Duo account, they don't need to do // anything. return array( 'duo.enroll' => 'auto', 'duo.username' => $duo_user, ); case 'enroll': if (!$this->shouldAllowDuoEnrollment($provider)) { return array( 'duo.enroll' => 'blocked', 'duo.username' => $duo_user, ); } $external_uri = $result['response']['enroll_portal_url']; // Otherwise, enrollment is permitted so we're going to continue. break; default: case 'deny': return $this->newResult() ->setIsError(true) ->setErrorMessage( - pht('Your account is not permitted to access this system.')); + pht( + 'Your Duo account ("%s") is not permitted to access this '. + 'system. Contact your Duo administrator for help. '. + 'The Duo preauth API responded with status message ("%s"): %s', + $duo_user, + $result_code, + $status_message)); } // Duo's "/enroll" API isn't repeatable for the same username. If we're // the first call, great: we can do inline enrollment, which is way more // user friendly. Otherwise, we have to send the user on an adventure. $parameters = array( 'username' => $duo_user, 'valid_secs' => phutil_units('1 hour in seconds'), ); try { $result = $this->newDuoFuture($provider) ->setMethod('enroll', $parameters) ->resolve(); } catch (HTTPFutureHTTPResponseStatus $ex) { return array( 'duo.enroll' => 'external', 'duo.username' => $duo_user, 'duo.uri' => $external_uri, ); } return array( 'duo.enroll' => 'inline', 'duo.uri' => $result['response']['activation_code'], 'duo.username' => $duo_user, 'duo.user-id' => $result['response']['user_id'], ); } protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { // If we already issued a valid challenge for this workflow and session, // don't issue a new one. $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge) { return array(); } if (!$this->hasCSRF($config)) { return $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'An authorization request will be pushed to the Duo '. 'application on your phone.')); } $provider = $config->getFactorProvider(); // Otherwise, issue a new challenge. $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username'); $parameters = array( 'username' => $duo_user, ); $response = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $response = $response['response']; $next_step = $response['result']; $status_message = $response['status_msg']; switch ($next_step) { case 'auth': // We're good to go. break; case 'allow': // Duo is telling us to bypass MFA. For now, refuse. return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Duo is not requiring a challenge, which defeats the '. 'purpose of MFA. Duo must be configured to challenge you.')); case 'enroll': return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Your Duo account ("%s") requires enrollment. Contact your '. 'Duo administrator for help. Duo status message: %s', $duo_user, $status_message)); case 'deny': default: return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( - 'Duo has denied you access. Duo status message ("%s"): %s', + 'Your Duo account ("%s") is not permitted to access this '. + 'system. Contact your Duo administrator for help. The Duo '. + 'preauth API responded with status message ("%s"): %s', + $duo_user, $next_step, $status_message)); } $has_push = false; $devices = $response['devices']; foreach ($devices as $device) { $capabilities = array_fuse($device['capabilities']); if (isset($capabilities['push'])) { $has_push = true; break; } } if (!$has_push) { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'This factor has been removed from your device, so Phabricator '. 'can not send you a challenge. To continue, an administrator '. 'must strip this factor from your account.')); } $push_info = array( pht('Domain') => $this->getInstallDisplayName(), ); $push_info = phutil_build_http_querystring($push_info); $parameters = array( 'username' => $duo_user, 'factor' => 'push', 'async' => '1', // Duo allows us to specify a device, or to pass "auto" to have it pick // the first one. For now, just let it pick. 'device' => 'auto', // This is a hard-coded prefix for the word "... request" in the Duo UI, // which defaults to "Login". We could pass richer information from // workflows here, but it's not very flexible anyway. 'type' => 'Authentication', 'display_username' => $viewer->getUsername(), 'pushinfo' => $push_info, ); $result = $this->newDuoFuture($provider) ->setMethod('auth', $parameters) ->resolve(); $duo_xaction = $result['response']['txid']; // The Duo push timeout is 60 seconds. Set our challenge to expire slightly // more quickly so that we'll re-issue a new challenge before Duo times out. // This should keep users away from a dead-end where they can't respond to // Duo but Phabricator won't issue a new challenge yet. $ttl_seconds = 55; return array( $this->newChallenge($config, $viewer) ->setChallengeKey($duo_xaction) ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), ); } protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge->getIsAnsweredChallenge()) { return $this->newResult() ->setAnsweredChallenge($challenge); } $provider = $config->getFactorProvider(); $duo_xaction = $challenge->getChallengeKey(); $parameters = array( 'txid' => $duo_xaction, ); // This endpoint always long-polls, so use a timeout to force it to act // more asynchronously. try { $result = $this->newDuoFuture($provider) ->setHTTPMethod('GET') ->setMethod('auth_status', $parameters) ->setTimeout(5) ->resolve(); $state = $result['response']['result']; $status = $result['response']['status']; } catch (HTTPFutureCURLResponseStatus $exception) { if ($exception->isTimeout()) { $state = 'waiting'; $status = 'poll'; } else { throw $exception; } } $now = PhabricatorTime::getNow(); switch ($state) { case 'allow': $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); $challenge ->markChallengeAsAnswered($ttl); return $this->newResult() ->setAnsweredChallenge($challenge); case 'waiting': // No result yet, we'll render a default state later on. break; default: case 'deny': if ($status === 'timeout') { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'This request has timed out because you took too long to '. 'respond.')); } else { $wait_duration = ($challenge->getChallengeTTL() - $now) + 1; return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'You denied this request. Wait %s second(s) to try again.', new PhutilNumber($wait_duration))); } break; } return null; } public function renderValidateFactorForm( PhabricatorAuthFactorConfig $config, AphrontFormView $form, PhabricatorUser $viewer, PhabricatorAuthFactorResult $result) { $control = $this->newAutomaticControl($result); if (!$control) { $result = $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'A challenge has been sent to your phone. Open the Duo '. 'application and confirm the challenge, then continue.')); $control = $this->newAutomaticControl($result); } $control ->setLabel(pht('Duo')) ->setCaption(pht('Factor Name: %s', $config->getFactorName())); $form->appendChild($control); } public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $value = $this->getChallengeResponseFromRequest($config, $request); return (bool)strlen($value); } protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); $code = $this->getChallengeResponseFromRequest( $config, $request); $result = $this->newResult() ->setValue($code); if ($challenge->getIsAnsweredChallenge()) { return $result->setAnsweredChallenge($challenge); } if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); $challenge ->markChallengeAsAnswered($ttl); return $result->setAnsweredChallenge($challenge); } if (strlen($code)) { $error_message = pht('Invalid'); } else { $error_message = pht('Required'); } $result->setErrorMessage($error_message); return $result; } private function newDuoFuture(PhabricatorAuthFactorProvider $provider) { $credential_phid = $provider->getAuthFactorProviderProperty( self::PROP_CREDENTIAL); $omnipotent = PhabricatorUser::getOmnipotentUser(); $credential = id(new PassphraseCredentialQuery()) ->setViewer($omnipotent) ->withPHIDs(array($credential_phid)) ->needSecrets(true) ->executeOne(); if (!$credential) { throw new Exception( pht( 'Unable to load Duo API credential ("%s").', $credential_phid)); } $duo_key = $credential->getUsername(); $duo_secret = $credential->getSecret(); if (!$duo_secret) { throw new Exception( pht( 'Duo API credential ("%s") has no secret key.', $credential_phid)); } $duo_host = $provider->getAuthFactorProviderProperty( self::PROP_HOSTNAME); self::requireDuoAPIHostname($duo_host); return id(new PhabricatorDuoFuture()) ->setIntegrationKey($duo_key) ->setSecretKey($duo_secret) ->setAPIHostname($duo_host) ->setTimeout(10) ->setHTTPMethod('POST'); } private function getDuoUsername( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); switch ($mode) { case 'username': return $user->getUsername(); case 'email': return $user->loadPrimaryEmailAddress(); default: throw new Exception( pht( 'Duo username pairing mode ("%s") is not supported.', $mode)); } } private function shouldAllowDuoEnrollment( PhabricatorAuthFactorProvider $provider) { $mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); switch ($mode) { case 'deny': return false; case 'allow': return true; default: throw new Exception( pht( 'Duo enrollment mode ("%s") is not supported.', $mode)); } } private function newDuoConfig(PhabricatorUser $user, $duo_user) { $config_properties = array( 'duo.username' => $duo_user, ); $config = $this->newConfigForUser($user) ->setFactorName(pht('Duo (%s)', $duo_user)) ->setProperties($config_properties); return $config; } public static function requireDuoAPIHostname($hostname) { if (preg_match('/\.duosecurity\.com\z/', $hostname)) { return; } throw new Exception( pht( 'Duo API hostname ("%s") is invalid, hostname must be '. '"*.duosecurity.com".', $hostname)); } }