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)
136 'username' => pht('Use Phabricator Username'),
137 'email' => pht('Use Primary Email Address'),
139 id(new PhabricatorSelectEditField())
140 ->setLabel(pht('Create Accounts'))
141 ->setKey('duo.enroll')
143 ->setTransactionType($xaction_enroll)
146 'deny' => pht('Require Existing Duo Account'),
147 'allow' => pht('Create New Duo Account'),
153 public function processAddFactorForm(
154 PhabricatorAuthFactorProvider
$provider,
155 AphrontFormView
$form,
156 AphrontRequest
$request,
157 PhabricatorUser
$user) {
159 $token = $this->loadMFASyncToken($provider, $request, $form, $user);
160 if ($this->isAuthResult($token)) {
161 $form->appendChild($this->newAutomaticControl($token));
165 $enroll = $token->getTemporaryTokenProperty('duo.enroll');
166 $duo_id = $token->getTemporaryTokenProperty('duo.user-id');
167 $duo_uri = $token->getTemporaryTokenProperty('duo.uri');
168 $duo_user = $token->getTemporaryTokenProperty('duo.username');
170 $is_external = ($enroll === 'external');
171 $is_auto = ($enroll === 'auto');
172 $is_blocked = ($enroll === 'blocked');
174 if (!$token->getIsNewTemporaryToken()) {
176 return $this->newDuoConfig($user, $duo_user);
177 } else if ($is_external ||
$is_blocked) {
179 'username' => $duo_user,
182 $result = $this->newDuoFuture($provider)
183 ->setMethod('preauth', $parameters)
186 $result_code = $result['response']['result'];
187 switch ($result_code) {
190 return $this->newDuoConfig($user, $duo_user);
193 // We'll render an equivalent static control below, so skip
194 // rendering here. We explicitly don't want to give the user
195 // an enroll workflow.
199 $duo_uri = $result['response']['enroll_portal_url'];
201 $waiting_icon = id(new PHUIIconView())
202 ->setIcon('fa-mobile', 'red');
204 $waiting_control = id(new PHUIFormTimerControl())
205 ->setIcon($waiting_icon)
206 ->setError(pht('Not Complete'))
209 'You have not completed Duo enrollment yet. '.
210 'Complete enrollment, then click continue.'));
212 $form->appendControl($waiting_control);
220 'user_id' => $duo_id,
221 'activation_code' => $duo_uri,
224 $future = $this->newDuoFuture($provider)
225 ->setMethod('enroll_status', $parameters);
227 $result = $future->resolve();
228 $response = $result['response'];
232 return $this->newDuoConfig($user, $duo_user);
234 $waiting_icon = id(new PHUIIconView())
235 ->setIcon('fa-mobile', 'red');
237 $waiting_control = id(new PHUIFormTimerControl())
238 ->setIcon($waiting_icon)
239 ->setError(pht('Not Complete'))
242 'You have not activated this enrollment in the Duo '.
243 'application on your phone yet. Complete activation, then '.
246 $form->appendControl($waiting_control);
252 'This Duo enrollment attempt is invalid or has '.
253 'expired ("%s"). Cancel the workflow and try again.',
260 $blocked_icon = id(new PHUIIconView())
261 ->setIcon('fa-times', 'red');
263 $blocked_control = id(new PHUIFormTimerControl())
264 ->setIcon($blocked_icon)
267 'Your Duo account ("%s") has not completed Duo enrollment. '.
268 'Check your email and complete enrollment to continue.',
269 phutil_tag('strong', array(), $duo_user)));
271 $form->appendControl($blocked_control);
272 } else if ($is_auto) {
273 $auto_icon = id(new PHUIIconView())
274 ->setIcon('fa-check', 'green');
276 $auto_control = id(new PHUIFormTimerControl())
277 ->setIcon($auto_icon)
280 'Duo account ("%s") is fully enrolled.',
281 phutil_tag('strong', array(), $duo_user)));
283 $form->appendControl($auto_control);
285 $duo_button = phutil_tag(
289 'class' => 'button button-grey',
290 'target' => ($is_external ?
'_blank' : null),
292 pht('Enroll Duo Account: %s', $duo_user));
294 $duo_button = phutil_tag(
297 'class' => 'mfa-form-enroll-button',
302 $form->appendRemarkupInstructions(
304 'Complete enrolling your phone with Duo:'));
306 $form->appendControl(
307 id(new AphrontFormMarkupControl())
308 ->setValue($duo_button));
311 $form->appendRemarkupInstructions(
313 'Scan this QR code with the Duo application on your mobile '.
317 $qr_code = $this->newQRCode($duo_uri);
318 $form->appendChild($qr_code);
320 $form->appendRemarkupInstructions(
322 'If you are currently using your phone to view this page, '.
323 'click this button to open the Duo application:'));
325 $form->appendControl(
326 id(new AphrontFormMarkupControl())
327 ->setValue($duo_button));
330 $form->appendRemarkupInstructions(
332 'Once you have completed setup on your phone, click continue.'));
337 protected function newMFASyncTokenProperties(
338 PhabricatorAuthFactorProvider
$provider,
339 PhabricatorUser
$user) {
341 $duo_user = $this->getDuoUsername($provider, $user);
343 // Duo automatically normalizes usernames to lowercase. Just do that here
344 // so that our value agrees more closely with Duo.
345 $duo_user = phutil_utf8_strtolower($duo_user);
348 'username' => $duo_user,
351 $result = $this->newDuoFuture($provider)
352 ->setMethod('preauth', $parameters)
355 $external_uri = null;
356 $result_code = $result['response']['result'];
357 $status_message = $result['response']['status_msg'];
358 switch ($result_code) {
361 // If the user already has a Duo account, they don't need to do
364 'duo.enroll' => 'auto',
365 'duo.username' => $duo_user,
368 if (!$this->shouldAllowDuoEnrollment($provider)) {
370 'duo.enroll' => 'blocked',
371 'duo.username' => $duo_user,
375 $external_uri = $result['response']['enroll_portal_url'];
377 // Otherwise, enrollment is permitted so we're going to continue.
381 return $this->newResult()
385 'Your Duo account ("%s") is not permitted to access this '.
386 'system. Contact your Duo administrator for help. '.
387 'The Duo preauth API responded with status message ("%s"): %s',
393 // Duo's "/enroll" API isn't repeatable for the same username. If we're
394 // the first call, great: we can do inline enrollment, which is way more
395 // user friendly. Otherwise, we have to send the user on an adventure.
398 'username' => $duo_user,
399 'valid_secs' => phutil_units('1 hour in seconds'),
403 $result = $this->newDuoFuture($provider)
404 ->setMethod('enroll', $parameters)
406 } catch (HTTPFutureHTTPResponseStatus
$ex) {
408 'duo.enroll' => 'external',
409 'duo.username' => $duo_user,
410 'duo.uri' => $external_uri,
415 'duo.enroll' => 'inline',
416 'duo.uri' => $result['response']['activation_code'],
417 'duo.username' => $duo_user,
418 'duo.user-id' => $result['response']['user_id'],
422 protected function newIssuedChallenges(
423 PhabricatorAuthFactorConfig
$config,
424 PhabricatorUser
$viewer,
427 // If we already issued a valid challenge for this workflow and session,
428 // don't issue a new one.
430 $challenge = $this->getChallengeForCurrentContext(
438 if (!$this->hasCSRF($config)) {
439 return $this->newResult()
440 ->setIsContinue(true)
443 'An authorization request will be pushed to the Duo '.
444 'application on your phone.'));
447 $provider = $config->getFactorProvider();
449 // Otherwise, issue a new challenge.
450 $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username');
453 'username' => $duo_user,
456 $response = $this->newDuoFuture($provider)
457 ->setMethod('preauth', $parameters)
459 $response = $response['response'];
461 $next_step = $response['result'];
462 $status_message = $response['status_msg'];
463 switch ($next_step) {
468 // Duo is telling us to bypass MFA. For now, refuse.
469 return $this->newResult()
473 'Duo is not requiring a challenge, which defeats the '.
474 'purpose of MFA. Duo must be configured to challenge you.'));
476 return $this->newResult()
480 'Your Duo account ("%s") requires enrollment. Contact your '.
481 'Duo administrator for help. Duo status message: %s',
486 return $this->newResult()
490 'Your Duo account ("%s") is not permitted to access this '.
491 'system. Contact your Duo administrator for help. The Duo '.
492 'preauth API responded with status message ("%s"): %s',
499 $devices = $response['devices'];
500 foreach ($devices as $device) {
501 $capabilities = array_fuse($device['capabilities']);
502 if (isset($capabilities['push'])) {
509 return $this->newResult()
513 'This factor has been removed from your device, so Phabricator '.
514 'can not send you a challenge. To continue, an administrator '.
515 'must strip this factor from your account.'));
519 pht('Domain') => $this->getInstallDisplayName(),
521 $push_info = phutil_build_http_querystring($push_info);
524 'username' => $duo_user,
528 // Duo allows us to specify a device, or to pass "auto" to have it pick
529 // the first one. For now, just let it pick.
532 // This is a hard-coded prefix for the word "... request" in the Duo UI,
533 // which defaults to "Login". We could pass richer information from
534 // workflows here, but it's not very flexible anyway.
535 'type' => 'Authentication',
537 'display_username' => $viewer->getUsername(),
538 'pushinfo' => $push_info,
541 $result = $this->newDuoFuture($provider)
542 ->setMethod('auth', $parameters)
545 $duo_xaction = $result['response']['txid'];
547 // The Duo push timeout is 60 seconds. Set our challenge to expire slightly
548 // more quickly so that we'll re-issue a new challenge before Duo times out.
549 // This should keep users away from a dead-end where they can't respond to
550 // Duo but Phabricator won't issue a new challenge yet.
554 $this->newChallenge($config, $viewer)
555 ->setChallengeKey($duo_xaction)
556 ->setChallengeTTL(PhabricatorTime
::getNow() +
$ttl_seconds),
560 protected function newResultFromIssuedChallenges(
561 PhabricatorAuthFactorConfig
$config,
562 PhabricatorUser
$viewer,
565 $challenge = $this->getChallengeForCurrentContext(
570 if ($challenge->getIsAnsweredChallenge()) {
571 return $this->newResult()
572 ->setAnsweredChallenge($challenge);
575 $provider = $config->getFactorProvider();
576 $duo_xaction = $challenge->getChallengeKey();
579 'txid' => $duo_xaction,
582 // This endpoint always long-polls, so use a timeout to force it to act
583 // more asynchronously.
585 $result = $this->newDuoFuture($provider)
586 ->setHTTPMethod('GET')
587 ->setMethod('auth_status', $parameters)
591 $state = $result['response']['result'];
592 $status = $result['response']['status'];
593 } catch (HTTPFutureCURLResponseStatus
$exception) {
594 if ($exception->isTimeout()) {
602 $now = PhabricatorTime
::getNow();
606 $ttl = PhabricatorTime
::getNow()
607 +
phutil_units('15 minutes in seconds');
610 ->markChallengeAsAnswered($ttl);
612 return $this->newResult()
613 ->setAnsweredChallenge($challenge);
615 // If we didn't just issue this challenge, give the user a stronger
616 // hint that they need to follow the instructions.
617 if (!$challenge->getIsNewChallenge()) {
618 return $this->newResult()
619 ->setIsContinue(true)
621 id(new PHUIIconView())
622 ->setIcon('fa-exclamation-triangle', 'yellow'))
625 'You must approve the challenge which was sent to your '.
626 'phone. Open the Duo application and confirm the challenge, '.
630 // Otherwise, we'll construct a default message later on.
634 if ($status === 'timeout') {
635 return $this->newResult()
639 'This request has timed out because you took too long to '.
642 $wait_duration = ($challenge->getChallengeTTL() - $now) +
1;
644 return $this->newResult()
648 'You denied this request. Wait %s second(s) to try again.',
649 new PhutilNumber($wait_duration)));
657 public function renderValidateFactorForm(
658 PhabricatorAuthFactorConfig
$config,
659 AphrontFormView
$form,
660 PhabricatorUser
$viewer,
661 PhabricatorAuthFactorResult
$result) {
663 $control = $this->newAutomaticControl($result);
666 ->setLabel(pht('Duo'))
667 ->setCaption(pht('Factor Name: %s', $config->getFactorName()));
669 $form->appendChild($control);
672 public function getRequestHasChallengeResponse(
673 PhabricatorAuthFactorConfig
$config,
674 AphrontRequest
$request) {
678 protected function newResultFromChallengeResponse(
679 PhabricatorAuthFactorConfig
$config,
680 PhabricatorUser
$viewer,
681 AphrontRequest
$request,
684 return $this->getResultForPrompt(
691 protected function newResultForPrompt(
692 PhabricatorAuthFactorConfig
$config,
693 PhabricatorUser
$viewer,
694 AphrontRequest
$request,
697 $result = $this->newResult()
698 ->setIsContinue(true)
701 'A challenge has been sent to your phone. Open the Duo '.
702 'application and confirm the challenge, then continue.'));
704 $challenge = $this->getChallengeForCurrentContext(
710 ->setStatusChallenge($challenge)
712 id(new PHUIIconView())
713 ->setIcon('fa-refresh', 'green ph-spin'));
719 private function newDuoFuture(PhabricatorAuthFactorProvider
$provider) {
720 $credential_phid = $provider->getAuthFactorProviderProperty(
721 self
::PROP_CREDENTIAL
);
723 $omnipotent = PhabricatorUser
::getOmnipotentUser();
725 $credential = id(new PassphraseCredentialQuery())
726 ->setViewer($omnipotent)
727 ->withPHIDs(array($credential_phid))
733 'Unable to load Duo API credential ("%s").',
737 $duo_key = $credential->getUsername();
738 $duo_secret = $credential->getSecret();
742 'Duo API credential ("%s") has no secret key.',
746 $duo_host = $provider->getAuthFactorProviderProperty(
747 self
::PROP_HOSTNAME
);
748 self
::requireDuoAPIHostname($duo_host);
750 return id(new PhabricatorDuoFuture())
751 ->setIntegrationKey($duo_key)
752 ->setSecretKey($duo_secret)
753 ->setAPIHostname($duo_host)
755 ->setHTTPMethod('POST');
758 private function getDuoUsername(
759 PhabricatorAuthFactorProvider
$provider,
760 PhabricatorUser
$user) {
762 $mode = $provider->getAuthFactorProviderProperty(self
::PROP_USERNAMES
);
765 return $user->getUsername();
767 return $user->loadPrimaryEmailAddress();
771 'Duo username pairing mode ("%s") is not supported.',
776 private function shouldAllowDuoEnrollment(
777 PhabricatorAuthFactorProvider
$provider) {
779 $mode = $provider->getAuthFactorProviderProperty(self
::PROP_ENROLL
);
788 'Duo enrollment mode ("%s") is not supported.',
793 private function newDuoConfig(PhabricatorUser
$user, $duo_user) {
794 $config_properties = array(
795 'duo.username' => $duo_user,
798 $config = $this->newConfigForUser($user)
799 ->setFactorName(pht('Duo (%s)', $duo_user))
800 ->setProperties($config_properties);
805 public static function requireDuoAPIHostname($hostname) {
806 if (preg_match('/\.duosecurity\.com\z/', $hostname)) {
812 'Duo API hostname ("%s") is invalid, hostname must be '.
813 '"*.duosecurity.com".',
817 public function newChallengeStatusView(
818 PhabricatorAuthFactorConfig
$config,
819 PhabricatorAuthFactorProvider
$provider,
820 PhabricatorUser
$viewer,
821 PhabricatorAuthChallenge
$challenge) {
823 $duo_xaction = $challenge->getChallengeKey();
826 'txid' => $duo_xaction,
829 $default_result = id(new PhabricatorAuthChallengeUpdate())
833 $result = $this->newDuoFuture($provider)
834 ->setHTTPMethod('GET')
835 ->setMethod('auth_status', $parameters)
839 $state = $result['response']['result'];
840 } catch (HTTPFutureCURLResponseStatus
$exception) {
841 // If we failed or timed out, retry. Usually, this is a timeout.
842 return id(new PhabricatorAuthChallengeUpdate())
846 // For now, don't update the view for anything but an "Allow". Updates
847 // here are just about providing more visual feedback for user convenience.
848 if ($state !== 'allow') {
849 return id(new PhabricatorAuthChallengeUpdate())
853 $icon = id(new PHUIIconView())
854 ->setIcon('fa-check-circle-o', 'green');
856 $view = id(new PHUIFormTimerControl())
858 ->appendChild(pht('You responded to this challenge correctly.'))
861 return id(new PhabricatorAuthChallengeUpdate())