Page MenuHomePhabricator

D20039.id.diff
No OneTemporary

D20039.id.diff

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 @@
+<?php
+
+final class PhabricatorDuoAuthFactor
+ extends PhabricatorAuthFactor {
+
+ const PROP_CREDENTIAL = 'duo.credentialPHID';
+ const PROP_ENROLL = 'duo.enroll';
+ const PROP_USERNAMES = 'duo.usernames';
+ const PROP_HOSTNAME = 'duo.hostname';
+
+ public function getFactorKey() {
+ return 'duo';
+ }
+
+ public function getFactorName() {
+ return pht('Duo Security');
+ }
+
+ public function getFactorShortName() {
+ return pht('Duo');
+ }
+
+ public function getFactorCreateHelp() {
+ return pht('Support for Duo push authentication.');
+ }
+
+ public function getFactorDescription() {
+ return pht(
+ 'When you need to authenticate, a request will be pushed to the '.
+ 'Duo application on your phone.');
+ }
+
+ public function getEnrollDescription(
+ PhabricatorAuthFactorProvider $provider,
+ PhabricatorUser $user) {
+ return pht(
+ 'To add a Duo factor, first download and install the Duo application '.
+ 'on your phone. Once you have launched the application and are ready '.
+ 'to perform setup, click continue.');
+ }
+
+ public function canCreateNewConfiguration(
+ PhabricatorAuthFactorProvider $provider,
+ PhabricatorUser $user) {
+
+ if ($this->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 @@
+<?php
+
+final class PhabricatorAuthFactorProviderDuoCredentialTransaction
+ extends PhabricatorAuthFactorProviderTransactionType {
+
+ const TRANSACTIONTYPE = 'duo.credential';
+
+ public function generateOldValue($object) {
+ $key = PhabricatorDuoAuthFactor::PROP_CREDENTIAL;
+ return $object->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 @@
+<?php
+
+final class PhabricatorAuthFactorProviderDuoEnrollTransaction
+ extends PhabricatorAuthFactorProviderTransactionType {
+
+ const TRANSACTIONTYPE = 'duo.enroll';
+
+ public function generateOldValue($object) {
+ $key = PhabricatorDuoAuthFactor::PROP_ENROLL;
+ return $object->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 @@
+<?php
+
+final class PhabricatorAuthFactorProviderDuoHostnameTransaction
+ extends PhabricatorAuthFactorProviderTransactionType {
+
+ const TRANSACTIONTYPE = 'duo.hostname';
+
+ public function generateOldValue($object) {
+ $key = PhabricatorDuoAuthFactor::PROP_HOSTNAME;
+ return $object->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 @@
+<?php
+
+final class PhabricatorAuthFactorProviderDuoUsernamesTransaction
+ extends PhabricatorAuthFactorProviderTransactionType {
+
+ const TRANSACTIONTYPE = 'duo.usernames';
+
+ public function generateOldValue($object) {
+ $key = PhabricatorDuoAuthFactor::PROP_USERNAMES;
+ return $object->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 @@
+<?php
+
+final class PhabricatorCredentialEditField
+ extends PhabricatorEditField {
+
+ private $credentialType;
+ private $credentials;
+
+ public function setCredentialType($credential_type) {
+ $this->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: <https://phurl.io/u/sms>.
+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
=============================

File Metadata

Mime Type
text/plain
Expires
Sun, May 12, 11:31 AM (1 w, 4 d ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/dd/5j/biwwc2iqlakeyxfc
Default Alt Text
D20039.id.diff (41 KB)

Event Timeline