diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index 194f4acb59..4a4c6d4c3c 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -1,527 +1,546 @@ 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( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return null; } + public function getConfigurationListDetails( + PhabricatorAuthFactorConfig $config, + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $viewer) { + 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_error = $result->getIsError(); if ($is_error) { return $this->newErrorControl($result); } $is_continue = $result->getIsContinue(); if ($is_continue) { return $this->newContinueControl($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.')); } private function newErrorControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = id(new PHUIIconView()) ->setIcon('fa-times', 'red'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error) ->setError(pht('Error')); } private function newContinueControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = id(new PHUIIconView()) ->setIcon('fa-commenting', 'green'); return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error); } /* -( 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; } /** * @phutil-external-symbol class QRcode */ final protected function newQRCode($uri) { $root = dirname(phutil_get_library_root('phabricator')); require_once $root.'/externals/phpqrcode/phpqrcode.php'; $lines = QRcode::text($uri); $total_width = 240; $cell_size = floor($total_width / count($lines)); $rows = array(); foreach ($lines as $line) { $cells = array(); for ($ii = 0; $ii < strlen($line); $ii++) { if ($line[$ii] == '1') { $color = '#000'; } else { $color = '#fff'; } $cells[] = phutil_tag( 'td', array( 'width' => $cell_size, 'height' => $cell_size, 'style' => 'background: '.$color, ), ''); } $rows[] = phutil_tag('tr', array(), $cells); } return phutil_tag( 'table', array( 'style' => 'margin: 24px auto;', ), $rows); } final protected function throwResult(PhabricatorAuthFactorResult $result) { throw new PhabricatorAuthFactorResultException($result); } final protected function getInstallDisplayName() { $uri = PhabricatorEnv::getURI('/'); $uri = new PhutilURI($uri); return $uri->getDomain(); } final protected function getChallengeResponseParameterName( PhabricatorAuthFactorConfig $config) { return $this->getParameterName($config, 'mfa.response'); } final protected function getChallengeResponseFromRequest( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $name = $this->getChallengeResponseParameterName($config); $value = $request->getStr($name); $value = (string)$value; $value = trim($value); return $value; } final protected function hasCSRF(PhabricatorAuthFactorConfig $config) { $engine = $config->getSessionEngine(); $request = $engine->getRequest(); if (!$request->isHTTPPost()) { return false; } return $request->validateCSRF(); } + final protected function loadConfigurationsForProvider( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + return id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($user) + ->withUserPHIDs(array($user->getPHID())) + ->withFactorProviderPHIDs(array($provider->getPHID())) + ->execute(); + } + } diff --git a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php index d91dceb475..58065a03c3 100644 --- a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php @@ -1,386 +1,404 @@ 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( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { if (!$this->loadUserContactNumber($user)) { return false; } + if ($this->loadConfigurationsForProvider($provider, $user)) { + return false; + } + return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, 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.'), )); } + if ($this->loadConfigurationsForProvider($provider, $user)) { + $messages[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setErrors( + array( + pht( + 'You already have SMS authentication attached to your account.'), + )); + } + 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(); } if (!$this->loadUserContactNumber($viewer)) { $result = $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Your account has no primary contact number.')); $this->throwResult($result); } if (!$this->isSMSMailerConfigured()) { $result = $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'No outbound mailer which can deliver SMS messages is '. 'configured.')); $this->throwResult($result); } if (!$this->hasCSRF($config)) { $result = $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'A text message with an authorization code will be sent to your '. 'primary contact number.')); $this->throwResult($result); } // 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) { return id(new PhabricatorMetaMTAMail()) ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE) ->addTos(array($user->getPHID())) ->setForceDelivery(true) ->setSensitiveContent(true) ->setBody( pht( 'Phabricator (%s) MFA Code: %s', $this->getInstallDisplayName(), $envelope->openEnvelope())) ->save(); } private function normalizeSMSCode($code) { return trim($code); } } diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php index 1401724125..f69840abdd 100644 --- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php @@ -1,436 +1,449 @@ getFactorSecret()) * 8; + return pht('%d-Bit Secret', $bits); + } + public function processAddFactorForm( PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { $sync_token = $this->loadMFASyncToken( $request, $form, $user); $secret = $sync_token->getTemporaryTokenProperty('secret'); $code = $request->getStr('totpcode'); $e_code = true; if (!$sync_token->getIsNewTemporaryToken()) { $okay = (bool)$this->getTimestepAtWhichResponseIsValid( $this->getAllowedTimesteps($this->getCurrentTimestep()), new PhutilOpaqueEnvelope($secret), $code); if ($okay) { $config = $this->newConfigForUser($user) ->setFactorName(pht('Mobile App (TOTP)')) ->setFactorSecret($secret) ->setMFASyncToken($sync_token); return $config; } else { if (!strlen($code)) { $e_code = pht('Required'); } else { $e_code = pht('Invalid'); } } } $form->appendInstructions( pht( 'Scan the QR code or manually enter the key shown below into the '. 'application.')); $prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/')); $issuer = $prod_uri->getDomain(); $uri = urisprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s', $issuer, $user->getUsername(), $secret, $issuer); $qrcode = $this->newQRCode($uri); $form->appendChild($qrcode); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Key')) ->setValue(phutil_tag('strong', array(), $secret))); $form->appendInstructions( pht( '(If given an option, select that this key is "Time Based", not '. '"Counter Based".)')); $form->appendInstructions( pht( 'After entering the key, the application should display a numeric '. 'code. Enter that code below to confirm that you have configured '. 'the authenticator correctly:')); $form->appendChild( id(new PHUIFormNumberControl()) ->setLabel(pht('TOTP Code')) ->setName('totpcode') ->setValue($code) ->setError($e_code)); } protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $current_step = $this->getCurrentTimestep(); // If we already issued a valid challenge, don't issue a new one. if ($challenges) { return array(); } // Otherwise, generate a new challenge for the current timestep and compute // the TTL. // When computing the TTL, note that we accept codes within a certain // window of the challenge timestep to account for clock skew and users // needing time to enter codes. // We don't want this challenge to expire until after all valid responses // to it are no longer valid responses to any other challenge we might // issue in the future. If the challenge expires too quickly, we may issue // a new challenge which can accept the same TOTP code response. // This means that we need to keep this challenge alive for double the // window size: if we're currently at timestep 3, the user might respond // with the code for timestep 5. This is valid, since timestep 5 is within // the window for timestep 3. // But the code for timestep 5 can be used to respond at timesteps 3, 4, 5, // 6, and 7. To prevent any valid response to this challenge from being // used again, we need to keep this challenge active until timestep 8. $window_size = $this->getTimestepWindowSize(); $step_duration = $this->getTimestepDuration(); $ttl_steps = ($window_size * 2) + 1; $ttl_seconds = ($ttl_steps * $step_duration); return array( $this->newChallenge($config, $viewer) ->setChallengeKey($current_step) ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), ); } 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('App 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 newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { // If we've already issued a challenge at the current timestep or any // nearby timestep, require that it was issued to the current session. // This is defusing attacks where you (broadly) look at someone's phone // and type the code in more quickly than they do. $session_phid = $viewer->getSession()->getPHID(); $now = PhabricatorTime::getNow(); $engine = $config->getSessionEngine(); $workflow_key = $engine->getWorkflowKey(); $current_timestep = $this->getCurrentTimestep(); foreach ($challenges as $challenge) { $challenge_timestep = (int)$challenge->getChallengeKey(); $wait_duration = ($challenge->getChallengeTTL() - $now) + 1; if ($challenge->getSessionPHID() !== $session_phid) { return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'This factor recently issued a challenge to a different login '. 'session. Wait %s second(s) for the code to cycle, then try '. 'again.', new PhutilNumber($wait_duration))); } if ($challenge->getWorkflowKey() !== $workflow_key) { return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'This factor recently issued a challenge for a different '. 'workflow. Wait %s second(s) for the code to cycle, then try '. 'again.', new PhutilNumber($wait_duration))); } // If the current realtime timestep isn't a valid response to the current // challenge but the challenge hasn't expired yet, we're locking out // the factor to prevent challenge windows from overlapping. Let the user // know that they should wait for a new challenge. $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep); if (!isset($challenge_timesteps[$current_timestep])) { return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'This factor recently issued a challenge which has expired. '. 'A new challenge can not be issued yet. Wait %s second(s) for '. 'the code to cycle, then try again.', new PhutilNumber($wait_duration))); } if ($challenge->getIsReusedChallenge()) { return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'You recently provided a response to this factor. Responses '. 'may not be reused. Wait %s second(s) for the code to cycle, '. 'then try again.', new PhutilNumber($wait_duration))); } } return null; } protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { $code = $this->getChallengeResponseFromRequest( $config, $request); $result = $this->newResult() ->setValue($code); // We expect to reach TOTP validation with exactly one valid challenge. if (count($challenges) !== 1) { throw new Exception( pht( 'Reached TOTP challenge validation with an unexpected number of '. 'unexpired challenges (%d), expected exactly one.', phutil_count($challenges))); } $challenge = head($challenges); // If the client has already provided a valid answer to this challenge and // submitted a token proving they answered it, we're all set. if ($challenge->getIsAnsweredChallenge()) { return $result->setAnsweredChallenge($challenge); } $challenge_timestep = (int)$challenge->getChallengeKey(); $current_timestep = $this->getCurrentTimestep(); $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep); $current_timesteps = $this->getAllowedTimesteps($current_timestep); // We require responses be both valid for the challenge and valid for the // current timestep. A longer challenge TTL doesn't let you use older // codes for a longer period of time. $valid_timestep = $this->getTimestepAtWhichResponseIsValid( array_intersect_key($challenge_timesteps, $current_timesteps), new PhutilOpaqueEnvelope($config->getFactorSecret()), $code); if ($valid_timestep) { $ttl = PhabricatorTime::getNow() + 60; $challenge ->setProperty('totp.timestep', $valid_timestep) ->markChallengeAsAnswered($ttl); $result->setAnsweredChallenge($challenge); } else { if (strlen($code)) { $error_message = pht('Invalid'); } else { $error_message = pht('Required'); } $result->setErrorMessage($error_message); } return $result; } public static function generateNewTOTPKey() { return strtoupper(Filesystem::readRandomCharacters(32)); } public static function base32Decode($buf) { $buf = strtoupper($buf); $map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $map = str_split($map); $map = array_flip($map); $out = ''; $len = strlen($buf); $acc = 0; $bits = 0; for ($ii = 0; $ii < $len; $ii++) { $chr = $buf[$ii]; $val = $map[$chr]; $acc = $acc << 5; $acc = $acc + $val; $bits += 5; if ($bits >= 8) { $bits = $bits - 8; $out .= chr(($acc & (0xFF << $bits)) >> $bits); } } return $out; } public static function getTOTPCode(PhutilOpaqueEnvelope $key, $timestamp) { $binary_timestamp = pack('N*', 0).pack('N*', $timestamp); $binary_key = self::base32Decode($key->openEnvelope()); $hash = hash_hmac('sha1', $binary_timestamp, $binary_key, true); // See RFC 4226. $offset = ord($hash[19]) & 0x0F; $code = ((ord($hash[$offset + 0]) & 0x7F) << 24) | ((ord($hash[$offset + 1]) & 0xFF) << 16) | ((ord($hash[$offset + 2]) & 0xFF) << 8) | ((ord($hash[$offset + 3]) ) ); $code = ($code % 1000000); $code = str_pad($code, 6, '0', STR_PAD_LEFT); return $code; } private function getTimestepDuration() { return 30; } private function getCurrentTimestep() { $duration = $this->getTimestepDuration(); return (int)(PhabricatorTime::getNow() / $duration); } private function getAllowedTimesteps($at_timestep) { $window = $this->getTimestepWindowSize(); $range = range($at_timestep - $window, $at_timestep + $window); return array_fuse($range); } private function getTimestepWindowSize() { // The user is allowed to provide a code from the recent past or the // near future to account for minor clock skew between the client // and server, and the time it takes to actually enter a code. return 1; } private function getTimestepAtWhichResponseIsValid( array $timesteps, PhutilOpaqueEnvelope $key, $code) { foreach ($timesteps as $timestep) { $expect_code = self::getTOTPCode($key, $timestep); if (phutil_hashes_are_identical($code, $expect_code)) { return $timestep; } } return null; } protected function newMFASyncTokenProperties(PhabricatorUser $user) { return array( 'secret' => self::generateNewTOTPKey(), ); } } diff --git a/src/applications/auth/storage/PhabricatorAuthFactorProvider.php b/src/applications/auth/storage/PhabricatorAuthFactorProvider.php index dfc948a423..50031d818d 100644 --- a/src/applications/auth/storage/PhabricatorAuthFactorProvider.php +++ b/src/applications/auth/storage/PhabricatorAuthFactorProvider.php @@ -1,182 +1,191 @@ setProviderFactorKey($factor->getFactorKey()) ->attachFactor($factor) ->setStatus(PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE); } protected function getConfiguration() { return array( self::CONFIG_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'providerFactorKey' => 'text64', 'name' => 'text255', 'status' => 'text32', ), ) + parent::getConfiguration(); } public function getPHIDType() { return PhabricatorAuthAuthFactorProviderPHIDType::TYPECONST; } public function getURI() { return '/auth/mfa/'.$this->getID().'/'; } public function getObjectName() { return pht('MFA Provider %d', $this->getID()); } public function getAuthFactorProviderProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function setAuthFactorProviderProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function attachFactor(PhabricatorAuthFactor $factor) { $this->factor = $factor; return $this; } public function getFactor() { return $this->assertAttached($this->factor); } public function getDisplayName() { $name = $this->getName(); if (strlen($name)) { return $name; } return $this->getFactor()->getFactorName(); } public function newIconView() { return $this->getFactor()->newIconView(); } public function getDisplayDescription() { return $this->getFactor()->getFactorDescription(); } public function processAddFactorForm( AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { $factor = $this->getFactor(); $config = $factor->processAddFactorForm($this, $form, $request, $user); if ($config) { $config->setFactorProviderPHID($this->getPHID()); } return $config; } public function newSortVector() { $factor = $this->getFactor(); return id(new PhutilSortVector()) ->addInt($factor->getFactorOrder()) ->addInt($this->getID()); } public function getEnrollDescription(PhabricatorUser $user) { return $this->getFactor()->getEnrollDescription($this, $user); } public function getEnrollButtonText(PhabricatorUser $user) { return $this->getFactor()->getEnrollButtonText($this, $user); } public function newStatus() { $status_key = $this->getStatus(); return PhabricatorAuthFactorProviderStatus::newForStatus($status_key); } public function canCreateNewConfiguration(PhabricatorUser $user) { return $this->getFactor()->canCreateNewConfiguration($this, $user); } public function getConfigurationCreateDescription(PhabricatorUser $user) { return $this->getFactor()->getConfigurationCreateDescription($this, $user); } + public function getConfigurationListDetails( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer) { + return $this->getFactor()->getConfigurationListDetails( + $config, + $this, + $viewer); + } + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorAuthFactorProviderEditor(); } public function getApplicationTransactionTemplate() { return new PhabricatorAuthFactorProviderTransaction(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { return PhabricatorPolicies::getMostOpenPolicy(); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: break; case PhabricatorPolicyCapability::CAN_EDIT: $extended[] = array( new PhabricatorAuthApplication(), AuthManageProvidersCapability::CAPABILITY, ); break; } return $extended; } } diff --git a/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php b/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php index c355b6e14a..4da09dd324 100644 --- a/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php @@ -1,455 +1,465 @@ isEnrollment = $is_enrollment; return $this; } public function getIsEnrollment() { return $this->isEnrollment; } public function processRequest(AphrontRequest $request) { if ($request->getExists('new') || $request->getExists('providerPHID')) { return $this->processNew($request); } if ($request->getExists('edit')) { return $this->processEdit($request); } if ($request->getExists('delete')) { return $this->processDelete($request); } $user = $this->getUser(); $viewer = $request->getUser(); $factors = id(new PhabricatorAuthFactorConfigQuery()) ->setViewer($viewer) ->withUserPHIDs(array($user->getPHID())) ->execute(); $factors = msort($factors, 'newSortVector'); $rows = array(); $rowc = array(); $highlight_id = $request->getInt('id'); foreach ($factors as $factor) { $provider = $factor->getFactorProvider(); if ($factor->getID() == $highlight_id) { $rowc[] = 'highlighted'; } else { $rowc[] = null; } $status = $provider->newStatus(); $status_icon = $status->getFactorIcon(); $status_color = $status->getFactorColor(); $icon = id(new PHUIIconView()) ->setIcon("{$status_icon} {$status_color}") ->setTooltip(pht('Provider: %s', $status->getName())); + $details = $provider->getConfigurationListDetails($factor, $viewer); + $rows[] = array( $icon, javelin_tag( 'a', array( 'href' => $this->getPanelURI('?edit='.$factor->getID()), 'sigil' => 'workflow', ), $factor->getFactorName()), + $provider->getFactor()->getFactorShortName(), $provider->getDisplayName(), + $details, phabricator_datetime($factor->getDateCreated(), $viewer), javelin_tag( 'a', array( 'href' => $this->getPanelURI('?delete='.$factor->getID()), 'sigil' => 'workflow', 'class' => 'small button button-grey', ), pht('Remove')), ); } $table = new AphrontTableView($rows); $table->setNoDataString( pht("You haven't added any authentication factors to your account yet.")); $table->setHeaders( array( null, pht('Name'), pht('Type'), + pht('Provider'), + pht('Details'), pht('Created'), null, )); $table->setColumnClasses( array( null, 'wide pri', null, + null, + null, 'right', 'action', )); $table->setRowClasses($rowc); $table->setDeviceVisibility( array( true, true, false, false, + false, + false, true, )); $help_uri = PhabricatorEnv::getDoclink( 'User Guide: Multi-Factor Authentication'); $buttons = array(); // If we're enrolling a new account in MFA, provide a small visual hint // that this is the button they want to click. if ($this->getIsEnrollment()) { $add_color = PHUIButtonView::BLUE; } else { $add_color = PHUIButtonView::GREY; } $can_add = (bool)$this->loadActiveMFAProviders(); $buttons[] = id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-plus') ->setText(pht('Add Auth Factor')) ->setHref($this->getPanelURI('?new=true')) ->setWorkflow(true) ->setDisabled(!$can_add) ->setColor($add_color); $buttons[] = id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-book') ->setText(pht('Help')) ->setHref($help_uri) ->setColor(PHUIButtonView::GREY); return $this->newBox(pht('Authentication Factors'), $table, $buttons); } private function processNew(AphrontRequest $request) { $viewer = $request->getUser(); $user = $this->getUser(); $cancel_uri = $this->getPanelURI(); // Check that we have providers before we send the user through the MFA // gate, so you don't authenticate and then immediately get roadblocked. $providers = $this->loadActiveMFAProviders(); if (!$providers) { return $this->newDialog() ->setTitle(pht('No MFA Providers')) ->appendParagraph( pht( 'This install does not have any active MFA providers configured. '. 'At least one provider must be configured and active before you '. 'can add new MFA factors.')) ->addCancelButton($cancel_uri); } $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, $cancel_uri); $selected_phid = $request->getStr('providerPHID'); if (empty($providers[$selected_phid])) { $selected_provider = null; } else { $selected_provider = $providers[$selected_phid]; // Only let the user continue creating a factor for a given provider if // they actually pass the provider's checks. if (!$selected_provider->canCreateNewConfiguration($viewer)) { $selected_provider = null; } } if (!$selected_provider) { $menu = id(new PHUIObjectItemListView()) ->setViewer($viewer) ->setBig(true) ->setFlush(true); foreach ($providers as $provider_phid => $provider) { $provider_uri = id(new PhutilURI($this->getPanelURI())) ->setQueryParam('providerPHID', $provider_phid); $is_enabled = $provider->canCreateNewConfiguration($viewer); $item = id(new PHUIObjectItemView()) ->setHeader($provider->getDisplayName()) ->setImageIcon($provider->newIconView()) ->addAttribute($provider->getDisplayDescription()); if ($is_enabled) { $item ->setHref($provider_uri) ->setClickable(true); } else { $item->setDisabled(true); } $create_description = $provider->getConfigurationCreateDescription( $viewer); if ($create_description) { $item->appendChild($create_description); } $menu->addItem($item); } return $this->newDialog() ->setTitle(pht('Choose Factor Type')) ->appendChild($menu) ->addCancelButton($cancel_uri); } // NOTE: Beyond providing guidance, this step is also providing a CSRF gate // on this endpoint, since prompting the user to respond to a challenge // sometimes requires us to push a challenge to them as a side effect (for // example, with SMS). if (!$request->isFormPost() || !$request->getBool('mfa.start')) { $description = $selected_provider->getEnrollDescription($viewer); return $this->newDialog() ->addHiddenInput('providerPHID', $selected_provider->getPHID()) ->addHiddenInput('mfa.start', 1) ->setTitle(pht('Add Authentication Factor')) ->appendChild(new PHUIRemarkupView($viewer, $description)) ->addCancelButton($cancel_uri) ->addSubmitButton($selected_provider->getEnrollButtonText($viewer)); } $form = id(new AphrontFormView()) ->setViewer($viewer); if ($request->getBool('mfa.enroll')) { // Subject users to rate limiting so that it's difficult to add factors // by pure brute force. This is normally not much of an attack, but push // factor types may have side effects. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthNewFactorAction(), 1); } else { // Test the limit before showing the user a form, so we don't give them // a form which can never possibly work because it will always hit rate // limiting. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthNewFactorAction(), 0); } $config = $selected_provider->processAddFactorForm( $form, $request, $user); if ($config) { // If the user added a factor, give them a rate limiting point back. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthNewFactorAction(), -1); $config->save(); // If we used a temporary token to handle synchronizing the factor, // revoke it now. $sync_token = $config->getMFASyncToken(); if ($sync_token) { $sync_token->revokeToken(); } $log = PhabricatorUserLog::initializeNewLog( $viewer, $user->getPHID(), PhabricatorUserLog::ACTION_MULTI_ADD); $log->save(); $user->updateMultiFactorEnrollment(); // Terminate other sessions so they must log in and survive the // multi-factor auth check. id(new PhabricatorAuthSessionEngine())->terminateLoginSessions( $user, new PhutilOpaqueEnvelope( $request->getCookie(PhabricatorCookies::COOKIE_SESSION))); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI('?id='.$config->getID())); } return $this->newDialog() ->addHiddenInput('providerPHID', $selected_provider->getPHID()) ->addHiddenInput('mfa.start', 1) ->addHiddenInput('mfa.enroll', 1) ->setWidth(AphrontDialogView::WIDTH_FORM) ->setTitle(pht('Add Authentication Factor')) ->appendChild($form->buildLayoutView()) ->addSubmitButton(pht('Continue')) ->addCancelButton($cancel_uri); } private function processEdit(AphrontRequest $request) { $viewer = $request->getUser(); $user = $this->getUser(); $factor = id(new PhabricatorAuthFactorConfig())->loadOneWhere( 'id = %d AND userPHID = %s', $request->getInt('edit'), $user->getPHID()); if (!$factor) { return new Aphront404Response(); } $e_name = true; $errors = array(); if ($request->isFormPost()) { $name = $request->getStr('name'); if (!strlen($name)) { $e_name = pht('Required'); $errors[] = pht( 'Authentication factors must have a name to identify them.'); } if (!$errors) { $factor->setFactorName($name); $factor->save(); $user->updateMultiFactorEnrollment(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI('?id='.$factor->getID())); } } else { $name = $factor->getFactorName(); } $form = id(new AphrontFormView()) ->setUser($viewer) ->appendChild( id(new AphrontFormTextControl()) ->setName('name') ->setLabel(pht('Name')) ->setValue($name) ->setError($e_name)); $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addHiddenInput('edit', $factor->getID()) ->setTitle(pht('Edit Authentication Factor')) ->setErrors($errors) ->appendChild($form->buildLayoutView()) ->addSubmitButton(pht('Save')) ->addCancelButton($this->getPanelURI()); return id(new AphrontDialogResponse()) ->setDialog($dialog); } private function processDelete(AphrontRequest $request) { $viewer = $request->getUser(); $user = $this->getUser(); $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, $this->getPanelURI()); $factor = id(new PhabricatorAuthFactorConfig())->loadOneWhere( 'id = %d AND userPHID = %s', $request->getInt('delete'), $user->getPHID()); if (!$factor) { return new Aphront404Response(); } if ($request->isFormPost()) { $factor->delete(); $log = PhabricatorUserLog::initializeNewLog( $viewer, $user->getPHID(), PhabricatorUserLog::ACTION_MULTI_REMOVE); $log->save(); $user->updateMultiFactorEnrollment(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI()); } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addHiddenInput('delete', $factor->getID()) ->setTitle(pht('Delete Authentication Factor')) ->appendParagraph( pht( 'Really remove the authentication factor %s from your account?', phutil_tag('strong', array(), $factor->getFactorName()))) ->addSubmitButton(pht('Remove Factor')) ->addCancelButton($this->getPanelURI()); return id(new AphrontDialogResponse()) ->setDialog($dialog); } private function loadActiveMFAProviders() { $viewer = $this->getViewer(); $providers = id(new PhabricatorAuthFactorProviderQuery()) ->setViewer($viewer) ->withStatuses( array( PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, )) ->execute(); $providers = mpull($providers, null, 'getPHID'); $providers = msortv($providers, 'newSortVector'); return $providers; } }