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 @@ -2228,6 +2228,10 @@ 'PhabricatorAuthFactorConfigQuery' => 'applications/auth/query/PhabricatorAuthFactorConfigQuery.php', 'PhabricatorAuthFactorProvider' => 'applications/auth/storage/PhabricatorAuthFactorProvider.php', 'PhabricatorAuthFactorProviderController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderController.php', + 'PhabricatorAuthFactorProviderDuoCredentialTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php', + 'PhabricatorAuthFactorProviderDuoEnrollTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php', + 'PhabricatorAuthFactorProviderDuoHostnameTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php', + 'PhabricatorAuthFactorProviderDuoUsernamesTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php', 'PhabricatorAuthFactorProviderEditController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php', 'PhabricatorAuthFactorProviderEditEngine' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php', 'PhabricatorAuthFactorProviderEditor' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditor.php', @@ -2800,6 +2804,7 @@ 'PhabricatorCountdownTransactionType' => 'applications/countdown/xaction/PhabricatorCountdownTransactionType.php', 'PhabricatorCountdownView' => 'applications/countdown/view/PhabricatorCountdownView.php', 'PhabricatorCountdownViewController' => 'applications/countdown/controller/PhabricatorCountdownViewController.php', + 'PhabricatorCredentialEditField' => 'applications/transactions/editfield/PhabricatorCredentialEditField.php', 'PhabricatorCursorPagedPolicyAwareQuery' => 'infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php', 'PhabricatorCustomField' => 'infrastructure/customfield/field/PhabricatorCustomField.php', 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource.php', @@ -2986,6 +2991,7 @@ 'PhabricatorDraftEngine' => 'applications/transactions/draft/PhabricatorDraftEngine.php', 'PhabricatorDraftInterface' => 'applications/transactions/draft/PhabricatorDraftInterface.php', 'PhabricatorDrydockApplication' => 'applications/drydock/application/PhabricatorDrydockApplication.php', + 'PhabricatorDuoAuthFactor' => 'applications/auth/factor/PhabricatorDuoAuthFactor.php', 'PhabricatorDuoFuture' => 'applications/auth/future/PhabricatorDuoFuture.php', 'PhabricatorEdgeChangeRecord' => 'infrastructure/edges/util/PhabricatorEdgeChangeRecord.php', 'PhabricatorEdgeChangeRecordTestCase' => 'infrastructure/edges/__tests__/PhabricatorEdgeChangeRecordTestCase.php', @@ -7958,6 +7964,10 @@ 'PhabricatorEditEngineMFAInterface', ), 'PhabricatorAuthFactorProviderController' => 'PhabricatorAuthProviderController', + 'PhabricatorAuthFactorProviderDuoCredentialTransaction' => 'PhabricatorAuthFactorProviderTransactionType', + 'PhabricatorAuthFactorProviderDuoEnrollTransaction' => 'PhabricatorAuthFactorProviderTransactionType', + 'PhabricatorAuthFactorProviderDuoHostnameTransaction' => 'PhabricatorAuthFactorProviderTransactionType', + 'PhabricatorAuthFactorProviderDuoUsernamesTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 'PhabricatorAuthFactorProviderEditController' => 'PhabricatorAuthFactorProviderController', 'PhabricatorAuthFactorProviderEditEngine' => 'PhabricatorEditEngine', 'PhabricatorAuthFactorProviderEditor' => 'PhabricatorApplicationTransactionEditor', @@ -8633,6 +8643,7 @@ 'PhabricatorCountdownTransactionType' => 'PhabricatorModularTransactionType', 'PhabricatorCountdownView' => 'AphrontView', 'PhabricatorCountdownViewController' => 'PhabricatorCountdownController', + 'PhabricatorCredentialEditField' => 'PhabricatorEditField', 'PhabricatorCursorPagedPolicyAwareQuery' => 'PhabricatorPolicyAwareQuery', 'PhabricatorCustomField' => 'Phobject', 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'PhabricatorTypeaheadDatasource', @@ -8837,6 +8848,7 @@ 'PhabricatorDraftDAO' => 'PhabricatorLiskDAO', 'PhabricatorDraftEngine' => 'Phobject', 'PhabricatorDrydockApplication' => 'PhabricatorApplication', + 'PhabricatorDuoAuthFactor' => 'PhabricatorAuthFactor', 'PhabricatorDuoFuture' => 'FutureProxy', 'PhabricatorEdgeChangeRecord' => 'Phobject', 'PhabricatorEdgeChangeRecordTestCase' => 'PhabricatorTestCase', diff --git a/src/applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php b/src/applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php --- a/src/applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php +++ b/src/applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php @@ -93,11 +93,12 @@ } protected function buildCustomEditFields($object) { - $factor_name = $object->getFactor()->getFactorName(); + $factor = $object->getFactor(); + $factor_name = $factor->getFactorName(); $status_map = PhabricatorAuthFactorProviderStatus::getMap(); - return array( + $fields = array( id(new PhabricatorStaticEditField()) ->setKey('displayType') ->setLabel(pht('Factor Type')) @@ -120,6 +121,13 @@ ->setValue($object->getStatus()) ->setOptions($status_map), ); + + $factor_fields = $factor->newEditEngineFields($this, $object); + foreach ($factor_fields as $field) { + $fields[] = $field; + } + + return $fields; } } diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -74,6 +74,12 @@ return null; } + public function newEditEngineFields( + PhabricatorEditEngine $engine, + PhabricatorAuthFactorProvider $provider) { + return array(); + } + /** * Is this a factor which depends on the user's contact number? * @@ -331,6 +337,7 @@ final protected function loadMFASyncToken( + PhabricatorAuthFactorProvider $provider, AphrontRequest $request, AphrontFormView $form, PhabricatorUser $user) { @@ -397,7 +404,9 @@ ->setTokenCode($sync_key_digest) ->setTokenExpires($now + $sync_ttl); - $properties = $this->newMFASyncTokenProperties($user); + $properties = $this->newMFASyncTokenProperties( + $provider, + $user); foreach ($properties as $key => $value) { $sync_token->setTemporaryTokenProperty($key, $value); @@ -411,7 +420,9 @@ return $sync_token; } - protected function newMFASyncTokenProperties(PhabricatorUser $user) { + protected function newMFASyncTokenProperties( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { return array(); } diff --git a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php @@ -0,0 +1,802 @@ +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); + + $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']; + 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.')); + } + + // 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', + $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(), + ); + foreach ($push_info as $k => $v) { + $push_info[$k] = rawurlencode($k).'='.rawurlencode($v); + } + $push_info = implode('&', $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)); + } + +} diff --git a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php --- a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php @@ -140,7 +140,7 @@ AphrontRequest $request, PhabricatorUser $user) { - $token = $this->loadMFASyncToken($request, $form, $user); + $token = $this->loadMFASyncToken($provider, $request, $form, $user); $code = $request->getStr('sms.code'); $e_code = true; @@ -364,7 +364,10 @@ return head($contact_numbers); } - protected function newMFASyncTokenProperties(PhabricatorUser $user) { + protected function newMFASyncTokenProperties( + PhabricatorAuthFactorProvider $providerr, + PhabricatorUser $user) { + $sms_code = $this->newSMSChallengeCode(); $envelope = new PhutilOpaqueEnvelope($sms_code); diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php --- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php @@ -58,6 +58,7 @@ PhabricatorUser $user) { $sync_token = $this->loadMFASyncToken( + $provider, $request, $form, $user); @@ -440,7 +441,9 @@ return null; } - protected function newMFASyncTokenProperties(PhabricatorUser $user) { + protected function newMFASyncTokenProperties( + PhabricatorAuthFactorProvider $providerr, + PhabricatorUser $user) { return array( 'secret' => self::generateNewTOTPKey(), ); diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php @@ -0,0 +1,65 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_CREDENTIAL; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the credential for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldHandle(), + $this->renderNewHandle()); + } + + public function validateTransactions($object, array $xactions) { + $actor = $this->getActor(); + $errors = array(); + + $old_value = $this->generateOldValue($object); + if ($this->isEmptyTextTransaction($old_value, $xactions)) { + $errors[] = $this->newRequiredError( + pht('Duo providers must have an API credential.')); + } + + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (!strlen($new_value)) { + continue; + } + + if ($new_value === $old_value) { + continue; + } + + $credential = id(new PassphraseCredentialQuery()) + ->setViewer($actor) + ->withIsDestroyed(false) + ->withPHIDs(array($new_value)) + ->executeOne(); + if (!$credential) { + $errors[] = $this->newInvalidError( + pht( + 'Credential ("%s") is not valid.', + $new_value), + $xaction); + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php @@ -0,0 +1,26 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_ENROLL; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the enrollment policy for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php @@ -0,0 +1,59 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_HOSTNAME; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the hostname for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $old_value = $this->generateOldValue($object); + if ($this->isEmptyTextTransaction($old_value, $xactions)) { + $errors[] = $this->newRequiredError( + pht('Duo providers must have an API hostname.')); + } + + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (!strlen($new_value)) { + continue; + } + + if ($new_value === $old_value) { + continue; + } + + try { + PhabricatorDuoAuthFactor::requireDuoAPIHostname($new_value); + } catch (Exception $ex) { + $errors[] = $this->newInvalidError( + $ex->getMessage(), + $xaction); + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php @@ -0,0 +1,26 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_USERNAMES; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the username policy for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + +} diff --git a/src/applications/transactions/editfield/PhabricatorCredentialEditField.php b/src/applications/transactions/editfield/PhabricatorCredentialEditField.php new file mode 100644 --- /dev/null +++ b/src/applications/transactions/editfield/PhabricatorCredentialEditField.php @@ -0,0 +1,43 @@ +credentialType = $credential_type; + return $this; + } + + public function getCredentialType() { + return $this->credentialType; + } + + public function setCredentials(array $credentials) { + $this->credentials = $credentials; + return $this; + } + + public function getCredentials() { + return $this->credentials; + } + + protected function newControl() { + $control = id(new PassphraseCredentialControl()) + ->setCredentialType($this->getCredentialType()) + ->setOptions($this->getCredentials()); + + return $control; + } + + protected function newHTTPParameterType() { + return new AphrontPHIDHTTPParameterType(); + } + + protected function newConduitParameterType() { + return new ConduitPHIDParameterType(); + } + +} diff --git a/src/applications/transactions/editfield/PhabricatorSpaceEditField.php b/src/applications/transactions/editfield/PhabricatorSpaceEditField.php --- a/src/applications/transactions/editfield/PhabricatorSpaceEditField.php +++ b/src/applications/transactions/editfield/PhabricatorSpaceEditField.php @@ -28,7 +28,6 @@ return new ConduitPHIDParameterType(); } - public function shouldReadValueFromRequest() { return $this->getPolicyField()->shouldReadValueFromRequest(); } diff --git a/src/docs/user/userguide/multi_factor_auth.diviner b/src/docs/user/userguide/multi_factor_auth.diviner --- a/src/docs/user/userguide/multi_factor_auth.diviner +++ b/src/docs/user/userguide/multi_factor_auth.diviner @@ -109,6 +109,17 @@ details, see: . +Factor: Duo +=========== + +This factor supports integration with [[ https://duo.com/ | Duo Security ]], a +third-party authentication service popular with enterprises that have a lot of +policies to enforce. + +To use Duo, you'll install the Duo application on your phone. When you try +to take a sensitive action, you'll be asked to confirm it in the application. + + Administration: Configuration =============================