diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php index afcc065559..ee76094a68 100644 --- a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php @@ -1,78 +1,87 @@ getViewer(); $id = $request->getURIData('id'); $number = id(new PhabricatorAuthContactNumberQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$number) { return new Aphront404Response(); } $id = $number->getID(); $cancel_uri = $number->getURI(); if ($number->isDisabled()) { return $this->newDialog() ->setTitle(pht('Number Disabled')) ->appendParagraph( pht( 'You can not make a disabled number your primary contact number.')) ->addCancelButton($cancel_uri); } if ($number->getIsPrimary()) { return $this->newDialog() ->setTitle(pht('Number Already Primary')) ->appendParagraph( pht( 'This contact number is already your primary contact number.')) ->addCancelButton($cancel_uri); } if ($request->isFormPost()) { $xactions = array(); $xactions[] = id(new PhabricatorAuthContactNumberTransaction()) ->setTransactionType( PhabricatorAuthContactNumberPrimaryTransaction::TRANSACTIONTYPE) ->setNewValue(true); $editor = id(new PhabricatorAuthContactNumberEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); - $editor->applyTransactions($number, $xactions); + try { + $editor->applyTransactions($number, $xactions); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + // This happens when you try to make a number into your primary + // number, but you have contact number MFA on your account. + return $this->newDialog() + ->setTitle(pht('Unable to Make Primary')) + ->setValidationException($ex) + ->addCancelButton($cancel_uri); + } return id(new AphrontRedirectResponse())->setURI($cancel_uri); } $number_display = phutil_tag( 'strong', array(), $number->getDisplayName()); return $this->newDialog() ->setTitle(pht('Set Primary Contact Number')) ->appendParagraph( pht( 'Designate %s as your primary contact number?', $number_display)) ->addSubmitButton(pht('Make Primary')) ->addCancelButton($cancel_uri); } } diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index 768ad34e18..f11b81549f 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -1,391 +1,403 @@ getID().'.'.$name; } public static function getAllFactors() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getFactorKey') ->execute(); } protected function newConfigForUser(PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfig()) ->setUserPHID($user->getPHID()) ->setFactorSecret(''); } protected function newResult() { return new PhabricatorAuthFactorResult(); } public function newIconView() { return id(new PHUIIconView()) ->setIcon('fa-mobile'); } public function canCreateNewProvider() { return true; } public function getProviderCreateDescription() { return null; } public function canCreateNewConfiguration(PhabricatorUser $user) { return true; } public function getConfigurationCreateDescription(PhabricatorUser $user) { return null; } + /** + * Is this a factor which depends on the user's contact number? + * + * If a user has a "contact number" factor configured, they can not modify + * or switch their primary contact number. + * + * @return bool True if this factor should lock contact numbers. + */ + public function isContactNumberFactor() { + return false; + } + abstract public function getEnrollDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user); public function getEnrollButtonText( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return pht('Continue'); } public function getFactorOrder() { return 1000; } final public function newSortVector() { return id(new PhutilSortVector()) ->addInt($this->canCreateNewProvider() ? 0 : 1) ->addInt($this->getFactorOrder()) ->addString($this->getFactorName()); } protected function newChallenge( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer) { $engine = $config->getSessionEngine(); return PhabricatorAuthChallenge::initializeNewChallenge() ->setUserPHID($viewer->getPHID()) ->setSessionPHID($viewer->getSession()->getPHID()) ->setFactorPHID($config->getPHID()) ->setWorkflowKey($engine->getWorkflowKey()); } abstract public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $response); final public function getNewIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $now = PhabricatorTime::getNow(); // Factor implementations may need to perform writes in order to issue // challenges, particularly push factors like SMS. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $new_challenges = $this->newIssuedChallenges( $config, $viewer, $challenges); assert_instances_of($new_challenges, 'PhabricatorAuthChallenge'); foreach ($new_challenges as $new_challenge) { $ttl = $new_challenge->getChallengeTTL(); if (!$ttl) { throw new Exception( pht('Newly issued MFA challenges must have a valid TTL!')); } if ($ttl < $now) { throw new Exception( pht( 'Newly issued MFA challenges must have a future TTL. This '. 'factor issued a bad TTL ("%s"). (Did you use a relative '. 'time instead of an epoch?)', $ttl)); } } foreach ($new_challenges as $challenge) { $challenge->save(); } unset($unguarded); return $new_challenges; } abstract protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromIssuedChallenges( $config, $viewer, $challenges); if ($result === null) { return $result; } if (!($result instanceof PhabricatorAuthFactorResult)) { throw new Exception( pht( 'Expected "newResultFromIssuedChallenges()" to return null or '. 'an object of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } $result->setIssuedChallenges($challenges); return $result; } abstract protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromChallengeResponse( $config, $viewer, $request, $challenges); if (!($result instanceof PhabricatorAuthFactorResult)) { throw new Exception( pht( 'Expected "newResultFromChallengeResponse()" to return an object '. 'of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } $result->setIssuedChallenges($challenges); return $result; } abstract protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges); final protected function newAutomaticControl( PhabricatorAuthFactorResult $result) { $is_answered = (bool)$result->getAnsweredChallenge(); if ($is_answered) { return $this->newAnsweredControl($result); } $is_wait = $result->getIsWait(); if ($is_wait) { return $this->newWaitControl($result); } return null; } private function newWaitControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = id(new PHUIIconView()) ->setIcon('fa-clock-o', 'red'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error) ->setError(pht('Wait')); } private function newAnsweredControl( PhabricatorAuthFactorResult $result) { $icon = id(new PHUIIconView()) ->setIcon('fa-check-circle-o', 'green'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild( pht('You responded to this challenge correctly.')); } /* -( Synchronizing New Factors )------------------------------------------ */ final protected function loadMFASyncToken( AphrontRequest $request, AphrontFormView $form, PhabricatorUser $user) { // If the form included a synchronization key, load the corresponding // token. The user must synchronize to a key we generated because this // raises the barrier to theoretical attacks where an attacker might // provide a known key for factors like TOTP. // (We store and verify the hash of the key, not the key itself, to limit // how useful the data in the table is to an attacker.) $sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE; $sync_token = null; $sync_key = $request->getStr($this->getMFASyncTokenFormKey()); if (strlen($sync_key)) { $sync_key_digest = PhabricatorHash::digestWithNamedKey( $sync_key, PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); $sync_token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($sync_type)) ->withExpired(false) ->withTokenCodes(array($sync_key_digest)) ->executeOne(); } if (!$sync_token) { // Don't generate a new sync token if there are too many outstanding // tokens already. This is mostly relevant for push factors like SMS, // where generating a token has the side effect of sending a user a // message. $outstanding_limit = 10; $outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($sync_type)) ->withExpired(false) ->execute(); if (count($outstanding_tokens) > $outstanding_limit) { throw new Exception( pht( 'Your account has too many outstanding, incomplete MFA '. 'synchronization attempts. Wait an hour and try again.')); } $now = PhabricatorTime::getNow(); $sync_key = Filesystem::readRandomCharacters(32); $sync_key_digest = PhabricatorHash::digestWithNamedKey( $sync_key, PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); $sync_ttl = $this->getMFASyncTokenTTL(); $sync_token = id(new PhabricatorAuthTemporaryToken()) ->setIsNewTemporaryToken(true) ->setTokenResource($user->getPHID()) ->setTokenType($sync_type) ->setTokenCode($sync_key_digest) ->setTokenExpires($now + $sync_ttl); $properties = $this->newMFASyncTokenProperties($user); foreach ($properties as $key => $value) { $sync_token->setTemporaryTokenProperty($key, $value); } $sync_token->save(); } $form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key); return $sync_token; } protected function newMFASyncTokenProperties(PhabricatorUser $user) { return array(); } private function getMFASyncTokenFormKey() { return 'sync.key'; } private function getMFASyncTokenTTL() { return phutil_units('1 hour in seconds'); } final protected function getChallengeForCurrentContext( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $session_phid = $viewer->getSession()->getPHID(); $engine = $config->getSessionEngine(); $workflow_key = $engine->getWorkflowKey(); foreach ($challenges as $challenge) { if ($challenge->getSessionPHID() !== $session_phid) { continue; } if ($challenge->getWorkflowKey() !== $workflow_key) { continue; } if ($challenge->getIsCompleted()) { continue; } if ($challenge->getIsReusedChallenge()) { continue; } return $challenge; } return null; } } diff --git a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php index 03558f7333..baa714c21d 100644 --- a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php @@ -1,367 +1,371 @@ isSMSMailerConfigured(); } public function getProviderCreateDescription() { $messages = array(); if (!$this->isSMSMailerConfigured()) { $messages[] = id(new PHUIInfoView()) ->setErrors( array( pht( 'You have not configured an outbound SMS mailer. You must '. 'configure one before you can set up SMS. See: %s', phutil_tag( 'a', array( 'href' => '/config/edit/cluster.mailers/', ), 'cluster.mailers')), )); } $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'SMS is weak, and relatively easy for attackers to compromise. '. 'Strongly consider using a different MFA provider.'), )); return $messages; } public function canCreateNewConfiguration(PhabricatorUser $user) { if (!$this->loadUserContactNumber($user)) { return false; } return true; } public function getConfigurationCreateDescription(PhabricatorUser $user) { $messages = array(); if (!$this->loadUserContactNumber($user)) { $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'You have not configured a primary contact number. Configure '. 'a contact number before adding SMS as an authentication '. 'factor.'), )); } return $messages; } public function getEnrollDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return pht( 'To verify your phone as an authentication factor, a text message with '. 'a secret code will be sent to the phone number you have listed as '. 'your primary contact number.'); } public function getEnrollButtonText( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $contact_number = $this->loadUserContactNumber($user); return pht('Send SMS: %s', $contact_number->getDisplayName()); } public function processAddFactorForm( PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { $token = $this->loadMFASyncToken($request, $form, $user); $code = $request->getStr('sms.code'); $e_code = true; if (!$token->getIsNewTemporaryToken()) { $expect_code = $token->getTemporaryTokenProperty('code'); $okay = phutil_hashes_are_identical( $this->normalizeSMSCode($code), $this->normalizeSMSCode($expect_code)); if ($okay) { $config = $this->newConfigForUser($user) ->setFactorName(pht('SMS')); return $config; } else { if (!strlen($code)) { $e_code = pht('Required'); } else { $e_code = pht('Invalid'); } } } $form->appendRemarkupInstructions( pht( 'Enter the code from the text message which was sent to your '. 'primary contact number.')); $form->appendChild( id(new PHUIFormNumberControl()) ->setLabel(pht('SMS Code')) ->setName('sms.code') ->setValue($code) ->setError($e_code)); } protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { // If we already issued a valid challenge for this workflow and session, // don't issue a new one. $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge) { return array(); } // Otherwise, issue a new challenge. $challenge_code = $this->newSMSChallengeCode(); $envelope = new PhutilOpaqueEnvelope($challenge_code); $this->sendSMSCodeToUser($envelope, $viewer); $ttl_seconds = phutil_units('15 minutes in seconds'); return array( $this->newChallenge($config, $viewer) ->setChallengeKey($challenge_code) ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), ); } protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge->getIsAnsweredChallenge()) { return $this->newResult() ->setAnsweredChallenge($challenge); } return null; } public function renderValidateFactorForm( PhabricatorAuthFactorConfig $config, AphrontFormView $form, PhabricatorUser $viewer, PhabricatorAuthFactorResult $result) { $control = $this->newAutomaticControl($result); if (!$control) { $value = $result->getValue(); $error = $result->getErrorMessage(); $name = $this->getChallengeResponseParameterName($config); $control = id(new PHUIFormNumberControl()) ->setName($name) ->setDisableAutocomplete(true) ->setValue($value) ->setError($error); } $control ->setLabel(pht('SMS Code')) ->setCaption(pht('Factor Name: %s', $config->getFactorName())); $form->appendChild($control); } public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $value = $this->getChallengeResponseFromRequest($config, $request); return (bool)strlen($value); } protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); $code = $this->getChallengeResponseFromRequest( $config, $request); $result = $this->newResult() ->setValue($code); if ($challenge->getIsAnsweredChallenge()) { return $result->setAnsweredChallenge($challenge); } if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); $challenge ->markChallengeAsAnswered($ttl); return $result->setAnsweredChallenge($challenge); } if (strlen($code)) { $error_message = pht('Invalid'); } else { $error_message = pht('Required'); } $result->setErrorMessage($error_message); return $result; } private function newSMSChallengeCode() { $value = Filesystem::readRandomInteger(0, 99999999); $value = sprintf('%08d', $value); return $value; } private function isSMSMailerConfigured() { $mailers = PhabricatorMetaMTAMail::newMailers( array( 'outbound' => true, 'media' => array( PhabricatorMailSMSMessage::MESSAGETYPE, ), )); return (bool)$mailers; } private function loadUserContactNumber(PhabricatorUser $user) { $contact_numbers = id(new PhabricatorAuthContactNumberQuery()) ->setViewer($user) ->withObjectPHIDs(array($user->getPHID())) ->withStatuses( array( PhabricatorAuthContactNumber::STATUS_ACTIVE, )) ->withIsPrimary(true) ->execute(); if (count($contact_numbers) !== 1) { return null; } return head($contact_numbers); } protected function newMFASyncTokenProperties(PhabricatorUser $user) { $sms_code = $this->newSMSChallengeCode(); $envelope = new PhutilOpaqueEnvelope($sms_code); $this->sendSMSCodeToUser($envelope, $user); return array( 'code' => $sms_code, ); } private function sendSMSCodeToUser( PhutilOpaqueEnvelope $envelope, PhabricatorUser $user) { $uri = PhabricatorEnv::getURI('/'); $uri = new PhutilURI($uri); return id(new PhabricatorMetaMTAMail()) ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE) ->addTos(array($user->getPHID())) ->setForceDelivery(true) ->setSensitiveContent(true) ->setBody( pht( 'Phabricator (%s) MFA Code: %s', $uri->getDomain(), $envelope->openEnvelope())) ->save(); } private function normalizeSMSCode($code) { return trim($code); } private function getChallengeResponseParameterName( PhabricatorAuthFactorConfig $config) { return $this->getParameterName($config, 'sms.code'); } private function getChallengeResponseFromRequest( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $name = $this->getChallengeResponseParameterName($config); $value = $request->getStr($name); $value = (string)$value; $value = trim($value); return $value; } } diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php index 00499959ec..88d9d4bffc 100644 --- a/src/applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php @@ -1,91 +1,96 @@ getContactNumber(); } public function generateNewValue($object, $value) { $number = new PhabricatorPhoneNumber($value); return $number->toE164(); } public function applyInternalEffects($object, $value) { $object->setContactNumber($value); } public function getTitle() { $old = $this->getOldValue(); $new = $this->getNewValue(); return pht( '%s changed this contact number from %s to %s.', $this->renderAuthor(), $this->renderOldValue(), $this->renderNewValue()); } public function validateTransactions($object, array $xactions) { $errors = array(); $current_value = $object->getContactNumber(); if ($this->isEmptyTextTransaction($current_value, $xactions)) { $errors[] = $this->newRequiredError( pht('Contact numbers must have a contact number.')); return $errors; } $max_length = $object->getColumnMaximumByteLength('contactNumber'); foreach ($xactions as $xaction) { $new_value = $xaction->getNewValue(); $new_length = strlen($new_value); if ($new_length > $max_length) { $errors[] = $this->newInvalidError( pht( 'Contact numbers can not be longer than %s characters.', new PhutilNumber($max_length)), $xaction); continue; } try { new PhabricatorPhoneNumber($new_value); } catch (Exception $ex) { $errors[] = $this->newInvalidError( pht( 'Contact number is invalid: %s', $ex->getMessage()), $xaction); continue; } $new_value = $this->generateNewValue($object, $new_value); $unique_key = id(clone $object) ->setContactNumber($new_value) ->newUniqueKey(); $other = id(new PhabricatorAuthContactNumberQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUniqueKeys(array($unique_key)) ->executeOne(); if ($other) { if ($other->getID() !== $object->getID()) { $errors[] = $this->newInvalidError( pht('Contact number is already in use.'), $xaction); continue; } } + $mfa_error = $this->newContactNumberMFAError($object, $xaction); + if ($mfa_error) { + $errors[] = $mfa_error; + continue; + } } return $errors; } } diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php index 2e4b6ff55c..42788029b5 100644 --- a/src/applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php @@ -1,49 +1,55 @@ getIsPrimary(); } public function applyInternalEffects($object, $value) { $object->setIsPrimary((int)$value); } public function getTitle() { return pht( '%s made this the primary contact number.', $this->renderAuthor()); } public function validateTransactions($object, array $xactions) { $errors = array(); foreach ($xactions as $xaction) { $new_value = $xaction->getNewValue(); if (!$new_value) { $errors[] = $this->newInvalidError( pht( 'To choose a different primary contact number, make that '. 'number primary (instead of trying to demote this one).'), $xaction); continue; } if ($object->isDisabled()) { $errors[] = $this->newInvalidError( pht( 'You can not make a disabled number a primary contact number.'), $xaction); continue; } + + $mfa_error = $this->newContactNumberMFAError($object, $xaction); + if ($mfa_error) { + $errors[] = $mfa_error; + continue; + } } return $errors; } } diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php index 305243ae15..5dab6fe8c0 100644 --- a/src/applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php @@ -1,59 +1,65 @@ getStatus(); } public function applyInternalEffects($object, $value) { $object->setStatus($value); } public function getTitle() { $new = $this->getNewValue(); if ($new === PhabricatorAuthContactNumber::STATUS_DISABLED) { return pht( '%s disabled this contact number.', $this->renderAuthor()); } else { return pht( '%s enabled this contact number.', $this->renderAuthor()); } } public function validateTransactions($object, array $xactions) { $errors = array(); $map = PhabricatorAuthContactNumber::getStatusNameMap(); foreach ($xactions as $xaction) { $new_value = $xaction->getNewValue(); if (!isset($map[$new_value])) { $errors[] = $this->newInvalidError( pht( 'Status ("%s") is not a valid contact number status. Valid '. 'status constants are: %s.', $new_value, implode(', ', array_keys($map))), $xaction); continue; } + $mfa_error = $this->newContactNumberMFAError($object, $xaction); + if ($mfa_error) { + $errors[] = $mfa_error; + continue; + } + // NOTE: Enabling a contact number may cause us to collide with another // active contact number. However, there might also be a transaction in // this group that changes the number itself. Since we can't easily // predict if we'll collide or not, just let the duplicate key logic // handle it when we do. } return $errors; } } diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php index c32fbe6a30..a74c78d4c4 100644 --- a/src/applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php @@ -1,4 +1,72 @@ getTransactionType() === $primary_type) { + // We're trying to make a non-primary number into the primary number, + // so do MFA checks. + $is_primary = false; + } else if ($object->getIsPrimary()) { + // We're editing the primary number, so do MFA checks. + $is_primary = true; + } else { + // Editing a non-primary number and not making it primary, so this is + // fine. + return null; + } + + $target_phid = $object->getObjectPHID(); + $omnipotent = PhabricatorUser::getOmnipotentUser(); + + $user_configs = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($omnipotent) + ->withUserPHIDs(array($target_phid)) + ->execute(); + + $problem_configs = array(); + foreach ($user_configs as $config) { + $provider = $config->getFactorProvider(); + $factor = $provider->getFactor(); + + if ($factor->isContactNumberFactor()) { + $problem_configs[] = $config; + } + } + + if (!$problem_configs) { + return null; + } + + $problem_config = head($problem_configs); + + if ($is_primary) { + return $this->newInvalidError( + pht( + 'You currently have multi-factor authentication ("%s") which '. + 'depends on your primary contact number. You must remove this '. + 'authentication factor before you can modify or disable your '. + 'primary contact number.', + $problem_config->getFactorName()), + $xaction); + } else { + return $this->newInvalidError( + pht( + 'You currently have multi-factor authentication ("%s") which '. + 'depends on your primary contact number. You must remove this '. + 'authentication factor before you can designate a new primary '. + 'contact number.', + $problem_config->getFactorName()), + $xaction); + } + } + +}