Path: blob/master/src/applications/auth/factor/PhabricatorDuoAuthFactor.php
12256 views
<?php12final class PhabricatorDuoAuthFactor3extends PhabricatorAuthFactor {45const PROP_CREDENTIAL = 'duo.credentialPHID';6const PROP_ENROLL = 'duo.enroll';7const PROP_USERNAMES = 'duo.usernames';8const PROP_HOSTNAME = 'duo.hostname';910public function getFactorKey() {11return 'duo';12}1314public function getFactorName() {15return pht('Duo Security');16}1718public function getFactorShortName() {19return pht('Duo');20}2122public function getFactorCreateHelp() {23return pht('Support for Duo push authentication.');24}2526public function getFactorDescription() {27return pht(28'When you need to authenticate, a request will be pushed to the '.29'Duo application on your phone.');30}3132public function getEnrollDescription(33PhabricatorAuthFactorProvider $provider,34PhabricatorUser $user) {35return pht(36'To add a Duo factor, first download and install the Duo application '.37'on your phone. Once you have launched the application and are ready '.38'to perform setup, click continue.');39}4041public function canCreateNewConfiguration(42PhabricatorAuthFactorProvider $provider,43PhabricatorUser $user) {4445if ($this->loadConfigurationsForProvider($provider, $user)) {46return false;47}4849return true;50}5152public function getConfigurationCreateDescription(53PhabricatorAuthFactorProvider $provider,54PhabricatorUser $user) {5556$messages = array();5758if ($this->loadConfigurationsForProvider($provider, $user)) {59$messages[] = id(new PHUIInfoView())60->setSeverity(PHUIInfoView::SEVERITY_WARNING)61->setErrors(62array(63pht(64'You already have Duo authentication attached to your account '.65'for this provider.'),66));67}6869return $messages;70}7172public function getConfigurationListDetails(73PhabricatorAuthFactorConfig $config,74PhabricatorAuthFactorProvider $provider,75PhabricatorUser $viewer) {7677$duo_user = $config->getAuthFactorConfigProperty('duo.username');7879return pht('Duo Username: %s', $duo_user);80}818283public function newEditEngineFields(84PhabricatorEditEngine $engine,85PhabricatorAuthFactorProvider $provider) {8687$viewer = $engine->getViewer();8889$credential_phid = $provider->getAuthFactorProviderProperty(90self::PROP_CREDENTIAL);9192$hostname = $provider->getAuthFactorProviderProperty(self::PROP_HOSTNAME);93$usernames = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES);94$enroll = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL);9596$credential_type = PassphrasePasswordCredentialType::CREDENTIAL_TYPE;97$provides_type = PassphrasePasswordCredentialType::PROVIDES_TYPE;9899$credentials = id(new PassphraseCredentialQuery())100->setViewer($viewer)101->withIsDestroyed(false)102->withProvidesTypes(array($provides_type))103->execute();104105$xaction_hostname =106PhabricatorAuthFactorProviderDuoHostnameTransaction::TRANSACTIONTYPE;107$xaction_credential =108PhabricatorAuthFactorProviderDuoCredentialTransaction::TRANSACTIONTYPE;109$xaction_usernames =110PhabricatorAuthFactorProviderDuoUsernamesTransaction::TRANSACTIONTYPE;111$xaction_enroll =112PhabricatorAuthFactorProviderDuoEnrollTransaction::TRANSACTIONTYPE;113114return array(115id(new PhabricatorTextEditField())116->setLabel(pht('Duo API Hostname'))117->setKey('duo.hostname')118->setValue($hostname)119->setTransactionType($xaction_hostname)120->setIsRequired(true),121id(new PhabricatorCredentialEditField())122->setLabel(pht('Duo API Credential'))123->setKey('duo.credential')124->setValue($credential_phid)125->setTransactionType($xaction_credential)126->setCredentialType($credential_type)127->setCredentials($credentials),128id(new PhabricatorSelectEditField())129->setLabel(pht('Duo Username'))130->setKey('duo.usernames')131->setValue($usernames)132->setTransactionType($xaction_usernames)133->setOptions(134array(135'username' => pht(136'Use %s Username',137PlatformSymbols::getPlatformServerName()),138'email' => pht('Use Primary Email Address'),139)),140id(new PhabricatorSelectEditField())141->setLabel(pht('Create Accounts'))142->setKey('duo.enroll')143->setValue($enroll)144->setTransactionType($xaction_enroll)145->setOptions(146array(147'deny' => pht('Require Existing Duo Account'),148'allow' => pht('Create New Duo Account'),149)),150);151}152153154public function processAddFactorForm(155PhabricatorAuthFactorProvider $provider,156AphrontFormView $form,157AphrontRequest $request,158PhabricatorUser $user) {159160$token = $this->loadMFASyncToken($provider, $request, $form, $user);161if ($this->isAuthResult($token)) {162$form->appendChild($this->newAutomaticControl($token));163return;164}165166$enroll = $token->getTemporaryTokenProperty('duo.enroll');167$duo_id = $token->getTemporaryTokenProperty('duo.user-id');168$duo_uri = $token->getTemporaryTokenProperty('duo.uri');169$duo_user = $token->getTemporaryTokenProperty('duo.username');170171$is_external = ($enroll === 'external');172$is_auto = ($enroll === 'auto');173$is_blocked = ($enroll === 'blocked');174175if (!$token->getIsNewTemporaryToken()) {176if ($is_auto) {177return $this->newDuoConfig($user, $duo_user);178} else if ($is_external || $is_blocked) {179$parameters = array(180'username' => $duo_user,181);182183$result = $this->newDuoFuture($provider)184->setMethod('preauth', $parameters)185->resolve();186187$result_code = $result['response']['result'];188switch ($result_code) {189case 'auth':190case 'allow':191return $this->newDuoConfig($user, $duo_user);192case 'enroll':193if ($is_blocked) {194// We'll render an equivalent static control below, so skip195// rendering here. We explicitly don't want to give the user196// an enroll workflow.197break;198}199200$duo_uri = $result['response']['enroll_portal_url'];201202$waiting_icon = id(new PHUIIconView())203->setIcon('fa-mobile', 'red');204205$waiting_control = id(new PHUIFormTimerControl())206->setIcon($waiting_icon)207->setError(pht('Not Complete'))208->appendChild(209pht(210'You have not completed Duo enrollment yet. '.211'Complete enrollment, then click continue.'));212213$form->appendControl($waiting_control);214break;215default:216case 'deny':217break;218}219} else {220$parameters = array(221'user_id' => $duo_id,222'activation_code' => $duo_uri,223);224225$future = $this->newDuoFuture($provider)226->setMethod('enroll_status', $parameters);227228$result = $future->resolve();229$response = $result['response'];230231switch ($response) {232case 'success':233return $this->newDuoConfig($user, $duo_user);234case 'waiting':235$waiting_icon = id(new PHUIIconView())236->setIcon('fa-mobile', 'red');237238$waiting_control = id(new PHUIFormTimerControl())239->setIcon($waiting_icon)240->setError(pht('Not Complete'))241->appendChild(242pht(243'You have not activated this enrollment in the Duo '.244'application on your phone yet. Complete activation, then '.245'click continue.'));246247$form->appendControl($waiting_control);248break;249case 'invalid':250default:251throw new Exception(252pht(253'This Duo enrollment attempt is invalid or has '.254'expired ("%s"). Cancel the workflow and try again.',255$response));256}257}258}259260if ($is_blocked) {261$blocked_icon = id(new PHUIIconView())262->setIcon('fa-times', 'red');263264$blocked_control = id(new PHUIFormTimerControl())265->setIcon($blocked_icon)266->appendChild(267pht(268'Your Duo account ("%s") has not completed Duo enrollment. '.269'Check your email and complete enrollment to continue.',270phutil_tag('strong', array(), $duo_user)));271272$form->appendControl($blocked_control);273} else if ($is_auto) {274$auto_icon = id(new PHUIIconView())275->setIcon('fa-check', 'green');276277$auto_control = id(new PHUIFormTimerControl())278->setIcon($auto_icon)279->appendChild(280pht(281'Duo account ("%s") is fully enrolled.',282phutil_tag('strong', array(), $duo_user)));283284$form->appendControl($auto_control);285} else {286$duo_button = phutil_tag(287'a',288array(289'href' => $duo_uri,290'class' => 'button button-grey',291'target' => ($is_external ? '_blank' : null),292),293pht('Enroll Duo Account: %s', $duo_user));294295$duo_button = phutil_tag(296'div',297array(298'class' => 'mfa-form-enroll-button',299),300$duo_button);301302if ($is_external) {303$form->appendRemarkupInstructions(304pht(305'Complete enrolling your phone with Duo:'));306307$form->appendControl(308id(new AphrontFormMarkupControl())309->setValue($duo_button));310} else {311312$form->appendRemarkupInstructions(313pht(314'Scan this QR code with the Duo application on your mobile '.315'phone:'));316317318$qr_code = $this->newQRCode($duo_uri);319$form->appendChild($qr_code);320321$form->appendRemarkupInstructions(322pht(323'If you are currently using your phone to view this page, '.324'click this button to open the Duo application:'));325326$form->appendControl(327id(new AphrontFormMarkupControl())328->setValue($duo_button));329}330331$form->appendRemarkupInstructions(332pht(333'Once you have completed setup on your phone, click continue.'));334}335}336337338protected function newMFASyncTokenProperties(339PhabricatorAuthFactorProvider $provider,340PhabricatorUser $user) {341342$duo_user = $this->getDuoUsername($provider, $user);343344// Duo automatically normalizes usernames to lowercase. Just do that here345// so that our value agrees more closely with Duo.346$duo_user = phutil_utf8_strtolower($duo_user);347348$parameters = array(349'username' => $duo_user,350);351352$result = $this->newDuoFuture($provider)353->setMethod('preauth', $parameters)354->resolve();355356$external_uri = null;357$result_code = $result['response']['result'];358$status_message = $result['response']['status_msg'];359switch ($result_code) {360case 'auth':361case 'allow':362// If the user already has a Duo account, they don't need to do363// anything.364return array(365'duo.enroll' => 'auto',366'duo.username' => $duo_user,367);368case 'enroll':369if (!$this->shouldAllowDuoEnrollment($provider)) {370return array(371'duo.enroll' => 'blocked',372'duo.username' => $duo_user,373);374}375376$external_uri = $result['response']['enroll_portal_url'];377378// Otherwise, enrollment is permitted so we're going to continue.379break;380default:381case 'deny':382return $this->newResult()383->setIsError(true)384->setErrorMessage(385pht(386'Your Duo account ("%s") is not permitted to access this '.387'system. Contact your Duo administrator for help. '.388'The Duo preauth API responded with status message ("%s"): %s',389$duo_user,390$result_code,391$status_message));392}393394// Duo's "/enroll" API isn't repeatable for the same username. If we're395// the first call, great: we can do inline enrollment, which is way more396// user friendly. Otherwise, we have to send the user on an adventure.397398$parameters = array(399'username' => $duo_user,400'valid_secs' => phutil_units('1 hour in seconds'),401);402403try {404$result = $this->newDuoFuture($provider)405->setMethod('enroll', $parameters)406->resolve();407} catch (HTTPFutureHTTPResponseStatus $ex) {408return array(409'duo.enroll' => 'external',410'duo.username' => $duo_user,411'duo.uri' => $external_uri,412);413}414415return array(416'duo.enroll' => 'inline',417'duo.uri' => $result['response']['activation_code'],418'duo.username' => $duo_user,419'duo.user-id' => $result['response']['user_id'],420);421}422423protected function newIssuedChallenges(424PhabricatorAuthFactorConfig $config,425PhabricatorUser $viewer,426array $challenges) {427428// If we already issued a valid challenge for this workflow and session,429// don't issue a new one.430431$challenge = $this->getChallengeForCurrentContext(432$config,433$viewer,434$challenges);435if ($challenge) {436return array();437}438439if (!$this->hasCSRF($config)) {440return $this->newResult()441->setIsContinue(true)442->setErrorMessage(443pht(444'An authorization request will be pushed to the Duo '.445'application on your phone.'));446}447448$provider = $config->getFactorProvider();449450// Otherwise, issue a new challenge.451$duo_user = (string)$config->getAuthFactorConfigProperty('duo.username');452453$parameters = array(454'username' => $duo_user,455);456457$response = $this->newDuoFuture($provider)458->setMethod('preauth', $parameters)459->resolve();460$response = $response['response'];461462$next_step = $response['result'];463$status_message = $response['status_msg'];464switch ($next_step) {465case 'auth':466// We're good to go.467break;468case 'allow':469// Duo is telling us to bypass MFA. For now, refuse.470return $this->newResult()471->setIsError(true)472->setErrorMessage(473pht(474'Duo is not requiring a challenge, which defeats the '.475'purpose of MFA. Duo must be configured to challenge you.'));476case 'enroll':477return $this->newResult()478->setIsError(true)479->setErrorMessage(480pht(481'Your Duo account ("%s") requires enrollment. Contact your '.482'Duo administrator for help. Duo status message: %s',483$duo_user,484$status_message));485case 'deny':486default:487return $this->newResult()488->setIsError(true)489->setErrorMessage(490pht(491'Your Duo account ("%s") is not permitted to access this '.492'system. Contact your Duo administrator for help. The Duo '.493'preauth API responded with status message ("%s"): %s',494$duo_user,495$next_step,496$status_message));497}498499$has_push = false;500$devices = $response['devices'];501foreach ($devices as $device) {502$capabilities = array_fuse($device['capabilities']);503if (isset($capabilities['push'])) {504$has_push = true;505break;506}507}508509if (!$has_push) {510return $this->newResult()511->setIsError(true)512->setErrorMessage(513pht(514'This factor has been removed from your device, so this server '.515'can not send you a challenge. To continue, an administrator '.516'must strip this factor from your account.'));517}518519$push_info = array(520pht('Domain') => $this->getInstallDisplayName(),521);522$push_info = phutil_build_http_querystring($push_info);523524$parameters = array(525'username' => $duo_user,526'factor' => 'push',527'async' => '1',528529// Duo allows us to specify a device, or to pass "auto" to have it pick530// the first one. For now, just let it pick.531'device' => 'auto',532533// This is a hard-coded prefix for the word "... request" in the Duo UI,534// which defaults to "Login". We could pass richer information from535// workflows here, but it's not very flexible anyway.536'type' => 'Authentication',537538'display_username' => $viewer->getUsername(),539'pushinfo' => $push_info,540);541542$result = $this->newDuoFuture($provider)543->setMethod('auth', $parameters)544->resolve();545546$duo_xaction = $result['response']['txid'];547548// The Duo push timeout is 60 seconds. Set our challenge to expire slightly549// more quickly so that we'll re-issue a new challenge before Duo times out.550// This should keep users away from a dead-end where they can't respond to551// Duo but we won't issue a new challenge yet.552$ttl_seconds = 55;553554return array(555$this->newChallenge($config, $viewer)556->setChallengeKey($duo_xaction)557->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),558);559}560561protected function newResultFromIssuedChallenges(562PhabricatorAuthFactorConfig $config,563PhabricatorUser $viewer,564array $challenges) {565566$challenge = $this->getChallengeForCurrentContext(567$config,568$viewer,569$challenges);570571if ($challenge->getIsAnsweredChallenge()) {572return $this->newResult()573->setAnsweredChallenge($challenge);574}575576$provider = $config->getFactorProvider();577$duo_xaction = $challenge->getChallengeKey();578579$parameters = array(580'txid' => $duo_xaction,581);582583// This endpoint always long-polls, so use a timeout to force it to act584// more asynchronously.585try {586$result = $this->newDuoFuture($provider)587->setHTTPMethod('GET')588->setMethod('auth_status', $parameters)589->setTimeout(3)590->resolve();591592$state = $result['response']['result'];593$status = $result['response']['status'];594} catch (HTTPFutureCURLResponseStatus $exception) {595if ($exception->isTimeout()) {596$state = 'waiting';597$status = 'poll';598} else {599throw $exception;600}601}602603$now = PhabricatorTime::getNow();604605switch ($state) {606case 'allow':607$ttl = PhabricatorTime::getNow()608+ phutil_units('15 minutes in seconds');609610$challenge611->markChallengeAsAnswered($ttl);612613return $this->newResult()614->setAnsweredChallenge($challenge);615case 'waiting':616// If we didn't just issue this challenge, give the user a stronger617// hint that they need to follow the instructions.618if (!$challenge->getIsNewChallenge()) {619return $this->newResult()620->setIsContinue(true)621->setIcon(622id(new PHUIIconView())623->setIcon('fa-exclamation-triangle', 'yellow'))624->setErrorMessage(625pht(626'You must approve the challenge which was sent to your '.627'phone. Open the Duo application and confirm the challenge, '.628'then continue.'));629}630631// Otherwise, we'll construct a default message later on.632break;633default:634case 'deny':635if ($status === 'timeout') {636return $this->newResult()637->setIsError(true)638->setErrorMessage(639pht(640'This request has timed out because you took too long to '.641'respond.'));642} else {643$wait_duration = ($challenge->getChallengeTTL() - $now) + 1;644645return $this->newResult()646->setIsWait(true)647->setErrorMessage(648pht(649'You denied this request. Wait %s second(s) to try again.',650new PhutilNumber($wait_duration)));651}652break;653}654655return null;656}657658public function renderValidateFactorForm(659PhabricatorAuthFactorConfig $config,660AphrontFormView $form,661PhabricatorUser $viewer,662PhabricatorAuthFactorResult $result) {663664$control = $this->newAutomaticControl($result);665666$control667->setLabel(pht('Duo'))668->setCaption(pht('Factor Name: %s', $config->getFactorName()));669670$form->appendChild($control);671}672673public function getRequestHasChallengeResponse(674PhabricatorAuthFactorConfig $config,675AphrontRequest $request) {676return false;677}678679protected function newResultFromChallengeResponse(680PhabricatorAuthFactorConfig $config,681PhabricatorUser $viewer,682AphrontRequest $request,683array $challenges) {684685return $this->getResultForPrompt(686$config,687$viewer,688$request,689$challenges);690}691692protected function newResultForPrompt(693PhabricatorAuthFactorConfig $config,694PhabricatorUser $viewer,695AphrontRequest $request,696array $challenges) {697698$result = $this->newResult()699->setIsContinue(true)700->setErrorMessage(701pht(702'A challenge has been sent to your phone. Open the Duo '.703'application and confirm the challenge, then continue.'));704705$challenge = $this->getChallengeForCurrentContext(706$config,707$viewer,708$challenges);709if ($challenge) {710$result711->setStatusChallenge($challenge)712->setIcon(713id(new PHUIIconView())714->setIcon('fa-refresh', 'green ph-spin'));715}716717return $result;718}719720private function newDuoFuture(PhabricatorAuthFactorProvider $provider) {721$credential_phid = $provider->getAuthFactorProviderProperty(722self::PROP_CREDENTIAL);723724$omnipotent = PhabricatorUser::getOmnipotentUser();725726$credential = id(new PassphraseCredentialQuery())727->setViewer($omnipotent)728->withPHIDs(array($credential_phid))729->needSecrets(true)730->executeOne();731if (!$credential) {732throw new Exception(733pht(734'Unable to load Duo API credential ("%s").',735$credential_phid));736}737738$duo_key = $credential->getUsername();739$duo_secret = $credential->getSecret();740if (!$duo_secret) {741throw new Exception(742pht(743'Duo API credential ("%s") has no secret key.',744$credential_phid));745}746747$duo_host = $provider->getAuthFactorProviderProperty(748self::PROP_HOSTNAME);749self::requireDuoAPIHostname($duo_host);750751return id(new PhabricatorDuoFuture())752->setIntegrationKey($duo_key)753->setSecretKey($duo_secret)754->setAPIHostname($duo_host)755->setTimeout(10)756->setHTTPMethod('POST');757}758759private function getDuoUsername(760PhabricatorAuthFactorProvider $provider,761PhabricatorUser $user) {762763$mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES);764switch ($mode) {765case 'username':766return $user->getUsername();767case 'email':768return $user->loadPrimaryEmailAddress();769default:770throw new Exception(771pht(772'Duo username pairing mode ("%s") is not supported.',773$mode));774}775}776777private function shouldAllowDuoEnrollment(778PhabricatorAuthFactorProvider $provider) {779780$mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL);781switch ($mode) {782case 'deny':783return false;784case 'allow':785return true;786default:787throw new Exception(788pht(789'Duo enrollment mode ("%s") is not supported.',790$mode));791}792}793794private function newDuoConfig(PhabricatorUser $user, $duo_user) {795$config_properties = array(796'duo.username' => $duo_user,797);798799$config = $this->newConfigForUser($user)800->setFactorName(pht('Duo (%s)', $duo_user))801->setProperties($config_properties);802803return $config;804}805806public static function requireDuoAPIHostname($hostname) {807if (preg_match('/\.duosecurity\.com\z/', $hostname)) {808return;809}810811throw new Exception(812pht(813'Duo API hostname ("%s") is invalid, hostname must be '.814'"*.duosecurity.com".',815$hostname));816}817818public function newChallengeStatusView(819PhabricatorAuthFactorConfig $config,820PhabricatorAuthFactorProvider $provider,821PhabricatorUser $viewer,822PhabricatorAuthChallenge $challenge) {823824$duo_xaction = $challenge->getChallengeKey();825826$parameters = array(827'txid' => $duo_xaction,828);829830$default_result = id(new PhabricatorAuthChallengeUpdate())831->setRetry(true);832833try {834$result = $this->newDuoFuture($provider)835->setHTTPMethod('GET')836->setMethod('auth_status', $parameters)837->setTimeout(5)838->resolve();839840$state = $result['response']['result'];841} catch (HTTPFutureCURLResponseStatus $exception) {842// If we failed or timed out, retry. Usually, this is a timeout.843return id(new PhabricatorAuthChallengeUpdate())844->setRetry(true);845}846847// For now, don't update the view for anything but an "Allow". Updates848// here are just about providing more visual feedback for user convenience.849if ($state !== 'allow') {850return id(new PhabricatorAuthChallengeUpdate())851->setRetry(false);852}853854$icon = id(new PHUIIconView())855->setIcon('fa-check-circle-o', 'green');856857$view = id(new PHUIFormTimerControl())858->setIcon($icon)859->appendChild(pht('You responded to this challenge correctly.'))860->newTimerView();861862return id(new PhabricatorAuthChallengeUpdate())863->setState('allow')864->setRetry(false)865->setMarkup($view);866}867868}869870871