3 final class PhabricatorDuoAuthFactor
4 extends PhabricatorAuthFactor
{
6 const PROP_CREDENTIAL
= 'duo.credentialPHID';
7 const PROP_ENROLL
= 'duo.enroll';
8 const PROP_USERNAMES
= 'duo.usernames';
9 const PROP_HOSTNAME
= 'duo.hostname';
11 public function getFactorKey() {
15 public function getFactorName() {
16 return pht('Duo Security');
19 public function getFactorShortName() {
23 public function getFactorCreateHelp() {
24 return pht('Support for Duo push authentication.');
27 public function getFactorDescription() {
29 'When you need to authenticate, a request will be pushed to the '.
30 'Duo application on your phone.');
33 public function getEnrollDescription(
34 PhabricatorAuthFactorProvider
$provider,
35 PhabricatorUser
$user) {
37 'To add a Duo factor, first download and install the Duo application '.
38 'on your phone. Once you have launched the application and are ready '.
39 'to perform setup, click continue.');
42 public function canCreateNewConfiguration(
43 PhabricatorAuthFactorProvider
$provider,
44 PhabricatorUser
$user) {
46 if ($this->loadConfigurationsForProvider($provider, $user)) {
53 public function getConfigurationCreateDescription(
54 PhabricatorAuthFactorProvider
$provider,
55 PhabricatorUser
$user) {
59 if ($this->loadConfigurationsForProvider($provider, $user)) {
60 $messages[] = id(new PHUIInfoView())
61 ->setSeverity(PHUIInfoView
::SEVERITY_WARNING
)
65 'You already have Duo authentication attached to your account '.
66 'for this provider.'),
73 public function getConfigurationListDetails(
74 PhabricatorAuthFactorConfig
$config,
75 PhabricatorAuthFactorProvider
$provider,
76 PhabricatorUser
$viewer) {
78 $duo_user = $config->getAuthFactorConfigProperty('duo.username');
80 return pht('Duo Username: %s', $duo_user);
84 public function newEditEngineFields(
85 PhabricatorEditEngine
$engine,
86 PhabricatorAuthFactorProvider
$provider) {
88 $viewer = $engine->getViewer();
90 $credential_phid = $provider->getAuthFactorProviderProperty(
91 self
::PROP_CREDENTIAL
);
93 $hostname = $provider->getAuthFactorProviderProperty(self
::PROP_HOSTNAME
);
94 $usernames = $provider->getAuthFactorProviderProperty(self
::PROP_USERNAMES
);
95 $enroll = $provider->getAuthFactorProviderProperty(self
::PROP_ENROLL
);
97 $credential_type = PassphrasePasswordCredentialType
::CREDENTIAL_TYPE
;
98 $provides_type = PassphrasePasswordCredentialType
::PROVIDES_TYPE
;
100 $credentials = id(new PassphraseCredentialQuery())
102 ->withIsDestroyed(false)
103 ->withProvidesTypes(array($provides_type))
107 PhabricatorAuthFactorProviderDuoHostnameTransaction
::TRANSACTIONTYPE
;
108 $xaction_credential =
109 PhabricatorAuthFactorProviderDuoCredentialTransaction
::TRANSACTIONTYPE
;
111 PhabricatorAuthFactorProviderDuoUsernamesTransaction
::TRANSACTIONTYPE
;
113 PhabricatorAuthFactorProviderDuoEnrollTransaction
::TRANSACTIONTYPE
;
116 id(new PhabricatorTextEditField())
117 ->setLabel(pht('Duo API Hostname'))
118 ->setKey('duo.hostname')
119 ->setValue($hostname)
120 ->setTransactionType($xaction_hostname)
121 ->setIsRequired(true),
122 id(new PhabricatorCredentialEditField())
123 ->setLabel(pht('Duo API Credential'))
124 ->setKey('duo.credential')
125 ->setValue($credential_phid)
126 ->setTransactionType($xaction_credential)
127 ->setCredentialType($credential_type)
128 ->setCredentials($credentials),
129 id(new PhabricatorSelectEditField())
130 ->setLabel(pht('Duo Username'))
131 ->setKey('duo.usernames')
132 ->setValue($usernames)
133 ->setTransactionType($xaction_usernames)
138 PlatformSymbols
::getPlatformServerName()),
139 'email' => pht('Use Primary Email Address'),
141 id(new PhabricatorSelectEditField())
142 ->setLabel(pht('Create Accounts'))
143 ->setKey('duo.enroll')
145 ->setTransactionType($xaction_enroll)
148 'deny' => pht('Require Existing Duo Account'),
149 'allow' => pht('Create New Duo Account'),
155 public function processAddFactorForm(
156 PhabricatorAuthFactorProvider
$provider,
157 AphrontFormView
$form,
158 AphrontRequest
$request,
159 PhabricatorUser
$user) {
161 $token = $this->loadMFASyncToken($provider, $request, $form, $user);
162 if ($this->isAuthResult($token)) {
163 $form->appendChild($this->newAutomaticControl($token));
167 $enroll = $token->getTemporaryTokenProperty('duo.enroll');
168 $duo_id = $token->getTemporaryTokenProperty('duo.user-id');
169 $duo_uri = $token->getTemporaryTokenProperty('duo.uri');
170 $duo_user = $token->getTemporaryTokenProperty('duo.username');
172 $is_external = ($enroll === 'external');
173 $is_auto = ($enroll === 'auto');
174 $is_blocked = ($enroll === 'blocked');
176 if (!$token->getIsNewTemporaryToken()) {
178 return $this->newDuoConfig($user, $duo_user);
179 } else if ($is_external ||
$is_blocked) {
181 'username' => $duo_user,
184 $result = $this->newDuoFuture($provider)
185 ->setMethod('preauth', $parameters)
188 $result_code = $result['response']['result'];
189 switch ($result_code) {
192 return $this->newDuoConfig($user, $duo_user);
195 // We'll render an equivalent static control below, so skip
196 // rendering here. We explicitly don't want to give the user
197 // an enroll workflow.
201 $duo_uri = $result['response']['enroll_portal_url'];
203 $waiting_icon = id(new PHUIIconView())
204 ->setIcon('fa-mobile', 'red');
206 $waiting_control = id(new PHUIFormTimerControl())
207 ->setIcon($waiting_icon)
208 ->setError(pht('Not Complete'))
211 'You have not completed Duo enrollment yet. '.
212 'Complete enrollment, then click continue.'));
214 $form->appendControl($waiting_control);
222 'user_id' => $duo_id,
223 'activation_code' => $duo_uri,
226 $future = $this->newDuoFuture($provider)
227 ->setMethod('enroll_status', $parameters);
229 $result = $future->resolve();
230 $response = $result['response'];
234 return $this->newDuoConfig($user, $duo_user);
236 $waiting_icon = id(new PHUIIconView())
237 ->setIcon('fa-mobile', 'red');
239 $waiting_control = id(new PHUIFormTimerControl())
240 ->setIcon($waiting_icon)
241 ->setError(pht('Not Complete'))
244 'You have not activated this enrollment in the Duo '.
245 'application on your phone yet. Complete activation, then '.
248 $form->appendControl($waiting_control);
254 'This Duo enrollment attempt is invalid or has '.
255 'expired ("%s"). Cancel the workflow and try again.',
262 $blocked_icon = id(new PHUIIconView())
263 ->setIcon('fa-times', 'red');
265 $blocked_control = id(new PHUIFormTimerControl())
266 ->setIcon($blocked_icon)
269 'Your Duo account ("%s") has not completed Duo enrollment. '.
270 'Check your email and complete enrollment to continue.',
271 phutil_tag('strong', array(), $duo_user)));
273 $form->appendControl($blocked_control);
274 } else if ($is_auto) {
275 $auto_icon = id(new PHUIIconView())
276 ->setIcon('fa-check', 'green');
278 $auto_control = id(new PHUIFormTimerControl())
279 ->setIcon($auto_icon)
282 'Duo account ("%s") is fully enrolled.',
283 phutil_tag('strong', array(), $duo_user)));
285 $form->appendControl($auto_control);
287 $duo_button = phutil_tag(
291 'class' => 'button button-grey',
292 'target' => ($is_external ?
'_blank' : null),
294 pht('Enroll Duo Account: %s', $duo_user));
296 $duo_button = phutil_tag(
299 'class' => 'mfa-form-enroll-button',
304 $form->appendRemarkupInstructions(
306 'Complete enrolling your phone with Duo:'));
308 $form->appendControl(
309 id(new AphrontFormMarkupControl())
310 ->setValue($duo_button));
313 $form->appendRemarkupInstructions(
315 'Scan this QR code with the Duo application on your mobile '.
319 $qr_code = $this->newQRCode($duo_uri);
320 $form->appendChild($qr_code);
322 $form->appendRemarkupInstructions(
324 'If you are currently using your phone to view this page, '.
325 'click this button to open the Duo application:'));
327 $form->appendControl(
328 id(new AphrontFormMarkupControl())
329 ->setValue($duo_button));
332 $form->appendRemarkupInstructions(
334 'Once you have completed setup on your phone, click continue.'));
339 protected function newMFASyncTokenProperties(
340 PhabricatorAuthFactorProvider
$provider,
341 PhabricatorUser
$user) {
343 $duo_user = $this->getDuoUsername($provider, $user);
345 // Duo automatically normalizes usernames to lowercase. Just do that here
346 // so that our value agrees more closely with Duo.
347 $duo_user = phutil_utf8_strtolower($duo_user);
350 'username' => $duo_user,
353 $result = $this->newDuoFuture($provider)
354 ->setMethod('preauth', $parameters)
357 $external_uri = null;
358 $result_code = $result['response']['result'];
359 $status_message = $result['response']['status_msg'];
360 switch ($result_code) {
363 // If the user already has a Duo account, they don't need to do
366 'duo.enroll' => 'auto',
367 'duo.username' => $duo_user,
370 if (!$this->shouldAllowDuoEnrollment($provider)) {
372 'duo.enroll' => 'blocked',
373 'duo.username' => $duo_user,
377 $external_uri = $result['response']['enroll_portal_url'];
379 // Otherwise, enrollment is permitted so we're going to continue.
383 return $this->newResult()
387 'Your Duo account ("%s") is not permitted to access this '.
388 'system. Contact your Duo administrator for help. '.
389 'The Duo preauth API responded with status message ("%s"): %s',
395 // Duo's "/enroll" API isn't repeatable for the same username. If we're
396 // the first call, great: we can do inline enrollment, which is way more
397 // user friendly. Otherwise, we have to send the user on an adventure.
400 'username' => $duo_user,
401 'valid_secs' => phutil_units('1 hour in seconds'),
405 $result = $this->newDuoFuture($provider)
406 ->setMethod('enroll', $parameters)
408 } catch (HTTPFutureHTTPResponseStatus
$ex) {
410 'duo.enroll' => 'external',
411 'duo.username' => $duo_user,
412 'duo.uri' => $external_uri,
417 'duo.enroll' => 'inline',
418 'duo.uri' => $result['response']['activation_code'],
419 'duo.username' => $duo_user,
420 'duo.user-id' => $result['response']['user_id'],
424 protected function newIssuedChallenges(
425 PhabricatorAuthFactorConfig
$config,
426 PhabricatorUser
$viewer,
429 // If we already issued a valid challenge for this workflow and session,
430 // don't issue a new one.
432 $challenge = $this->getChallengeForCurrentContext(
440 if (!$this->hasCSRF($config)) {
441 return $this->newResult()
442 ->setIsContinue(true)
445 'An authorization request will be pushed to the Duo '.
446 'application on your phone.'));
449 $provider = $config->getFactorProvider();
451 // Otherwise, issue a new challenge.
452 $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username');
455 'username' => $duo_user,
458 $response = $this->newDuoFuture($provider)
459 ->setMethod('preauth', $parameters)
461 $response = $response['response'];
463 $next_step = $response['result'];
464 $status_message = $response['status_msg'];
465 switch ($next_step) {
470 // Duo is telling us to bypass MFA. For now, refuse.
471 return $this->newResult()
475 'Duo is not requiring a challenge, which defeats the '.
476 'purpose of MFA. Duo must be configured to challenge you.'));
478 return $this->newResult()
482 'Your Duo account ("%s") requires enrollment. Contact your '.
483 'Duo administrator for help. Duo status message: %s',
488 return $this->newResult()
492 'Your Duo account ("%s") is not permitted to access this '.
493 'system. Contact your Duo administrator for help. The Duo '.
494 'preauth API responded with status message ("%s"): %s',
501 $devices = $response['devices'];
502 foreach ($devices as $device) {
503 $capabilities = array_fuse($device['capabilities']);
504 if (isset($capabilities['push'])) {
511 return $this->newResult()
515 'This factor has been removed from your device, so this server '.
516 'can not send you a challenge. To continue, an administrator '.
517 'must strip this factor from your account.'));
521 pht('Domain') => $this->getInstallDisplayName(),
523 $push_info = phutil_build_http_querystring($push_info);
526 'username' => $duo_user,
530 // Duo allows us to specify a device, or to pass "auto" to have it pick
531 // the first one. For now, just let it pick.
534 // This is a hard-coded prefix for the word "... request" in the Duo UI,
535 // which defaults to "Login". We could pass richer information from
536 // workflows here, but it's not very flexible anyway.
537 'type' => 'Authentication',
539 'display_username' => $viewer->getUsername(),
540 'pushinfo' => $push_info,
543 $result = $this->newDuoFuture($provider)
544 ->setMethod('auth', $parameters)
547 $duo_xaction = $result['response']['txid'];
549 // The Duo push timeout is 60 seconds. Set our challenge to expire slightly
550 // more quickly so that we'll re-issue a new challenge before Duo times out.
551 // This should keep users away from a dead-end where they can't respond to
552 // Duo but we won't issue a new challenge yet.
556 $this->newChallenge($config, $viewer)
557 ->setChallengeKey($duo_xaction)
558 ->setChallengeTTL(PhabricatorTime
::getNow() +
$ttl_seconds),
562 protected function newResultFromIssuedChallenges(
563 PhabricatorAuthFactorConfig
$config,
564 PhabricatorUser
$viewer,
567 $challenge = $this->getChallengeForCurrentContext(
572 if ($challenge->getIsAnsweredChallenge()) {
573 return $this->newResult()
574 ->setAnsweredChallenge($challenge);
577 $provider = $config->getFactorProvider();
578 $duo_xaction = $challenge->getChallengeKey();
581 'txid' => $duo_xaction,
584 // This endpoint always long-polls, so use a timeout to force it to act
585 // more asynchronously.
587 $result = $this->newDuoFuture($provider)
588 ->setHTTPMethod('GET')
589 ->setMethod('auth_status', $parameters)
593 $state = $result['response']['result'];
594 $status = $result['response']['status'];
595 } catch (HTTPFutureCURLResponseStatus
$exception) {
596 if ($exception->isTimeout()) {
604 $now = PhabricatorTime
::getNow();
608 $ttl = PhabricatorTime
::getNow()
609 +
phutil_units('15 minutes in seconds');
612 ->markChallengeAsAnswered($ttl);
614 return $this->newResult()
615 ->setAnsweredChallenge($challenge);
617 // If we didn't just issue this challenge, give the user a stronger
618 // hint that they need to follow the instructions.
619 if (!$challenge->getIsNewChallenge()) {
620 return $this->newResult()
621 ->setIsContinue(true)
623 id(new PHUIIconView())
624 ->setIcon('fa-exclamation-triangle', 'yellow'))
627 'You must approve the challenge which was sent to your '.
628 'phone. Open the Duo application and confirm the challenge, '.
632 // Otherwise, we'll construct a default message later on.
636 if ($status === 'timeout') {
637 return $this->newResult()
641 'This request has timed out because you took too long to '.
644 $wait_duration = ($challenge->getChallengeTTL() - $now) +
1;
646 return $this->newResult()
650 'You denied this request. Wait %s second(s) to try again.',
651 new PhutilNumber($wait_duration)));
659 public function renderValidateFactorForm(
660 PhabricatorAuthFactorConfig
$config,
661 AphrontFormView
$form,
662 PhabricatorUser
$viewer,
663 PhabricatorAuthFactorResult
$result) {
665 $control = $this->newAutomaticControl($result);
668 ->setLabel(pht('Duo'))
669 ->setCaption(pht('Factor Name: %s', $config->getFactorName()));
671 $form->appendChild($control);
674 public function getRequestHasChallengeResponse(
675 PhabricatorAuthFactorConfig
$config,
676 AphrontRequest
$request) {
680 protected function newResultFromChallengeResponse(
681 PhabricatorAuthFactorConfig
$config,
682 PhabricatorUser
$viewer,
683 AphrontRequest
$request,
686 return $this->getResultForPrompt(
693 protected function newResultForPrompt(
694 PhabricatorAuthFactorConfig
$config,
695 PhabricatorUser
$viewer,
696 AphrontRequest
$request,
699 $result = $this->newResult()
700 ->setIsContinue(true)
703 'A challenge has been sent to your phone. Open the Duo '.
704 'application and confirm the challenge, then continue.'));
706 $challenge = $this->getChallengeForCurrentContext(
712 ->setStatusChallenge($challenge)
714 id(new PHUIIconView())
715 ->setIcon('fa-refresh', 'green ph-spin'));
721 private function newDuoFuture(PhabricatorAuthFactorProvider
$provider) {
722 $credential_phid = $provider->getAuthFactorProviderProperty(
723 self
::PROP_CREDENTIAL
);
725 $omnipotent = PhabricatorUser
::getOmnipotentUser();
727 $credential = id(new PassphraseCredentialQuery())
728 ->setViewer($omnipotent)
729 ->withPHIDs(array($credential_phid))
735 'Unable to load Duo API credential ("%s").',
739 $duo_key = $credential->getUsername();
740 $duo_secret = $credential->getSecret();
744 'Duo API credential ("%s") has no secret key.',
748 $duo_host = $provider->getAuthFactorProviderProperty(
749 self
::PROP_HOSTNAME
);
750 self
::requireDuoAPIHostname($duo_host);
752 return id(new PhabricatorDuoFuture())
753 ->setIntegrationKey($duo_key)
754 ->setSecretKey($duo_secret)
755 ->setAPIHostname($duo_host)
757 ->setHTTPMethod('POST');
760 private function getDuoUsername(
761 PhabricatorAuthFactorProvider
$provider,
762 PhabricatorUser
$user) {
764 $mode = $provider->getAuthFactorProviderProperty(self
::PROP_USERNAMES
);
767 return $user->getUsername();
769 return $user->loadPrimaryEmailAddress();
773 'Duo username pairing mode ("%s") is not supported.',
778 private function shouldAllowDuoEnrollment(
779 PhabricatorAuthFactorProvider
$provider) {
781 $mode = $provider->getAuthFactorProviderProperty(self
::PROP_ENROLL
);
790 'Duo enrollment mode ("%s") is not supported.',
795 private function newDuoConfig(PhabricatorUser
$user, $duo_user) {
796 $config_properties = array(
797 'duo.username' => $duo_user,
800 $config = $this->newConfigForUser($user)
801 ->setFactorName(pht('Duo (%s)', $duo_user))
802 ->setProperties($config_properties);
807 public static function requireDuoAPIHostname($hostname) {
808 if (preg_match('/\.duosecurity\.com\z/', $hostname)) {
814 'Duo API hostname ("%s") is invalid, hostname must be '.
815 '"*.duosecurity.com".',
819 public function newChallengeStatusView(
820 PhabricatorAuthFactorConfig
$config,
821 PhabricatorAuthFactorProvider
$provider,
822 PhabricatorUser
$viewer,
823 PhabricatorAuthChallenge
$challenge) {
825 $duo_xaction = $challenge->getChallengeKey();
828 'txid' => $duo_xaction,
831 $default_result = id(new PhabricatorAuthChallengeUpdate())
835 $result = $this->newDuoFuture($provider)
836 ->setHTTPMethod('GET')
837 ->setMethod('auth_status', $parameters)
841 $state = $result['response']['result'];
842 } catch (HTTPFutureCURLResponseStatus
$exception) {
843 // If we failed or timed out, retry. Usually, this is a timeout.
844 return id(new PhabricatorAuthChallengeUpdate())
848 // For now, don't update the view for anything but an "Allow". Updates
849 // here are just about providing more visual feedback for user convenience.
850 if ($state !== 'allow') {
851 return id(new PhabricatorAuthChallengeUpdate())
855 $icon = id(new PHUIIconView())
856 ->setIcon('fa-check-circle-o', 'green');
858 $view = id(new PHUIFormTimerControl())
860 ->appendChild(pht('You responded to this challenge correctly.'))
863 return id(new PhabricatorAuthChallengeUpdate())