Changeset View
Standalone View
src/applications/auth/factor/PhabricatorDuoAuthFactor.php
- This file was added.
<?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.'); | |||||
} | |||||
amckinley: "Supports for" | |||||
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( | |||||
Not Done Inline ActionsI know QR codes are designed to survive translation and rotation, but can they actually survive reflection? Either way, this is still funny, but should maybe be behind a serious-business check, since I predict a link between Duo environments and an expectation of Serious Business. amckinley: I know QR codes are designed to survive translation and rotation, but can they actually survive… | |||||
Done Inline ActionsTurns out they do! I took a TOTP QR screenshot, flipped it horizontally in Photoshop, and Google Authenticator scanned it in fine. epriestley: Turns out they do! I took a TOTP QR screenshot, flipped it horizontally in Photoshop, and… | |||||
Not Done Inline Actionsamckinley: howneatisthat | |||||
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; | |||||
Not Done Inline ActionsShouldn't we wrap this in a try block? I guess just throwing a standard yellow dialogue box exception is ok, but since this is a 3rd party service dependency, it would be nice to make it look less like a generic internal Phabricator error. amckinley: Shouldn't we wrap this in a `try` block? I guess just throwing a standard yellow dialogue box… | |||||
$result_code = $result['response']['result']; | |||||
switch ($result_code) { | |||||
case 'auth': | |||||
Not Done Inline ActionsI know resolve() will throw on a non-2xx response, but does a 2xx response guarantee that response and result will be populated? Or does the Duo API do the goofy thing where a 2xx response means "the API handled your request, but you need to look for $result['response']['error'] before you can say if there were any application-level issues"? amckinley: I know `resolve()` will throw on a non-2xx response, but does a 2xx response guarantee that… | |||||
Done Inline ActionsDuo does "non-200 for error, 200 for okay". epriestley: Duo does "non-200 for error, 200 for okay". | |||||
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( | |||||
Not Done Inline ActionsI'm not sure what our style guide says about case statements that return without a following break, but every branch here results in a return so I don't really care. amckinley: I'm not sure what our style guide says about `case` statements that `return` without a… | |||||
Done Inline ActionsSee D1824. It's okay for a block to end with return, break, throw, exit(), continue, have an explicit fallthrough comment, or be empty. Since D19931, continue must be continue 2 or better and occur inside a loop which it can target. epriestley: See D1824. It's okay for a block to end with `return`, `break`, `throw`, `exit()`, `continue`… | |||||
'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() | |||||
Not Done Inline ActionsThis is pretty wacky. What was the install's intent if they have Duo configured as an MFA provider but then disable the challenge behavior? I'm picturing something we probably wouldn't want to support/encourage like "because Reasons™, an install configured auth.mfa-required, but the CISO can't be bothered to jump through silly security hoops and demands that he, and only he, can skip MFA", so they configure Duo to skip challenge requirements only for that user. amckinley: This is pretty wacky. What was the install's intent if they have Duo configured as an MFA… | |||||
Done Inline ActionsI'm hoping it's usually "someone made a mistake with configuration", but worry your scenario may also happen. If that's the case, I'd at least want to carry this flag out of the MFA process so that applications can distinguish between "user did actual MFA" and "user did silly pointless MFA". For now, I'm hoping that this is a mistake and the remedy is to fix Duo configuration. epriestley: I'm hoping it's usually "someone made a mistake with configuration", but worry your scenario… | |||||
->setIsError(true) | |||||
->setErrorMessage( | |||||
pht( | |||||
'Your Duo account ("%s") requires enrollment. Contact your '. | |||||
'Duo administrator for help. Duo status message: %s', | |||||
$duo_user, | |||||
$status_message)); | |||||
Not Done Inline ActionsAgreed that this isn't really our problem, but maybe we should provide some guidance, like "contact your Duo administrator and show them this message:" amckinley: Agreed that this isn't really our problem, but maybe we should provide //some// guidance, like… | |||||
Done Inline ActionsI don't actually know how accounts could possibly get into this state so I'm not sure who you should contact. Possibly us to tell us how you did it? epriestley: I don't actually know how accounts could possibly get into this state so I'm not sure who you… | |||||
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; | |||||
} | |||||
} | |||||
Not Done Inline ActionsWhy not something like: $capabilities = array_fuse(ipull($devices, 'capabilities')); if (isset($capabilities['push'])) { $has_push = true' } This is more of a "curious why you implemented it like this" question as opposed to a "why didn't you implement it with my clearly superior way" question. amckinley: Why not something like:
```lang=php
$capabilities = array_fuse(ipull($devices… | |||||
Done Inline ActionsIn a pure technical sense, ipull($devices, 'capabilities') would give us this: array( 0 => array('push', 'sms'), 1 => array('push', 'voice'), ) Since the values aren't scalars, array_fuse() would fatal. But something like $capabilities = array_fuse(array_mergev(ipull($devices, 'capabilities'))); would work. One reason I used a loop is that I'm imagining we may eventually need to extend this into "Choose which device we should push to", or "Here's the phone number of the device we're pushing to". Features like that are likely easier with the loop, so we can figure out which devices we actually matched with. Another is just a side effect of it being easier to write the code incrementally, e.g. I think I wrote something like this first: foreach ($devices as $device) { var_dump($device); } ...then looked at the values to make sure they were what I expected and added the next part, and so on until the logic worked. epriestley: In a pure technical sense, `ipull($devices, 'capabilities')` would give us this:
```
array… | |||||
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 '. | |||||
Not Done Inline ActionsThis is actually more like "The Duo factor that is associated with your Phabricator account is no longer recognized by Duo. To continue...", right? amckinley: This is actually more like "The Duo factor that is associated with your Phabricator account is… | |||||
Done Inline ActionsThe way I trigger this locally is by going into Duo on my phone and deleting the entry for Phabricator. (In the general case, this is deleting my entry for "The Company I Work At"). Duo still recognizes the account, but you've unpaired the account with all your phones so you can't prove you own the account. Put another way, you've sort of intentionally lost your own phone. I think Duo might let us enroll you again in this state but I'm not entirely sure, and it's potentially quite a mess to do an enroll from within a challenge. Two remedies which likely work:
While you're in this state, you're locked out of every Duo tool in your org, so I'm guessing this will almost always be remedy (2) and users will hit some other application before they end up here. epriestley: The way I trigger this locally is by going into Duo on my phone and deleting the entry for… | |||||
'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); | |||||
Not Done Inline ActionsThis is really the state-of-the-art for urlencoding? I figured we'd be doing this stuff all day/every day. Or does the libphutil URL stuff expect that we're passing in a full, well-formed URL instead of this little snippet that's getting wrapped again inside another URL parameter later? amckinley: This is really the state-of-the-art for urlencoding? I figured we'd be doing this stuff all… | |||||
Done Inline ActionsWe can do this instead: $uri = id(new PhutilURI('/')) ->setQueryParams($push_info); $uri = ltrim((string)$uri, '/?'); ...and we could add a getQueryString() to PhutilURI to avoid the ltrim(). There's also http_build_query(), which is what PhutilURI uses internally, but it requires weird arguments to guarantee sensible behavior. We don't have a phutil_build_query_with_sane_behavior($exactly_one_argument); so mostly the latter: fairly good tools for actually working with URIs, less-clean tools for double-encoding URI pieces inside another URI. I could clean this up -- we have similar code in the Duo future for signing. epriestley: We can do this instead:
```
$uri = id(new PhutilURI('/'))
->setQueryParams($push_info);
$uri… | |||||
$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', | |||||
Not Done Inline ActionsMaybe just distinguish between "Login" and "High-security Action"? Not sure if that information is carried sufficiently down the stack to expose it here, but it's not a big deal either way. amckinley: Maybe just distinguish between "Login" and "High-security Action"? Not sure if that information… | |||||
Done Inline ActionsRight now, we can't (reasonably) tell which is which at this layer. I would sort of like to pass human-readable workflow names down to this layer so we can say Login, Edit T123, etc -- Duo isn't super flexible, but we can put ~anything in an SMS at least. But this feels like a pretty fluff feature. epriestley: Right now, we can't (reasonably) tell which is which at this layer.
I would sort of like to… | |||||
Not Done Inline ActionsThe only sense in which it's not fluffy is that it would maybe help defeat a lot of goofy hypothetical social engineering attacks like this:
Anyway, out of scope for this revision, but maybe worth a followup note (especially because it could share a lot of code with SMS). amckinley: The only sense in which it's not fluffy is that it would maybe help defeat a lot of goofy… | |||||
'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(); | |||||
Not Done Inline ActionsI doubt this is ever going to be a problem in practice, but have you tried running this with a really short timeout so we hammer the endpoint a bunch of times? Since we're supposed to long-poll, I'm slightly worried that Duo would rate limit us. amckinley: I doubt this is ever going to be a problem in practice, but have you tried running this with a… | |||||
$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) { | |||||
Not Done Inline ActionsThis is pretty cool; are we considering switching the other "sensitive" config keys like AWS credentials to use Passphrase? amckinley: This is pretty cool; are we considering switching the other "sensitive" config keys like AWS… | |||||
Done Inline ActionsAnything that's UI-configurable should already be in Passphrase, I think (Diffusion, Drydock, Harbormaster). Stuff like AWS isn't UI-configurable today. The major problem with making stuff like AWS UI-configurable is that then an attacker can UI-reconfigure it to use their account and intercept all your email/storage/etc. I think there's also at least some value in being able to check cluster.mailers and such into version control. Having a bunch of secret keys in a JSON file doesn't feel great, but it's sort of like the least-bad alternative in some senses (attacker resistance, compatibility with ops flows that want stuff to be in VCS)? We can possibly solve "attacker resistance" with bin/auth lock-style stuff ("unlock, edit via web UI, lock") but I kind of think that no one will ever actually run a bin/<something> lock command willingly so we'll end up with poor attacker resistance. I suppose we could make bin/auth unlock temporary and automatically re-lock it after, say, 8 hours. But at this point we're kind of fighting administrators and still don't have a good answer for "I want to check the config into Git so I don't blow it away with git clean --force --really", which we've now both done, albeit not in production. 🐙 epriestley: Anything that's UI-configurable should already be in Passphrase, I think (Diffusion, Drydock… | |||||
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)); | |||||
} | |||||
} |
"Supports for"