1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-30 02:32:42 +01:00

Bring Duo MFA upstream

Summary: Depends on D20038. Ref T13231. Although I planned to keep this out of the upstream (see T13229) it ended up having enough pieces that I imagine it may need more fixes/updates than we can reasonably manage by copy/pasting stuff around. Until T5055, we don't really have good tools for managing this. Make my life easier by just upstreaming this.

Test Plan: See T13231 for a bunch of workflow discussion.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13231

Differential Revision: https://secure.phabricator.com/D20039
This commit is contained in:
epriestley 2019-01-25 11:21:03 -08:00
parent d8d4efe89e
commit 9fd8343704
13 changed files with 1076 additions and 8 deletions

View file

@ -2228,6 +2228,10 @@ phutil_register_library_map(array(
'PhabricatorAuthFactorConfigQuery' => 'applications/auth/query/PhabricatorAuthFactorConfigQuery.php', 'PhabricatorAuthFactorConfigQuery' => 'applications/auth/query/PhabricatorAuthFactorConfigQuery.php',
'PhabricatorAuthFactorProvider' => 'applications/auth/storage/PhabricatorAuthFactorProvider.php', 'PhabricatorAuthFactorProvider' => 'applications/auth/storage/PhabricatorAuthFactorProvider.php',
'PhabricatorAuthFactorProviderController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderController.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', 'PhabricatorAuthFactorProviderEditController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php',
'PhabricatorAuthFactorProviderEditEngine' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php', 'PhabricatorAuthFactorProviderEditEngine' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php',
'PhabricatorAuthFactorProviderEditor' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditor.php', 'PhabricatorAuthFactorProviderEditor' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditor.php',
@ -2800,6 +2804,7 @@ phutil_register_library_map(array(
'PhabricatorCountdownTransactionType' => 'applications/countdown/xaction/PhabricatorCountdownTransactionType.php', 'PhabricatorCountdownTransactionType' => 'applications/countdown/xaction/PhabricatorCountdownTransactionType.php',
'PhabricatorCountdownView' => 'applications/countdown/view/PhabricatorCountdownView.php', 'PhabricatorCountdownView' => 'applications/countdown/view/PhabricatorCountdownView.php',
'PhabricatorCountdownViewController' => 'applications/countdown/controller/PhabricatorCountdownViewController.php', 'PhabricatorCountdownViewController' => 'applications/countdown/controller/PhabricatorCountdownViewController.php',
'PhabricatorCredentialEditField' => 'applications/transactions/editfield/PhabricatorCredentialEditField.php',
'PhabricatorCursorPagedPolicyAwareQuery' => 'infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php', 'PhabricatorCursorPagedPolicyAwareQuery' => 'infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php',
'PhabricatorCustomField' => 'infrastructure/customfield/field/PhabricatorCustomField.php', 'PhabricatorCustomField' => 'infrastructure/customfield/field/PhabricatorCustomField.php',
'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource.php', 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource.php',
@ -2986,6 +2991,7 @@ phutil_register_library_map(array(
'PhabricatorDraftEngine' => 'applications/transactions/draft/PhabricatorDraftEngine.php', 'PhabricatorDraftEngine' => 'applications/transactions/draft/PhabricatorDraftEngine.php',
'PhabricatorDraftInterface' => 'applications/transactions/draft/PhabricatorDraftInterface.php', 'PhabricatorDraftInterface' => 'applications/transactions/draft/PhabricatorDraftInterface.php',
'PhabricatorDrydockApplication' => 'applications/drydock/application/PhabricatorDrydockApplication.php', 'PhabricatorDrydockApplication' => 'applications/drydock/application/PhabricatorDrydockApplication.php',
'PhabricatorDuoAuthFactor' => 'applications/auth/factor/PhabricatorDuoAuthFactor.php',
'PhabricatorDuoFuture' => 'applications/auth/future/PhabricatorDuoFuture.php', 'PhabricatorDuoFuture' => 'applications/auth/future/PhabricatorDuoFuture.php',
'PhabricatorEdgeChangeRecord' => 'infrastructure/edges/util/PhabricatorEdgeChangeRecord.php', 'PhabricatorEdgeChangeRecord' => 'infrastructure/edges/util/PhabricatorEdgeChangeRecord.php',
'PhabricatorEdgeChangeRecordTestCase' => 'infrastructure/edges/__tests__/PhabricatorEdgeChangeRecordTestCase.php', 'PhabricatorEdgeChangeRecordTestCase' => 'infrastructure/edges/__tests__/PhabricatorEdgeChangeRecordTestCase.php',
@ -7958,6 +7964,10 @@ phutil_register_library_map(array(
'PhabricatorEditEngineMFAInterface', 'PhabricatorEditEngineMFAInterface',
), ),
'PhabricatorAuthFactorProviderController' => 'PhabricatorAuthProviderController', 'PhabricatorAuthFactorProviderController' => 'PhabricatorAuthProviderController',
'PhabricatorAuthFactorProviderDuoCredentialTransaction' => 'PhabricatorAuthFactorProviderTransactionType',
'PhabricatorAuthFactorProviderDuoEnrollTransaction' => 'PhabricatorAuthFactorProviderTransactionType',
'PhabricatorAuthFactorProviderDuoHostnameTransaction' => 'PhabricatorAuthFactorProviderTransactionType',
'PhabricatorAuthFactorProviderDuoUsernamesTransaction' => 'PhabricatorAuthFactorProviderTransactionType',
'PhabricatorAuthFactorProviderEditController' => 'PhabricatorAuthFactorProviderController', 'PhabricatorAuthFactorProviderEditController' => 'PhabricatorAuthFactorProviderController',
'PhabricatorAuthFactorProviderEditEngine' => 'PhabricatorEditEngine', 'PhabricatorAuthFactorProviderEditEngine' => 'PhabricatorEditEngine',
'PhabricatorAuthFactorProviderEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorAuthFactorProviderEditor' => 'PhabricatorApplicationTransactionEditor',
@ -8633,6 +8643,7 @@ phutil_register_library_map(array(
'PhabricatorCountdownTransactionType' => 'PhabricatorModularTransactionType', 'PhabricatorCountdownTransactionType' => 'PhabricatorModularTransactionType',
'PhabricatorCountdownView' => 'AphrontView', 'PhabricatorCountdownView' => 'AphrontView',
'PhabricatorCountdownViewController' => 'PhabricatorCountdownController', 'PhabricatorCountdownViewController' => 'PhabricatorCountdownController',
'PhabricatorCredentialEditField' => 'PhabricatorEditField',
'PhabricatorCursorPagedPolicyAwareQuery' => 'PhabricatorPolicyAwareQuery', 'PhabricatorCursorPagedPolicyAwareQuery' => 'PhabricatorPolicyAwareQuery',
'PhabricatorCustomField' => 'Phobject', 'PhabricatorCustomField' => 'Phobject',
'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'PhabricatorTypeaheadDatasource', 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'PhabricatorTypeaheadDatasource',
@ -8837,6 +8848,7 @@ phutil_register_library_map(array(
'PhabricatorDraftDAO' => 'PhabricatorLiskDAO', 'PhabricatorDraftDAO' => 'PhabricatorLiskDAO',
'PhabricatorDraftEngine' => 'Phobject', 'PhabricatorDraftEngine' => 'Phobject',
'PhabricatorDrydockApplication' => 'PhabricatorApplication', 'PhabricatorDrydockApplication' => 'PhabricatorApplication',
'PhabricatorDuoAuthFactor' => 'PhabricatorAuthFactor',
'PhabricatorDuoFuture' => 'FutureProxy', 'PhabricatorDuoFuture' => 'FutureProxy',
'PhabricatorEdgeChangeRecord' => 'Phobject', 'PhabricatorEdgeChangeRecord' => 'Phobject',
'PhabricatorEdgeChangeRecordTestCase' => 'PhabricatorTestCase', 'PhabricatorEdgeChangeRecordTestCase' => 'PhabricatorTestCase',

View file

@ -93,11 +93,12 @@ final class PhabricatorAuthFactorProviderEditEngine
} }
protected function buildCustomEditFields($object) { protected function buildCustomEditFields($object) {
$factor_name = $object->getFactor()->getFactorName(); $factor = $object->getFactor();
$factor_name = $factor->getFactorName();
$status_map = PhabricatorAuthFactorProviderStatus::getMap(); $status_map = PhabricatorAuthFactorProviderStatus::getMap();
return array( $fields = array(
id(new PhabricatorStaticEditField()) id(new PhabricatorStaticEditField())
->setKey('displayType') ->setKey('displayType')
->setLabel(pht('Factor Type')) ->setLabel(pht('Factor Type'))
@ -120,6 +121,13 @@ final class PhabricatorAuthFactorProviderEditEngine
->setValue($object->getStatus()) ->setValue($object->getStatus())
->setOptions($status_map), ->setOptions($status_map),
); );
$factor_fields = $factor->newEditEngineFields($this, $object);
foreach ($factor_fields as $field) {
$fields[] = $field;
}
return $fields;
} }
} }

View file

@ -74,6 +74,12 @@ abstract class PhabricatorAuthFactor extends Phobject {
return null; return null;
} }
public function newEditEngineFields(
PhabricatorEditEngine $engine,
PhabricatorAuthFactorProvider $provider) {
return array();
}
/** /**
* Is this a factor which depends on the user's contact number? * Is this a factor which depends on the user's contact number?
* *
@ -331,6 +337,7 @@ abstract class PhabricatorAuthFactor extends Phobject {
final protected function loadMFASyncToken( final protected function loadMFASyncToken(
PhabricatorAuthFactorProvider $provider,
AphrontRequest $request, AphrontRequest $request,
AphrontFormView $form, AphrontFormView $form,
PhabricatorUser $user) { PhabricatorUser $user) {
@ -397,7 +404,9 @@ abstract class PhabricatorAuthFactor extends Phobject {
->setTokenCode($sync_key_digest) ->setTokenCode($sync_key_digest)
->setTokenExpires($now + $sync_ttl); ->setTokenExpires($now + $sync_ttl);
$properties = $this->newMFASyncTokenProperties($user); $properties = $this->newMFASyncTokenProperties(
$provider,
$user);
foreach ($properties as $key => $value) { foreach ($properties as $key => $value) {
$sync_token->setTemporaryTokenProperty($key, $value); $sync_token->setTemporaryTokenProperty($key, $value);
@ -411,7 +420,9 @@ abstract class PhabricatorAuthFactor extends Phobject {
return $sync_token; return $sync_token;
} }
protected function newMFASyncTokenProperties(PhabricatorUser $user) { protected function newMFASyncTokenProperties(
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $user) {
return array(); return array();
} }

View file

@ -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));
}
}

View file

@ -140,7 +140,7 @@ final class PhabricatorSMSAuthFactor
AphrontRequest $request, AphrontRequest $request,
PhabricatorUser $user) { PhabricatorUser $user) {
$token = $this->loadMFASyncToken($request, $form, $user); $token = $this->loadMFASyncToken($provider, $request, $form, $user);
$code = $request->getStr('sms.code'); $code = $request->getStr('sms.code');
$e_code = true; $e_code = true;
@ -364,7 +364,10 @@ final class PhabricatorSMSAuthFactor
return head($contact_numbers); return head($contact_numbers);
} }
protected function newMFASyncTokenProperties(PhabricatorUser $user) { protected function newMFASyncTokenProperties(
PhabricatorAuthFactorProvider $providerr,
PhabricatorUser $user) {
$sms_code = $this->newSMSChallengeCode(); $sms_code = $this->newSMSChallengeCode();
$envelope = new PhutilOpaqueEnvelope($sms_code); $envelope = new PhutilOpaqueEnvelope($sms_code);

View file

@ -58,6 +58,7 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
PhabricatorUser $user) { PhabricatorUser $user) {
$sync_token = $this->loadMFASyncToken( $sync_token = $this->loadMFASyncToken(
$provider,
$request, $request,
$form, $form,
$user); $user);
@ -440,7 +441,9 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
return null; return null;
} }
protected function newMFASyncTokenProperties(PhabricatorUser $user) { protected function newMFASyncTokenProperties(
PhabricatorAuthFactorProvider $providerr,
PhabricatorUser $user) {
return array( return array(
'secret' => self::generateNewTOTPKey(), 'secret' => self::generateNewTOTPKey(),
); );

View file

@ -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;
}
}

View file

@ -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());
}
}

View file

@ -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;
}
}

View file

@ -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());
}
}

View file

@ -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();
}
}

View file

@ -28,7 +28,6 @@ final class PhabricatorSpaceEditField
return new ConduitPHIDParameterType(); return new ConduitPHIDParameterType();
} }
public function shouldReadValueFromRequest() { public function shouldReadValueFromRequest() {
return $this->getPolicyField()->shouldReadValueFromRequest(); return $this->getPolicyField()->shouldReadValueFromRequest();
} }

View file

@ -109,6 +109,17 @@ an authorization code to enter into the prompt.
details, see: <https://phurl.io/u/sms>. 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 Administration: Configuration
============================= =============================