@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
fork

Configure Feed

Select the types of activity you want to include in your feed.

Bring Duo MFA upstream

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

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

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13231

Differential Revision: https://secure.phabricator.com/D20039

+1076 -8
+12
src/__phutil_library_map__.php
··· 2228 2228 'PhabricatorAuthFactorConfigQuery' => 'applications/auth/query/PhabricatorAuthFactorConfigQuery.php', 2229 2229 'PhabricatorAuthFactorProvider' => 'applications/auth/storage/PhabricatorAuthFactorProvider.php', 2230 2230 'PhabricatorAuthFactorProviderController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderController.php', 2231 + 'PhabricatorAuthFactorProviderDuoCredentialTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php', 2232 + 'PhabricatorAuthFactorProviderDuoEnrollTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php', 2233 + 'PhabricatorAuthFactorProviderDuoHostnameTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php', 2234 + 'PhabricatorAuthFactorProviderDuoUsernamesTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php', 2231 2235 'PhabricatorAuthFactorProviderEditController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php', 2232 2236 'PhabricatorAuthFactorProviderEditEngine' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php', 2233 2237 'PhabricatorAuthFactorProviderEditor' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditor.php', ··· 2800 2804 'PhabricatorCountdownTransactionType' => 'applications/countdown/xaction/PhabricatorCountdownTransactionType.php', 2801 2805 'PhabricatorCountdownView' => 'applications/countdown/view/PhabricatorCountdownView.php', 2802 2806 'PhabricatorCountdownViewController' => 'applications/countdown/controller/PhabricatorCountdownViewController.php', 2807 + 'PhabricatorCredentialEditField' => 'applications/transactions/editfield/PhabricatorCredentialEditField.php', 2803 2808 'PhabricatorCursorPagedPolicyAwareQuery' => 'infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php', 2804 2809 'PhabricatorCustomField' => 'infrastructure/customfield/field/PhabricatorCustomField.php', 2805 2810 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource.php', ··· 2986 2991 'PhabricatorDraftEngine' => 'applications/transactions/draft/PhabricatorDraftEngine.php', 2987 2992 'PhabricatorDraftInterface' => 'applications/transactions/draft/PhabricatorDraftInterface.php', 2988 2993 'PhabricatorDrydockApplication' => 'applications/drydock/application/PhabricatorDrydockApplication.php', 2994 + 'PhabricatorDuoAuthFactor' => 'applications/auth/factor/PhabricatorDuoAuthFactor.php', 2989 2995 'PhabricatorDuoFuture' => 'applications/auth/future/PhabricatorDuoFuture.php', 2990 2996 'PhabricatorEdgeChangeRecord' => 'infrastructure/edges/util/PhabricatorEdgeChangeRecord.php', 2991 2997 'PhabricatorEdgeChangeRecordTestCase' => 'infrastructure/edges/__tests__/PhabricatorEdgeChangeRecordTestCase.php', ··· 7958 7964 'PhabricatorEditEngineMFAInterface', 7959 7965 ), 7960 7966 'PhabricatorAuthFactorProviderController' => 'PhabricatorAuthProviderController', 7967 + 'PhabricatorAuthFactorProviderDuoCredentialTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 7968 + 'PhabricatorAuthFactorProviderDuoEnrollTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 7969 + 'PhabricatorAuthFactorProviderDuoHostnameTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 7970 + 'PhabricatorAuthFactorProviderDuoUsernamesTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 7961 7971 'PhabricatorAuthFactorProviderEditController' => 'PhabricatorAuthFactorProviderController', 7962 7972 'PhabricatorAuthFactorProviderEditEngine' => 'PhabricatorEditEngine', 7963 7973 'PhabricatorAuthFactorProviderEditor' => 'PhabricatorApplicationTransactionEditor', ··· 8633 8643 'PhabricatorCountdownTransactionType' => 'PhabricatorModularTransactionType', 8634 8644 'PhabricatorCountdownView' => 'AphrontView', 8635 8645 'PhabricatorCountdownViewController' => 'PhabricatorCountdownController', 8646 + 'PhabricatorCredentialEditField' => 'PhabricatorEditField', 8636 8647 'PhabricatorCursorPagedPolicyAwareQuery' => 'PhabricatorPolicyAwareQuery', 8637 8648 'PhabricatorCustomField' => 'Phobject', 8638 8649 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'PhabricatorTypeaheadDatasource', ··· 8837 8848 'PhabricatorDraftDAO' => 'PhabricatorLiskDAO', 8838 8849 'PhabricatorDraftEngine' => 'Phobject', 8839 8850 'PhabricatorDrydockApplication' => 'PhabricatorApplication', 8851 + 'PhabricatorDuoAuthFactor' => 'PhabricatorAuthFactor', 8840 8852 'PhabricatorDuoFuture' => 'FutureProxy', 8841 8853 'PhabricatorEdgeChangeRecord' => 'Phobject', 8842 8854 'PhabricatorEdgeChangeRecordTestCase' => 'PhabricatorTestCase',
+10 -2
src/applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php
··· 93 93 } 94 94 95 95 protected function buildCustomEditFields($object) { 96 - $factor_name = $object->getFactor()->getFactorName(); 96 + $factor = $object->getFactor(); 97 + $factor_name = $factor->getFactorName(); 97 98 98 99 $status_map = PhabricatorAuthFactorProviderStatus::getMap(); 99 100 100 - return array( 101 + $fields = array( 101 102 id(new PhabricatorStaticEditField()) 102 103 ->setKey('displayType') 103 104 ->setLabel(pht('Factor Type')) ··· 120 121 ->setValue($object->getStatus()) 121 122 ->setOptions($status_map), 122 123 ); 124 + 125 + $factor_fields = $factor->newEditEngineFields($this, $object); 126 + foreach ($factor_fields as $field) { 127 + $fields[] = $field; 128 + } 129 + 130 + return $fields; 123 131 } 124 132 125 133 }
+13 -2
src/applications/auth/factor/PhabricatorAuthFactor.php
··· 74 74 return null; 75 75 } 76 76 77 + public function newEditEngineFields( 78 + PhabricatorEditEngine $engine, 79 + PhabricatorAuthFactorProvider $provider) { 80 + return array(); 81 + } 82 + 77 83 /** 78 84 * Is this a factor which depends on the user's contact number? 79 85 * ··· 331 337 332 338 333 339 final protected function loadMFASyncToken( 340 + PhabricatorAuthFactorProvider $provider, 334 341 AphrontRequest $request, 335 342 AphrontFormView $form, 336 343 PhabricatorUser $user) { ··· 397 404 ->setTokenCode($sync_key_digest) 398 405 ->setTokenExpires($now + $sync_ttl); 399 406 400 - $properties = $this->newMFASyncTokenProperties($user); 407 + $properties = $this->newMFASyncTokenProperties( 408 + $provider, 409 + $user); 401 410 402 411 foreach ($properties as $key => $value) { 403 412 $sync_token->setTemporaryTokenProperty($key, $value); ··· 411 420 return $sync_token; 412 421 } 413 422 414 - protected function newMFASyncTokenProperties(PhabricatorUser $user) { 423 + protected function newMFASyncTokenProperties( 424 + PhabricatorAuthFactorProvider $provider, 425 + PhabricatorUser $user) { 415 426 return array(); 416 427 } 417 428
+802
src/applications/auth/factor/PhabricatorDuoAuthFactor.php
··· 1 + <?php 2 + 3 + final 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('Use Phabricator Username'), 137 + 'email' => pht('Use Primary Email Address'), 138 + )), 139 + id(new PhabricatorSelectEditField()) 140 + ->setLabel(pht('Create Accounts')) 141 + ->setKey('duo.enroll') 142 + ->setValue($enroll) 143 + ->setTransactionType($xaction_enroll) 144 + ->setOptions( 145 + array( 146 + 'deny' => pht('Require Existing Duo Account'), 147 + 'allow' => pht('Create New Duo Account'), 148 + )), 149 + ); 150 + } 151 + 152 + 153 + public function processAddFactorForm( 154 + PhabricatorAuthFactorProvider $provider, 155 + AphrontFormView $form, 156 + AphrontRequest $request, 157 + PhabricatorUser $user) { 158 + 159 + $token = $this->loadMFASyncToken($provider, $request, $form, $user); 160 + 161 + $enroll = $token->getTemporaryTokenProperty('duo.enroll'); 162 + $duo_id = $token->getTemporaryTokenProperty('duo.user-id'); 163 + $duo_uri = $token->getTemporaryTokenProperty('duo.uri'); 164 + $duo_user = $token->getTemporaryTokenProperty('duo.username'); 165 + 166 + $is_external = ($enroll === 'external'); 167 + $is_auto = ($enroll === 'auto'); 168 + $is_blocked = ($enroll === 'blocked'); 169 + 170 + if (!$token->getIsNewTemporaryToken()) { 171 + if ($is_auto) { 172 + return $this->newDuoConfig($user, $duo_user); 173 + } else if ($is_external || $is_blocked) { 174 + $parameters = array( 175 + 'username' => $duo_user, 176 + ); 177 + 178 + $result = $this->newDuoFuture($provider) 179 + ->setMethod('preauth', $parameters) 180 + ->resolve(); 181 + 182 + $result_code = $result['response']['result']; 183 + switch ($result_code) { 184 + case 'auth': 185 + case 'allow': 186 + return $this->newDuoConfig($user, $duo_user); 187 + case 'enroll': 188 + if ($is_blocked) { 189 + // We'll render an equivalent static control below, so skip 190 + // rendering here. We explicitly don't want to give the user 191 + // an enroll workflow. 192 + break; 193 + } 194 + 195 + $duo_uri = $result['response']['enroll_portal_url']; 196 + 197 + $waiting_icon = id(new PHUIIconView()) 198 + ->setIcon('fa-mobile', 'red'); 199 + 200 + $waiting_control = id(new PHUIFormTimerControl()) 201 + ->setIcon($waiting_icon) 202 + ->setError(pht('Not Complete')) 203 + ->appendChild( 204 + pht( 205 + 'You have not completed Duo enrollment yet. '. 206 + 'Complete enrollment, then click continue.')); 207 + 208 + $form->appendControl($waiting_control); 209 + break; 210 + default: 211 + case 'deny': 212 + break; 213 + } 214 + } else { 215 + $parameters = array( 216 + 'user_id' => $duo_id, 217 + 'activation_code' => $duo_uri, 218 + ); 219 + 220 + $future = $this->newDuoFuture($provider) 221 + ->setMethod('enroll_status', $parameters); 222 + 223 + $result = $future->resolve(); 224 + $response = $result['response']; 225 + 226 + switch ($response) { 227 + case 'success': 228 + return $this->newDuoConfig($user, $duo_user); 229 + case 'waiting': 230 + $waiting_icon = id(new PHUIIconView()) 231 + ->setIcon('fa-mobile', 'red'); 232 + 233 + $waiting_control = id(new PHUIFormTimerControl()) 234 + ->setIcon($waiting_icon) 235 + ->setError(pht('Not Complete')) 236 + ->appendChild( 237 + pht( 238 + 'You have not activated this enrollment in the Duo '. 239 + 'application on your phone yet. Complete activation, then '. 240 + 'click continue.')); 241 + 242 + $form->appendControl($waiting_control); 243 + break; 244 + case 'invalid': 245 + default: 246 + throw new Exception( 247 + pht( 248 + 'This Duo enrollment attempt is invalid or has '. 249 + 'expired ("%s"). Cancel the workflow and try again.', 250 + $response)); 251 + } 252 + } 253 + } 254 + 255 + if ($is_blocked) { 256 + $blocked_icon = id(new PHUIIconView()) 257 + ->setIcon('fa-times', 'red'); 258 + 259 + $blocked_control = id(new PHUIFormTimerControl()) 260 + ->setIcon($blocked_icon) 261 + ->appendChild( 262 + pht( 263 + 'Your Duo account ("%s") has not completed Duo enrollment. '. 264 + 'Check your email and complete enrollment to continue.', 265 + phutil_tag('strong', array(), $duo_user))); 266 + 267 + $form->appendControl($blocked_control); 268 + } else if ($is_auto) { 269 + $auto_icon = id(new PHUIIconView()) 270 + ->setIcon('fa-check', 'green'); 271 + 272 + $auto_control = id(new PHUIFormTimerControl()) 273 + ->setIcon($auto_icon) 274 + ->appendChild( 275 + pht( 276 + 'Duo account ("%s") is fully enrolled.', 277 + phutil_tag('strong', array(), $duo_user))); 278 + 279 + $form->appendControl($auto_control); 280 + } else { 281 + $duo_button = phutil_tag( 282 + 'a', 283 + array( 284 + 'href' => $duo_uri, 285 + 'class' => 'button button-grey', 286 + 'target' => ($is_external ? '_blank' : null), 287 + ), 288 + pht('Enroll Duo Account: %s', $duo_user)); 289 + 290 + $duo_button = phutil_tag( 291 + 'div', 292 + array( 293 + 'class' => 'mfa-form-enroll-button', 294 + ), 295 + $duo_button); 296 + 297 + if ($is_external) { 298 + $form->appendRemarkupInstructions( 299 + pht( 300 + 'Complete enrolling your phone with Duo:')); 301 + 302 + $form->appendControl( 303 + id(new AphrontFormMarkupControl()) 304 + ->setValue($duo_button)); 305 + } else { 306 + 307 + $form->appendRemarkupInstructions( 308 + pht( 309 + 'Scan this QR code with the Duo application on your mobile '. 310 + 'phone:')); 311 + 312 + 313 + $qr_code = $this->newQRCode($duo_uri); 314 + $form->appendChild($qr_code); 315 + 316 + $form->appendRemarkupInstructions( 317 + pht( 318 + 'If you are currently using your phone to view this page, '. 319 + 'click this button to open the Duo application:')); 320 + 321 + $form->appendControl( 322 + id(new AphrontFormMarkupControl()) 323 + ->setValue($duo_button)); 324 + } 325 + 326 + $form->appendRemarkupInstructions( 327 + pht( 328 + 'Once you have completed setup on your phone, click continue.')); 329 + } 330 + } 331 + 332 + 333 + protected function newMFASyncTokenProperties( 334 + PhabricatorAuthFactorProvider $provider, 335 + PhabricatorUser $user) { 336 + 337 + $duo_user = $this->getDuoUsername($provider, $user); 338 + 339 + // Duo automatically normalizes usernames to lowercase. Just do that here 340 + // so that our value agrees more closely with Duo. 341 + $duo_user = phutil_utf8_strtolower($duo_user); 342 + 343 + $parameters = array( 344 + 'username' => $duo_user, 345 + ); 346 + 347 + $result = $this->newDuoFuture($provider) 348 + ->setMethod('preauth', $parameters) 349 + ->resolve(); 350 + 351 + $external_uri = null; 352 + $result_code = $result['response']['result']; 353 + switch ($result_code) { 354 + case 'auth': 355 + case 'allow': 356 + // If the user already has a Duo account, they don't need to do 357 + // anything. 358 + return array( 359 + 'duo.enroll' => 'auto', 360 + 'duo.username' => $duo_user, 361 + ); 362 + case 'enroll': 363 + if (!$this->shouldAllowDuoEnrollment($provider)) { 364 + return array( 365 + 'duo.enroll' => 'blocked', 366 + 'duo.username' => $duo_user, 367 + ); 368 + } 369 + 370 + $external_uri = $result['response']['enroll_portal_url']; 371 + 372 + // Otherwise, enrollment is permitted so we're going to continue. 373 + break; 374 + default: 375 + case 'deny': 376 + return $this->newResult() 377 + ->setIsError(true) 378 + ->setErrorMessage( 379 + pht('Your account is not permitted to access this system.')); 380 + } 381 + 382 + // Duo's "/enroll" API isn't repeatable for the same username. If we're 383 + // the first call, great: we can do inline enrollment, which is way more 384 + // user friendly. Otherwise, we have to send the user on an adventure. 385 + 386 + $parameters = array( 387 + 'username' => $duo_user, 388 + 'valid_secs' => phutil_units('1 hour in seconds'), 389 + ); 390 + 391 + try { 392 + $result = $this->newDuoFuture($provider) 393 + ->setMethod('enroll', $parameters) 394 + ->resolve(); 395 + } catch (HTTPFutureHTTPResponseStatus $ex) { 396 + return array( 397 + 'duo.enroll' => 'external', 398 + 'duo.username' => $duo_user, 399 + 'duo.uri' => $external_uri, 400 + ); 401 + } 402 + 403 + return array( 404 + 'duo.enroll' => 'inline', 405 + 'duo.uri' => $result['response']['activation_code'], 406 + 'duo.username' => $duo_user, 407 + 'duo.user-id' => $result['response']['user_id'], 408 + ); 409 + } 410 + 411 + protected function newIssuedChallenges( 412 + PhabricatorAuthFactorConfig $config, 413 + PhabricatorUser $viewer, 414 + array $challenges) { 415 + 416 + // If we already issued a valid challenge for this workflow and session, 417 + // don't issue a new one. 418 + 419 + $challenge = $this->getChallengeForCurrentContext( 420 + $config, 421 + $viewer, 422 + $challenges); 423 + if ($challenge) { 424 + return array(); 425 + } 426 + 427 + if (!$this->hasCSRF($config)) { 428 + return $this->newResult() 429 + ->setIsContinue(true) 430 + ->setErrorMessage( 431 + pht( 432 + 'An authorization request will be pushed to the Duo '. 433 + 'application on your phone.')); 434 + } 435 + 436 + $provider = $config->getFactorProvider(); 437 + 438 + // Otherwise, issue a new challenge. 439 + $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username'); 440 + 441 + $parameters = array( 442 + 'username' => $duo_user, 443 + ); 444 + 445 + $response = $this->newDuoFuture($provider) 446 + ->setMethod('preauth', $parameters) 447 + ->resolve(); 448 + $response = $response['response']; 449 + 450 + $next_step = $response['result']; 451 + $status_message = $response['status_msg']; 452 + switch ($next_step) { 453 + case 'auth': 454 + // We're good to go. 455 + break; 456 + case 'allow': 457 + // Duo is telling us to bypass MFA. For now, refuse. 458 + return $this->newResult() 459 + ->setIsError(true) 460 + ->setErrorMessage( 461 + pht( 462 + 'Duo is not requiring a challenge, which defeats the '. 463 + 'purpose of MFA. Duo must be configured to challenge you.')); 464 + case 'enroll': 465 + return $this->newResult() 466 + ->setIsError(true) 467 + ->setErrorMessage( 468 + pht( 469 + 'Your Duo account ("%s") requires enrollment. Contact your '. 470 + 'Duo administrator for help. Duo status message: %s', 471 + $duo_user, 472 + $status_message)); 473 + case 'deny': 474 + default: 475 + return $this->newResult() 476 + ->setIsError(true) 477 + ->setErrorMessage( 478 + pht( 479 + 'Duo has denied you access. Duo status message ("%s"): %s', 480 + $next_step, 481 + $status_message)); 482 + } 483 + 484 + $has_push = false; 485 + $devices = $response['devices']; 486 + foreach ($devices as $device) { 487 + $capabilities = array_fuse($device['capabilities']); 488 + if (isset($capabilities['push'])) { 489 + $has_push = true; 490 + break; 491 + } 492 + } 493 + 494 + if (!$has_push) { 495 + return $this->newResult() 496 + ->setIsError(true) 497 + ->setErrorMessage( 498 + pht( 499 + 'This factor has been removed from your device, so Phabricator '. 500 + 'can not send you a challenge. To continue, an administrator '. 501 + 'must strip this factor from your account.')); 502 + } 503 + 504 + $push_info = array( 505 + pht('Domain') => $this->getInstallDisplayName(), 506 + ); 507 + foreach ($push_info as $k => $v) { 508 + $push_info[$k] = rawurlencode($k).'='.rawurlencode($v); 509 + } 510 + $push_info = implode('&', $push_info); 511 + 512 + $parameters = array( 513 + 'username' => $duo_user, 514 + 'factor' => 'push', 515 + 'async' => '1', 516 + 517 + // Duo allows us to specify a device, or to pass "auto" to have it pick 518 + // the first one. For now, just let it pick. 519 + 'device' => 'auto', 520 + 521 + // This is a hard-coded prefix for the word "... request" in the Duo UI, 522 + // which defaults to "Login". We could pass richer information from 523 + // workflows here, but it's not very flexible anyway. 524 + 'type' => 'Authentication', 525 + 526 + 'display_username' => $viewer->getUsername(), 527 + 'pushinfo' => $push_info, 528 + ); 529 + 530 + $result = $this->newDuoFuture($provider) 531 + ->setMethod('auth', $parameters) 532 + ->resolve(); 533 + 534 + $duo_xaction = $result['response']['txid']; 535 + 536 + // The Duo push timeout is 60 seconds. Set our challenge to expire slightly 537 + // more quickly so that we'll re-issue a new challenge before Duo times out. 538 + // This should keep users away from a dead-end where they can't respond to 539 + // Duo but Phabricator won't issue a new challenge yet. 540 + $ttl_seconds = 55; 541 + 542 + return array( 543 + $this->newChallenge($config, $viewer) 544 + ->setChallengeKey($duo_xaction) 545 + ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), 546 + ); 547 + } 548 + 549 + protected function newResultFromIssuedChallenges( 550 + PhabricatorAuthFactorConfig $config, 551 + PhabricatorUser $viewer, 552 + array $challenges) { 553 + 554 + $challenge = $this->getChallengeForCurrentContext( 555 + $config, 556 + $viewer, 557 + $challenges); 558 + 559 + if ($challenge->getIsAnsweredChallenge()) { 560 + return $this->newResult() 561 + ->setAnsweredChallenge($challenge); 562 + } 563 + 564 + $provider = $config->getFactorProvider(); 565 + $duo_xaction = $challenge->getChallengeKey(); 566 + 567 + $parameters = array( 568 + 'txid' => $duo_xaction, 569 + ); 570 + 571 + // This endpoint always long-polls, so use a timeout to force it to act 572 + // more asynchronously. 573 + try { 574 + $result = $this->newDuoFuture($provider) 575 + ->setHTTPMethod('GET') 576 + ->setMethod('auth_status', $parameters) 577 + ->setTimeout(5) 578 + ->resolve(); 579 + 580 + $state = $result['response']['result']; 581 + $status = $result['response']['status']; 582 + } catch (HTTPFutureCURLResponseStatus $exception) { 583 + if ($exception->isTimeout()) { 584 + $state = 'waiting'; 585 + $status = 'poll'; 586 + } else { 587 + throw $exception; 588 + } 589 + } 590 + 591 + $now = PhabricatorTime::getNow(); 592 + 593 + switch ($state) { 594 + case 'allow': 595 + $ttl = PhabricatorTime::getNow() 596 + + phutil_units('15 minutes in seconds'); 597 + 598 + $challenge 599 + ->markChallengeAsAnswered($ttl); 600 + 601 + return $this->newResult() 602 + ->setAnsweredChallenge($challenge); 603 + case 'waiting': 604 + // No result yet, we'll render a default state later on. 605 + break; 606 + default: 607 + case 'deny': 608 + if ($status === 'timeout') { 609 + return $this->newResult() 610 + ->setIsError(true) 611 + ->setErrorMessage( 612 + pht( 613 + 'This request has timed out because you took too long to '. 614 + 'respond.')); 615 + } else { 616 + $wait_duration = ($challenge->getChallengeTTL() - $now) + 1; 617 + 618 + return $this->newResult() 619 + ->setIsWait(true) 620 + ->setErrorMessage( 621 + pht( 622 + 'You denied this request. Wait %s second(s) to try again.', 623 + new PhutilNumber($wait_duration))); 624 + } 625 + break; 626 + } 627 + 628 + return null; 629 + } 630 + 631 + public function renderValidateFactorForm( 632 + PhabricatorAuthFactorConfig $config, 633 + AphrontFormView $form, 634 + PhabricatorUser $viewer, 635 + PhabricatorAuthFactorResult $result) { 636 + 637 + $control = $this->newAutomaticControl($result); 638 + if (!$control) { 639 + $result = $this->newResult() 640 + ->setIsContinue(true) 641 + ->setErrorMessage( 642 + pht( 643 + 'A challenge has been sent to your phone. Open the Duo '. 644 + 'application and confirm the challenge, then continue.')); 645 + $control = $this->newAutomaticControl($result); 646 + } 647 + 648 + $control 649 + ->setLabel(pht('Duo')) 650 + ->setCaption(pht('Factor Name: %s', $config->getFactorName())); 651 + 652 + $form->appendChild($control); 653 + } 654 + 655 + public function getRequestHasChallengeResponse( 656 + PhabricatorAuthFactorConfig $config, 657 + AphrontRequest $request) { 658 + $value = $this->getChallengeResponseFromRequest($config, $request); 659 + return (bool)strlen($value); 660 + } 661 + 662 + protected function newResultFromChallengeResponse( 663 + PhabricatorAuthFactorConfig $config, 664 + PhabricatorUser $viewer, 665 + AphrontRequest $request, 666 + array $challenges) { 667 + 668 + $challenge = $this->getChallengeForCurrentContext( 669 + $config, 670 + $viewer, 671 + $challenges); 672 + 673 + $code = $this->getChallengeResponseFromRequest( 674 + $config, 675 + $request); 676 + 677 + $result = $this->newResult() 678 + ->setValue($code); 679 + 680 + if ($challenge->getIsAnsweredChallenge()) { 681 + return $result->setAnsweredChallenge($challenge); 682 + } 683 + 684 + if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { 685 + $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); 686 + 687 + $challenge 688 + ->markChallengeAsAnswered($ttl); 689 + 690 + return $result->setAnsweredChallenge($challenge); 691 + } 692 + 693 + if (strlen($code)) { 694 + $error_message = pht('Invalid'); 695 + } else { 696 + $error_message = pht('Required'); 697 + } 698 + 699 + $result->setErrorMessage($error_message); 700 + 701 + return $result; 702 + } 703 + 704 + private function newDuoFuture(PhabricatorAuthFactorProvider $provider) { 705 + $credential_phid = $provider->getAuthFactorProviderProperty( 706 + self::PROP_CREDENTIAL); 707 + 708 + $omnipotent = PhabricatorUser::getOmnipotentUser(); 709 + 710 + $credential = id(new PassphraseCredentialQuery()) 711 + ->setViewer($omnipotent) 712 + ->withPHIDs(array($credential_phid)) 713 + ->needSecrets(true) 714 + ->executeOne(); 715 + if (!$credential) { 716 + throw new Exception( 717 + pht( 718 + 'Unable to load Duo API credential ("%s").', 719 + $credential_phid)); 720 + } 721 + 722 + $duo_key = $credential->getUsername(); 723 + $duo_secret = $credential->getSecret(); 724 + if (!$duo_secret) { 725 + throw new Exception( 726 + pht( 727 + 'Duo API credential ("%s") has no secret key.', 728 + $credential_phid)); 729 + } 730 + 731 + $duo_host = $provider->getAuthFactorProviderProperty( 732 + self::PROP_HOSTNAME); 733 + self::requireDuoAPIHostname($duo_host); 734 + 735 + return id(new PhabricatorDuoFuture()) 736 + ->setIntegrationKey($duo_key) 737 + ->setSecretKey($duo_secret) 738 + ->setAPIHostname($duo_host) 739 + ->setTimeout(10) 740 + ->setHTTPMethod('POST'); 741 + } 742 + 743 + private function getDuoUsername( 744 + PhabricatorAuthFactorProvider $provider, 745 + PhabricatorUser $user) { 746 + 747 + $mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); 748 + switch ($mode) { 749 + case 'username': 750 + return $user->getUsername(); 751 + case 'email': 752 + return $user->loadPrimaryEmailAddress(); 753 + default: 754 + throw new Exception( 755 + pht( 756 + 'Duo username pairing mode ("%s") is not supported.', 757 + $mode)); 758 + } 759 + } 760 + 761 + private function shouldAllowDuoEnrollment( 762 + PhabricatorAuthFactorProvider $provider) { 763 + 764 + $mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); 765 + switch ($mode) { 766 + case 'deny': 767 + return false; 768 + case 'allow': 769 + return true; 770 + default: 771 + throw new Exception( 772 + pht( 773 + 'Duo enrollment mode ("%s") is not supported.', 774 + $mode)); 775 + } 776 + } 777 + 778 + private function newDuoConfig(PhabricatorUser $user, $duo_user) { 779 + $config_properties = array( 780 + 'duo.username' => $duo_user, 781 + ); 782 + 783 + $config = $this->newConfigForUser($user) 784 + ->setFactorName(pht('Duo (%s)', $duo_user)) 785 + ->setProperties($config_properties); 786 + 787 + return $config; 788 + } 789 + 790 + public static function requireDuoAPIHostname($hostname) { 791 + if (preg_match('/\.duosecurity\.com\z/', $hostname)) { 792 + return; 793 + } 794 + 795 + throw new Exception( 796 + pht( 797 + 'Duo API hostname ("%s") is invalid, hostname must be '. 798 + '"*.duosecurity.com".', 799 + $hostname)); 800 + } 801 + 802 + }
+5 -2
src/applications/auth/factor/PhabricatorSMSAuthFactor.php
··· 140 140 AphrontRequest $request, 141 141 PhabricatorUser $user) { 142 142 143 - $token = $this->loadMFASyncToken($request, $form, $user); 143 + $token = $this->loadMFASyncToken($provider, $request, $form, $user); 144 144 $code = $request->getStr('sms.code'); 145 145 146 146 $e_code = true; ··· 364 364 return head($contact_numbers); 365 365 } 366 366 367 - protected function newMFASyncTokenProperties(PhabricatorUser $user) { 367 + protected function newMFASyncTokenProperties( 368 + PhabricatorAuthFactorProvider $providerr, 369 + PhabricatorUser $user) { 370 + 368 371 $sms_code = $this->newSMSChallengeCode(); 369 372 370 373 $envelope = new PhutilOpaqueEnvelope($sms_code);
+4 -1
src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
··· 58 58 PhabricatorUser $user) { 59 59 60 60 $sync_token = $this->loadMFASyncToken( 61 + $provider, 61 62 $request, 62 63 $form, 63 64 $user); ··· 440 441 return null; 441 442 } 442 443 443 - protected function newMFASyncTokenProperties(PhabricatorUser $user) { 444 + protected function newMFASyncTokenProperties( 445 + PhabricatorAuthFactorProvider $providerr, 446 + PhabricatorUser $user) { 444 447 return array( 445 448 'secret' => self::generateNewTOTPKey(), 446 449 );
+65
src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthFactorProviderDuoCredentialTransaction 4 + extends PhabricatorAuthFactorProviderTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'duo.credential'; 7 + 8 + public function generateOldValue($object) { 9 + $key = PhabricatorDuoAuthFactor::PROP_CREDENTIAL; 10 + return $object->getAuthFactorProviderProperty($key); 11 + } 12 + 13 + public function applyInternalEffects($object, $value) { 14 + $key = PhabricatorDuoAuthFactor::PROP_CREDENTIAL; 15 + $object->setAuthFactorProviderProperty($key, $value); 16 + } 17 + 18 + public function getTitle() { 19 + return pht( 20 + '%s changed the credential for this provider from %s to %s.', 21 + $this->renderAuthor(), 22 + $this->renderOldHandle(), 23 + $this->renderNewHandle()); 24 + } 25 + 26 + public function validateTransactions($object, array $xactions) { 27 + $actor = $this->getActor(); 28 + $errors = array(); 29 + 30 + $old_value = $this->generateOldValue($object); 31 + if ($this->isEmptyTextTransaction($old_value, $xactions)) { 32 + $errors[] = $this->newRequiredError( 33 + pht('Duo providers must have an API credential.')); 34 + } 35 + 36 + foreach ($xactions as $xaction) { 37 + $new_value = $xaction->getNewValue(); 38 + 39 + if (!strlen($new_value)) { 40 + continue; 41 + } 42 + 43 + if ($new_value === $old_value) { 44 + continue; 45 + } 46 + 47 + $credential = id(new PassphraseCredentialQuery()) 48 + ->setViewer($actor) 49 + ->withIsDestroyed(false) 50 + ->withPHIDs(array($new_value)) 51 + ->executeOne(); 52 + if (!$credential) { 53 + $errors[] = $this->newInvalidError( 54 + pht( 55 + 'Credential ("%s") is not valid.', 56 + $new_value), 57 + $xaction); 58 + continue; 59 + } 60 + } 61 + 62 + return $errors; 63 + } 64 + 65 + }
+26
src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthFactorProviderDuoEnrollTransaction 4 + extends PhabricatorAuthFactorProviderTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'duo.enroll'; 7 + 8 + public function generateOldValue($object) { 9 + $key = PhabricatorDuoAuthFactor::PROP_ENROLL; 10 + return $object->getAuthFactorProviderProperty($key); 11 + } 12 + 13 + public function applyInternalEffects($object, $value) { 14 + $key = PhabricatorDuoAuthFactor::PROP_ENROLL; 15 + $object->setAuthFactorProviderProperty($key, $value); 16 + } 17 + 18 + public function getTitle() { 19 + return pht( 20 + '%s changed the enrollment policy for this provider from %s to %s.', 21 + $this->renderAuthor(), 22 + $this->renderOldValue(), 23 + $this->renderNewValue()); 24 + } 25 + 26 + }
+59
src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthFactorProviderDuoHostnameTransaction 4 + extends PhabricatorAuthFactorProviderTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'duo.hostname'; 7 + 8 + public function generateOldValue($object) { 9 + $key = PhabricatorDuoAuthFactor::PROP_HOSTNAME; 10 + return $object->getAuthFactorProviderProperty($key); 11 + } 12 + 13 + public function applyInternalEffects($object, $value) { 14 + $key = PhabricatorDuoAuthFactor::PROP_HOSTNAME; 15 + $object->setAuthFactorProviderProperty($key, $value); 16 + } 17 + 18 + public function getTitle() { 19 + return pht( 20 + '%s changed the hostname for this provider from %s to %s.', 21 + $this->renderAuthor(), 22 + $this->renderOldValue(), 23 + $this->renderNewValue()); 24 + } 25 + 26 + public function validateTransactions($object, array $xactions) { 27 + $errors = array(); 28 + 29 + $old_value = $this->generateOldValue($object); 30 + if ($this->isEmptyTextTransaction($old_value, $xactions)) { 31 + $errors[] = $this->newRequiredError( 32 + pht('Duo providers must have an API hostname.')); 33 + } 34 + 35 + foreach ($xactions as $xaction) { 36 + $new_value = $xaction->getNewValue(); 37 + 38 + if (!strlen($new_value)) { 39 + continue; 40 + } 41 + 42 + if ($new_value === $old_value) { 43 + continue; 44 + } 45 + 46 + try { 47 + PhabricatorDuoAuthFactor::requireDuoAPIHostname($new_value); 48 + } catch (Exception $ex) { 49 + $errors[] = $this->newInvalidError( 50 + $ex->getMessage(), 51 + $xaction); 52 + continue; 53 + } 54 + } 55 + 56 + return $errors; 57 + } 58 + 59 + }
+26
src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthFactorProviderDuoUsernamesTransaction 4 + extends PhabricatorAuthFactorProviderTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'duo.usernames'; 7 + 8 + public function generateOldValue($object) { 9 + $key = PhabricatorDuoAuthFactor::PROP_USERNAMES; 10 + return $object->getAuthFactorProviderProperty($key); 11 + } 12 + 13 + public function applyInternalEffects($object, $value) { 14 + $key = PhabricatorDuoAuthFactor::PROP_USERNAMES; 15 + $object->setAuthFactorProviderProperty($key, $value); 16 + } 17 + 18 + public function getTitle() { 19 + return pht( 20 + '%s changed the username policy for this provider from %s to %s.', 21 + $this->renderAuthor(), 22 + $this->renderOldValue(), 23 + $this->renderNewValue()); 24 + } 25 + 26 + }
+43
src/applications/transactions/editfield/PhabricatorCredentialEditField.php
··· 1 + <?php 2 + 3 + final class PhabricatorCredentialEditField 4 + extends PhabricatorEditField { 5 + 6 + private $credentialType; 7 + private $credentials; 8 + 9 + public function setCredentialType($credential_type) { 10 + $this->credentialType = $credential_type; 11 + return $this; 12 + } 13 + 14 + public function getCredentialType() { 15 + return $this->credentialType; 16 + } 17 + 18 + public function setCredentials(array $credentials) { 19 + $this->credentials = $credentials; 20 + return $this; 21 + } 22 + 23 + public function getCredentials() { 24 + return $this->credentials; 25 + } 26 + 27 + protected function newControl() { 28 + $control = id(new PassphraseCredentialControl()) 29 + ->setCredentialType($this->getCredentialType()) 30 + ->setOptions($this->getCredentials()); 31 + 32 + return $control; 33 + } 34 + 35 + protected function newHTTPParameterType() { 36 + return new AphrontPHIDHTTPParameterType(); 37 + } 38 + 39 + protected function newConduitParameterType() { 40 + return new ConduitPHIDParameterType(); 41 + } 42 + 43 + }
-1
src/applications/transactions/editfield/PhabricatorSpaceEditField.php
··· 28 28 return new ConduitPHIDParameterType(); 29 29 } 30 30 31 - 32 31 public function shouldReadValueFromRequest() { 33 32 return $this->getPolicyField()->shouldReadValueFromRequest(); 34 33 }
+11
src/docs/user/userguide/multi_factor_auth.diviner
··· 109 109 details, see: <https://phurl.io/u/sms>. 110 110 111 111 112 + Factor: Duo 113 + =========== 114 + 115 + This factor supports integration with [[ https://duo.com/ | Duo Security ]], a 116 + third-party authentication service popular with enterprises that have a lot of 117 + policies to enforce. 118 + 119 + To use Duo, you'll install the Duo application on your phone. When you try 120 + to take a sensitive action, you'll be asked to confirm it in the application. 121 + 122 + 112 123 Administration: Configuration 113 124 ============================= 114 125