diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4297,6 +4297,7 @@ 'PhabricatorResourceSite' => 'aphront/site/PhabricatorResourceSite.php', 'PhabricatorRobotsController' => 'applications/system/controller/PhabricatorRobotsController.php', 'PhabricatorS3FileStorageEngine' => 'applications/files/engine/PhabricatorS3FileStorageEngine.php', + 'PhabricatorSMSAuthFactor' => 'applications/auth/factor/PhabricatorSMSAuthFactor.php', 'PhabricatorSQLPatchList' => 'infrastructure/storage/patch/PhabricatorSQLPatchList.php', 'PhabricatorSSHKeyGenerator' => 'infrastructure/util/PhabricatorSSHKeyGenerator.php', 'PhabricatorSSHKeysSettingsPanel' => 'applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php', @@ -10393,6 +10394,7 @@ 'PhabricatorResourceSite' => 'PhabricatorSite', 'PhabricatorRobotsController' => 'PhabricatorController', 'PhabricatorS3FileStorageEngine' => 'PhabricatorFileStorageEngine', + 'PhabricatorSMSAuthFactor' => 'PhabricatorAuthFactor', 'PhabricatorSQLPatchList' => 'Phobject', 'PhabricatorSSHKeyGenerator' => 'Phobject', 'PhabricatorSSHKeysSettingsPanel' => 'PhabricatorSettingsPanel', diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -33,7 +33,8 @@ protected function newConfigForUser(PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfig()) - ->setUserPHID($user->getPHID()); + ->setUserPHID($user->getPHID()) + ->setFactorSecret(''); } protected function newResult() { @@ -107,6 +108,10 @@ $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, @@ -131,10 +136,10 @@ } } - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - foreach ($new_challenges as $challenge) { - $challenge->save(); - } + foreach ($new_challenges as $challenge) { + $challenge->save(); + } + unset($unguarded); return $new_challenges; @@ -351,4 +356,36 @@ 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 new file mode 100644 --- /dev/null +++ b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php @@ -0,0 +1,367 @@ +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/phid/PhabricatorAuthMessagePHIDType.php b/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php --- a/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php +++ b/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php @@ -19,7 +19,9 @@ protected function buildQueryForObjects( PhabricatorObjectQuery $query, array $phids) { - return new PhabricatorAuthMessageQuery(); + + return id(new PhabricatorAuthMessageQuery()) + ->withPHIDs($phids); } public function loadHandles(