@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class PhabricatorDuoAuthFactor
4 extends PhabricatorAuthFactor {
5
6 const PROP_CREDENTIAL = 'duo.credentialPHID';
7 const PROP_ENROLL = 'duo.enroll';
8 const PROP_USERNAMES = 'duo.usernames';
9 const PROP_HOSTNAME = 'duo.hostname';
10
11 public function getFactorKey() {
12 return 'duo';
13 }
14
15 public function getFactorName() {
16 return pht('Duo Security');
17 }
18
19 public function getFactorShortName() {
20 return pht('Duo');
21 }
22
23 public function getFactorCreateHelp() {
24 return pht('Support for Duo push authentication.');
25 }
26
27 public function getFactorDescription() {
28 return pht(
29 'When you need to authenticate, a request will be pushed to the '.
30 'Duo application on your phone.');
31 }
32
33 public function getEnrollDescription(
34 PhabricatorAuthFactorProvider $provider,
35 PhabricatorUser $user) {
36 return pht(
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.');
40 }
41
42 public function canCreateNewConfiguration(
43 PhabricatorAuthFactorProvider $provider,
44 PhabricatorUser $user) {
45
46 if ($this->loadConfigurationsForProvider($provider, $user)) {
47 return false;
48 }
49
50 return true;
51 }
52
53 public function getConfigurationCreateDescription(
54 PhabricatorAuthFactorProvider $provider,
55 PhabricatorUser $user) {
56
57 $messages = array();
58
59 if ($this->loadConfigurationsForProvider($provider, $user)) {
60 $messages[] = id(new PHUIInfoView())
61 ->setSeverity(PHUIInfoView::SEVERITY_WARNING)
62 ->setErrors(
63 array(
64 pht(
65 'You already have Duo authentication attached to your account '.
66 'for this provider.'),
67 ));
68 }
69
70 return $messages;
71 }
72
73 public function getConfigurationListDetails(
74 PhabricatorAuthFactorConfig $config,
75 PhabricatorAuthFactorProvider $provider,
76 PhabricatorUser $viewer) {
77
78 $duo_user = $config->getAuthFactorConfigProperty('duo.username');
79
80 return pht('Duo Username: %s', $duo_user);
81 }
82
83
84 public function newEditEngineFields(
85 PhabricatorEditEngine $engine,
86 PhabricatorAuthFactorProvider $provider) {
87
88 $viewer = $engine->getViewer();
89
90 $credential_phid = $provider->getAuthFactorProviderProperty(
91 self::PROP_CREDENTIAL);
92
93 $hostname = $provider->getAuthFactorProviderProperty(self::PROP_HOSTNAME);
94 $usernames = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES);
95 $enroll = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL);
96
97 $credential_type = PassphrasePasswordCredentialType::CREDENTIAL_TYPE;
98 $provides_type = PassphrasePasswordCredentialType::PROVIDES_TYPE;
99
100 $credentials = id(new PassphraseCredentialQuery())
101 ->setViewer($viewer)
102 ->withIsDestroyed(false)
103 ->withProvidesTypes(array($provides_type))
104 ->execute();
105
106 $xaction_hostname =
107 PhabricatorAuthFactorProviderDuoHostnameTransaction::TRANSACTIONTYPE;
108 $xaction_credential =
109 PhabricatorAuthFactorProviderDuoCredentialTransaction::TRANSACTIONTYPE;
110 $xaction_usernames =
111 PhabricatorAuthFactorProviderDuoUsernamesTransaction::TRANSACTIONTYPE;
112 $xaction_enroll =
113 PhabricatorAuthFactorProviderDuoEnrollTransaction::TRANSACTIONTYPE;
114
115 return array(
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)
134 ->setOptions(
135 array(
136 'username' => pht(
137 'Use %s Username',
138 PlatformSymbols::getPlatformServerName()),
139 'email' => pht('Use Primary Email Address'),
140 )),
141 id(new PhabricatorSelectEditField())
142 ->setLabel(pht('Create Accounts'))
143 ->setKey('duo.enroll')
144 ->setValue($enroll)
145 ->setTransactionType($xaction_enroll)
146 ->setOptions(
147 array(
148 'deny' => pht('Require Existing Duo Account'),
149 'allow' => pht('Create New Duo Account'),
150 )),
151 );
152 }
153
154
155 public function processAddFactorForm(
156 PhabricatorAuthFactorProvider $provider,
157 AphrontFormView $form,
158 AphrontRequest $request,
159 PhabricatorUser $user) {
160
161 $token = $this->loadMFASyncToken($provider, $request, $form, $user);
162 if ($this->isAuthResult($token)) {
163 $form->appendChild($this->newAutomaticControl($token));
164 return;
165 }
166
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');
171
172 $is_external = ($enroll === 'external');
173 $is_auto = ($enroll === 'auto');
174 $is_blocked = ($enroll === 'blocked');
175
176 if (!$token->getIsNewTemporaryToken()) {
177 if ($is_auto) {
178 return $this->newDuoConfig($user, $duo_user);
179 } else if ($is_external || $is_blocked) {
180 $parameters = array(
181 'username' => $duo_user,
182 );
183
184 $result = $this->newDuoFuture($provider)
185 ->setMethod('preauth', $parameters)
186 ->resolve();
187
188 $result_code = $result['response']['result'];
189 switch ($result_code) {
190 case 'auth':
191 case 'allow':
192 return $this->newDuoConfig($user, $duo_user);
193 case 'enroll':
194 if ($is_blocked) {
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.
198 break;
199 }
200
201 $duo_uri = $result['response']['enroll_portal_url'];
202
203 $waiting_icon = id(new PHUIIconView())
204 ->setIcon('fa-mobile', 'red');
205
206 $waiting_control = id(new PHUIFormTimerControl())
207 ->setIcon($waiting_icon)
208 ->setError(pht('Not Complete'))
209 ->appendChild(
210 pht(
211 'You have not completed Duo enrollment yet. '.
212 'Complete enrollment, then click continue.'));
213
214 $form->appendControl($waiting_control);
215 break;
216 default:
217 case 'deny':
218 break;
219 }
220 } else {
221 $parameters = array(
222 'user_id' => $duo_id,
223 'activation_code' => $duo_uri,
224 );
225
226 $future = $this->newDuoFuture($provider)
227 ->setMethod('enroll_status', $parameters);
228
229 $result = $future->resolve();
230 $response = $result['response'];
231
232 switch ($response) {
233 case 'success':
234 return $this->newDuoConfig($user, $duo_user);
235 case 'waiting':
236 $waiting_icon = id(new PHUIIconView())
237 ->setIcon('fa-mobile', 'red');
238
239 $waiting_control = id(new PHUIFormTimerControl())
240 ->setIcon($waiting_icon)
241 ->setError(pht('Not Complete'))
242 ->appendChild(
243 pht(
244 'You have not activated this enrollment in the Duo '.
245 'application on your phone yet. Complete activation, then '.
246 'click continue.'));
247
248 $form->appendControl($waiting_control);
249 break;
250 case 'invalid':
251 default:
252 throw new Exception(
253 pht(
254 'This Duo enrollment attempt is invalid or has '.
255 'expired ("%s"). Cancel the workflow and try again.',
256 $response));
257 }
258 }
259 }
260
261 if ($is_blocked) {
262 $blocked_icon = id(new PHUIIconView())
263 ->setIcon('fa-times', 'red');
264
265 $blocked_control = id(new PHUIFormTimerControl())
266 ->setIcon($blocked_icon)
267 ->appendChild(
268 pht(
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)));
272
273 $form->appendControl($blocked_control);
274 } else if ($is_auto) {
275 $auto_icon = id(new PHUIIconView())
276 ->setIcon('fa-check', 'green');
277
278 $auto_control = id(new PHUIFormTimerControl())
279 ->setIcon($auto_icon)
280 ->appendChild(
281 pht(
282 'Duo account ("%s") is fully enrolled.',
283 phutil_tag('strong', array(), $duo_user)));
284
285 $form->appendControl($auto_control);
286 } else {
287 $duo_button = phutil_tag(
288 'a',
289 array(
290 'href' => $duo_uri,
291 'class' => 'button button-grey',
292 'target' => ($is_external ? '_blank' : null),
293 ),
294 pht('Enroll Duo Account: %s', $duo_user));
295
296 $duo_button = phutil_tag(
297 'div',
298 array(
299 'class' => 'mfa-form-enroll-button',
300 ),
301 $duo_button);
302
303 if ($is_external) {
304 $form->appendRemarkupInstructions(
305 pht(
306 'Complete enrolling your phone with Duo:'));
307
308 $form->appendControl(
309 id(new AphrontFormMarkupControl())
310 ->setValue($duo_button));
311 } else {
312
313 $form->appendRemarkupInstructions(
314 pht(
315 'Scan this QR code with the Duo application on your mobile '.
316 'phone:'));
317
318
319 $qr_code = $this->newQRCode($duo_uri);
320 $form->appendChild($qr_code);
321
322 $form->appendRemarkupInstructions(
323 pht(
324 'If you are currently using your phone to view this page, '.
325 'click this button to open the Duo application:'));
326
327 $form->appendControl(
328 id(new AphrontFormMarkupControl())
329 ->setValue($duo_button));
330 }
331
332 $form->appendRemarkupInstructions(
333 pht(
334 'Once you have completed setup on your phone, click continue.'));
335 }
336 }
337
338
339 protected function newMFASyncTokenProperties(
340 PhabricatorAuthFactorProvider $provider,
341 PhabricatorUser $user) {
342
343 $duo_user = $this->getDuoUsername($provider, $user);
344
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);
348
349 $parameters = array(
350 'username' => $duo_user,
351 );
352
353 $result = $this->newDuoFuture($provider)
354 ->setMethod('preauth', $parameters)
355 ->resolve();
356
357 $external_uri = null;
358 $result_code = $result['response']['result'];
359 $status_message = $result['response']['status_msg'];
360 switch ($result_code) {
361 case 'auth':
362 case 'allow':
363 // If the user already has a Duo account, they don't need to do
364 // anything.
365 return array(
366 'duo.enroll' => 'auto',
367 'duo.username' => $duo_user,
368 );
369 case 'enroll':
370 if (!$this->shouldAllowDuoEnrollment($provider)) {
371 return array(
372 'duo.enroll' => 'blocked',
373 'duo.username' => $duo_user,
374 );
375 }
376
377 $external_uri = $result['response']['enroll_portal_url'];
378
379 // Otherwise, enrollment is permitted so we're going to continue.
380 break;
381 default:
382 case 'deny':
383 return $this->newResult()
384 ->setIsError(true)
385 ->setErrorMessage(
386 pht(
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',
390 $duo_user,
391 $result_code,
392 $status_message));
393 }
394
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.
398
399 $parameters = array(
400 'username' => $duo_user,
401 'valid_secs' => phutil_units('1 hour in seconds'),
402 );
403
404 try {
405 $result = $this->newDuoFuture($provider)
406 ->setMethod('enroll', $parameters)
407 ->resolve();
408 } catch (HTTPFutureHTTPResponseStatus $ex) {
409 return array(
410 'duo.enroll' => 'external',
411 'duo.username' => $duo_user,
412 'duo.uri' => $external_uri,
413 );
414 }
415
416 return array(
417 'duo.enroll' => 'inline',
418 'duo.uri' => $result['response']['activation_code'],
419 'duo.username' => $duo_user,
420 'duo.user-id' => $result['response']['user_id'],
421 );
422 }
423
424 protected function newIssuedChallenges(
425 PhabricatorAuthFactorConfig $config,
426 PhabricatorUser $viewer,
427 array $challenges) {
428
429 // If we already issued a valid challenge for this workflow and session,
430 // don't issue a new one.
431
432 $challenge = $this->getChallengeForCurrentContext(
433 $config,
434 $viewer,
435 $challenges);
436 if ($challenge) {
437 return array();
438 }
439
440 if (!$this->hasCSRF($config)) {
441 return $this->newResult()
442 ->setIsContinue(true)
443 ->setErrorMessage(
444 pht(
445 'An authorization request will be pushed to the Duo '.
446 'application on your phone.'));
447 }
448
449 $provider = $config->getFactorProvider();
450
451 // Otherwise, issue a new challenge.
452 $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username');
453
454 $parameters = array(
455 'username' => $duo_user,
456 );
457
458 $response = $this->newDuoFuture($provider)
459 ->setMethod('preauth', $parameters)
460 ->resolve();
461 $response = $response['response'];
462
463 $next_step = $response['result'];
464 $status_message = $response['status_msg'];
465 switch ($next_step) {
466 case 'auth':
467 // We're good to go.
468 break;
469 case 'allow':
470 // Duo is telling us to bypass MFA. For now, refuse.
471 return $this->newResult()
472 ->setIsError(true)
473 ->setErrorMessage(
474 pht(
475 'Duo is not requiring a challenge, which defeats the '.
476 'purpose of MFA. Duo must be configured to challenge you.'));
477 case 'enroll':
478 return $this->newResult()
479 ->setIsError(true)
480 ->setErrorMessage(
481 pht(
482 'Your Duo account ("%s") requires enrollment. Contact your '.
483 'Duo administrator for help. Duo status message: %s',
484 $duo_user,
485 $status_message));
486 case 'deny':
487 default:
488 return $this->newResult()
489 ->setIsError(true)
490 ->setErrorMessage(
491 pht(
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',
495 $duo_user,
496 $next_step,
497 $status_message));
498 }
499
500 $has_push = false;
501 $devices = $response['devices'];
502 foreach ($devices as $device) {
503 $capabilities = array_fuse($device['capabilities']);
504 if (isset($capabilities['push'])) {
505 $has_push = true;
506 break;
507 }
508 }
509
510 if (!$has_push) {
511 return $this->newResult()
512 ->setIsError(true)
513 ->setErrorMessage(
514 pht(
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.'));
518 }
519
520 $push_info = array(
521 pht('Domain') => $this->getInstallDisplayName(),
522 );
523 $push_info = phutil_build_http_querystring($push_info);
524
525 $parameters = array(
526 'username' => $duo_user,
527 'factor' => 'push',
528 'async' => '1',
529
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.
532 'device' => 'auto',
533
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',
538
539 'display_username' => $viewer->getUsername(),
540 'pushinfo' => $push_info,
541 );
542
543 $result = $this->newDuoFuture($provider)
544 ->setMethod('auth', $parameters)
545 ->resolve();
546
547 $duo_xaction = $result['response']['txid'];
548
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.
553 $ttl_seconds = 55;
554
555 return array(
556 $this->newChallenge($config, $viewer)
557 ->setChallengeKey($duo_xaction)
558 ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),
559 );
560 }
561
562 protected function newResultFromIssuedChallenges(
563 PhabricatorAuthFactorConfig $config,
564 PhabricatorUser $viewer,
565 array $challenges) {
566
567 $challenge = $this->getChallengeForCurrentContext(
568 $config,
569 $viewer,
570 $challenges);
571
572 if ($challenge->getIsAnsweredChallenge()) {
573 return $this->newResult()
574 ->setAnsweredChallenge($challenge);
575 }
576
577 $provider = $config->getFactorProvider();
578 $duo_xaction = $challenge->getChallengeKey();
579
580 $parameters = array(
581 'txid' => $duo_xaction,
582 );
583
584 // This endpoint always long-polls, so use a timeout to force it to act
585 // more asynchronously.
586 try {
587 $result = $this->newDuoFuture($provider)
588 ->setHTTPMethod('GET')
589 ->setMethod('auth_status', $parameters)
590 ->setTimeout(3)
591 ->resolve();
592
593 $state = $result['response']['result'];
594 $status = $result['response']['status'];
595 } catch (HTTPFutureCURLResponseStatus $exception) {
596 if ($exception->isTimeout()) {
597 $state = 'waiting';
598 $status = 'poll';
599 } else {
600 throw $exception;
601 }
602 }
603
604 $now = PhabricatorTime::getNow();
605
606 switch ($state) {
607 case 'allow':
608 $ttl = PhabricatorTime::getNow()
609 + phutil_units('15 minutes in seconds');
610
611 $challenge
612 ->markChallengeAsAnswered($ttl);
613
614 return $this->newResult()
615 ->setAnsweredChallenge($challenge);
616 case 'waiting':
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)
622 ->setIcon(
623 id(new PHUIIconView())
624 ->setIcon('fa-exclamation-triangle', 'yellow'))
625 ->setErrorMessage(
626 pht(
627 'You must approve the challenge which was sent to your '.
628 'phone. Open the Duo application and confirm the challenge, '.
629 'then continue.'));
630 }
631
632 // Otherwise, we'll construct a default message later on.
633 break;
634 default:
635 case 'deny':
636 if ($status === 'timeout') {
637 return $this->newResult()
638 ->setIsError(true)
639 ->setErrorMessage(
640 pht(
641 'This request has timed out because you took too long to '.
642 'respond.'));
643 } else {
644 $wait_duration = ($challenge->getChallengeTTL() - $now) + 1;
645
646 return $this->newResult()
647 ->setIsWait(true)
648 ->setErrorMessage(
649 pht(
650 'You denied this request. Wait %s second(s) to try again.',
651 new PhutilNumber($wait_duration)));
652 }
653 }
654
655 return null;
656 }
657
658 public function renderValidateFactorForm(
659 PhabricatorAuthFactorConfig $config,
660 AphrontFormView $form,
661 PhabricatorUser $viewer,
662 PhabricatorAuthFactorResult $result) {
663
664 $control = $this->newAutomaticControl($result);
665
666 $control
667 ->setLabel(pht('Duo'))
668 ->setCaption(pht('Factor Name: %s', $config->getFactorName()));
669
670 $form->appendChild($control);
671 }
672
673 public function getRequestHasChallengeResponse(
674 PhabricatorAuthFactorConfig $config,
675 AphrontRequest $request) {
676 return false;
677 }
678
679 protected function newResultFromChallengeResponse(
680 PhabricatorAuthFactorConfig $config,
681 PhabricatorUser $viewer,
682 AphrontRequest $request,
683 array $challenges) {
684
685 return $this->getResultForPrompt(
686 $config,
687 $viewer,
688 $request,
689 $challenges);
690 }
691
692 protected function newResultForPrompt(
693 PhabricatorAuthFactorConfig $config,
694 PhabricatorUser $viewer,
695 AphrontRequest $request,
696 array $challenges) {
697
698 $result = $this->newResult()
699 ->setIsContinue(true)
700 ->setErrorMessage(
701 pht(
702 'A challenge has been sent to your phone. Open the Duo '.
703 'application and confirm the challenge, then continue.'));
704
705 $challenge = $this->getChallengeForCurrentContext(
706 $config,
707 $viewer,
708 $challenges);
709 if ($challenge) {
710 $result
711 ->setStatusChallenge($challenge)
712 ->setIcon(
713 id(new PHUIIconView())
714 ->setIcon('fa-refresh', 'green ph-spin'));
715 }
716
717 return $result;
718 }
719
720 private function newDuoFuture(PhabricatorAuthFactorProvider $provider) {
721 $credential_phid = $provider->getAuthFactorProviderProperty(
722 self::PROP_CREDENTIAL);
723
724 $omnipotent = PhabricatorUser::getOmnipotentUser();
725
726 $credential = id(new PassphraseCredentialQuery())
727 ->setViewer($omnipotent)
728 ->withPHIDs(array($credential_phid))
729 ->needSecrets(true)
730 ->executeOne();
731 if (!$credential) {
732 throw new Exception(
733 pht(
734 'Unable to load Duo API credential ("%s").',
735 $credential_phid));
736 }
737
738 $duo_key = $credential->getUsername();
739 $duo_secret = $credential->getSecret();
740 if (!$duo_secret) {
741 throw new Exception(
742 pht(
743 'Duo API credential ("%s") has no secret key.',
744 $credential_phid));
745 }
746
747 $duo_host = $provider->getAuthFactorProviderProperty(
748 self::PROP_HOSTNAME);
749 self::requireDuoAPIHostname($duo_host);
750
751 return id(new PhabricatorDuoFuture())
752 ->setIntegrationKey($duo_key)
753 ->setSecretKey($duo_secret)
754 ->setAPIHostname($duo_host)
755 ->setTimeout(10)
756 ->setHTTPMethod('POST');
757 }
758
759 private function getDuoUsername(
760 PhabricatorAuthFactorProvider $provider,
761 PhabricatorUser $user) {
762
763 $mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES);
764 switch ($mode) {
765 case 'username':
766 return $user->getUsername();
767 case 'email':
768 return $user->loadPrimaryEmailAddress();
769 default:
770 throw new Exception(
771 pht(
772 'Duo username pairing mode ("%s") is not supported.',
773 $mode));
774 }
775 }
776
777 private function shouldAllowDuoEnrollment(
778 PhabricatorAuthFactorProvider $provider) {
779
780 $mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL);
781 switch ($mode) {
782 case 'deny':
783 return false;
784 case 'allow':
785 return true;
786 default:
787 throw new Exception(
788 pht(
789 'Duo enrollment mode ("%s") is not supported.',
790 $mode));
791 }
792 }
793
794 private function newDuoConfig(PhabricatorUser $user, $duo_user) {
795 $config_properties = array(
796 'duo.username' => $duo_user,
797 );
798
799 $config = $this->newConfigForUser($user)
800 ->setFactorName(pht('Duo (%s)', $duo_user))
801 ->setProperties($config_properties);
802
803 return $config;
804 }
805
806 public static function requireDuoAPIHostname($hostname) {
807 if (preg_match('/\.duosecurity\.com\z/', $hostname)) {
808 return;
809 }
810
811 throw new Exception(
812 pht(
813 'Duo API hostname ("%s") is invalid, hostname must be '.
814 '"*.duosecurity.com".',
815 $hostname));
816 }
817
818 public function newChallengeStatusView(
819 PhabricatorAuthFactorConfig $config,
820 PhabricatorAuthFactorProvider $provider,
821 PhabricatorUser $viewer,
822 PhabricatorAuthChallenge $challenge) {
823
824 $duo_xaction = $challenge->getChallengeKey();
825
826 $parameters = array(
827 'txid' => $duo_xaction,
828 );
829
830 $default_result = id(new PhabricatorAuthChallengeUpdate())
831 ->setRetry(true);
832
833 try {
834 $result = $this->newDuoFuture($provider)
835 ->setHTTPMethod('GET')
836 ->setMethod('auth_status', $parameters)
837 ->setTimeout(5)
838 ->resolve();
839
840 $state = $result['response']['result'];
841 } catch (HTTPFutureCURLResponseStatus $exception) {
842 // If we failed or timed out, retry. Usually, this is a timeout.
843 return id(new PhabricatorAuthChallengeUpdate())
844 ->setRetry(true);
845 }
846
847 // For now, don't update the view for anything but an "Allow". Updates
848 // here are just about providing more visual feedback for user convenience.
849 if ($state !== 'allow') {
850 return id(new PhabricatorAuthChallengeUpdate())
851 ->setRetry(false);
852 }
853
854 $icon = id(new PHUIIconView())
855 ->setIcon('fa-check-circle-o', 'green');
856
857 $view = id(new PHUIFormTimerControl())
858 ->setIcon($icon)
859 ->appendChild(pht('You responded to this challenge correctly.'))
860 ->newTimerView();
861
862 return id(new PhabricatorAuthChallengeUpdate())
863 ->setState('allow')
864 ->setRetry(false)
865 ->setMarkup($view);
866 }
867
868}