Changeset View
Standalone View
src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
Show First 20 Lines • Show All 71 Lines • ▼ Show 20 Lines | if (!strlen($key)) { | ||||
->save(); | ->save(); | ||||
unset($unguarded); | unset($unguarded); | ||||
} | } | ||||
$code = $request->getStr('totpcode'); | $code = $request->getStr('totpcode'); | ||||
$e_code = true; | $e_code = true; | ||||
if ($request->getExists('totp')) { | if ($request->getExists('totp')) { | ||||
$okay = self::verifyTOTPCode( | $okay = $this->verifyTOTPCode( | ||||
$user, | $user, | ||||
new PhutilOpaqueEnvelope($key), | new PhutilOpaqueEnvelope($key), | ||||
$code); | $code); | ||||
if ($okay) { | if ($okay) { | ||||
$config = $this->newConfigForUser($user) | $config = $this->newConfigForUser($user) | ||||
->setFactorName(pht('Mobile App (TOTP)')) | ->setFactorName(pht('Mobile App (TOTP)')) | ||||
->setFactorSecret($key); | ->setFactorSecret($key); | ||||
▲ Show 20 Lines • Show All 56 Lines • ▼ Show 20 Lines | $form->appendChild( | ||||
id(new PHUIFormNumberControl()) | id(new PHUIFormNumberControl()) | ||||
->setLabel(pht('TOTP Code')) | ->setLabel(pht('TOTP Code')) | ||||
->setName('totpcode') | ->setName('totpcode') | ||||
->setValue($code) | ->setValue($code) | ||||
->setError($e_code)); | ->setError($e_code)); | ||||
} | } | ||||
protected function newIssuedChallenges( | |||||
PhabricatorAuthFactorConfig $config, | |||||
PhabricatorUser $viewer, | |||||
array $challenges) { | |||||
$now = $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. It TTLs | |||||
// after it would fall off the bottom of the window. | |||||
$timesteps = $this->getAllowedTimesteps(); | |||||
$min_step = min($timesteps); | |||||
$step_duration = $this->getTimestepDuration(); | |||||
$ttl_steps = ($now - $min_step) + 1; | |||||
$ttl_seconds = ($ttl_steps * $step_duration); | |||||
return array( | |||||
$this->newChallenge($config, $viewer) | |||||
->setChallengeKey($now) | |||||
->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), | |||||
); | |||||
} | |||||
public function renderValidateFactorForm( | public function renderValidateFactorForm( | ||||
PhabricatorAuthFactorConfig $config, | PhabricatorAuthFactorConfig $config, | ||||
AphrontFormView $form, | AphrontFormView $form, | ||||
PhabricatorUser $viewer, | PhabricatorUser $viewer, | ||||
PhabricatorAuthFactorResult $validation_result = null) { | PhabricatorAuthFactorResult $result) { | ||||
if ($validation_result) { | $value = $result->getValue(); | ||||
$value = $validation_result->getValue(); | $error = $result->getErrorMessage(); | ||||
$hint = $validation_result->getHint(); | $is_wait = $result->getIsWait(); | ||||
if ($is_wait) { | |||||
$control = id(new AphrontFormMarkupControl()) | |||||
->setValue($error) | |||||
->setError(pht('Wait')); | |||||
} else { | } else { | ||||
$value = null; | $control = id(new PHUIFormNumberControl()) | ||||
$hint = true; | |||||
} | |||||
$form->appendChild( | |||||
id(new PHUIFormNumberControl()) | |||||
->setName($this->getParameterName($config, 'totpcode')) | ->setName($this->getParameterName($config, 'totpcode')) | ||||
->setLabel(pht('App Code')) | |||||
->setDisableAutocomplete(true) | ->setDisableAutocomplete(true) | ||||
->setCaption(pht('Factor Name: %s', $config->getFactorName())) | |||||
->setValue($value) | ->setValue($value) | ||||
->setError($hint)); | ->setError($error); | ||||
} | |||||
$control | |||||
->setLabel(pht('App Code')) | |||||
->setCaption(pht('Factor Name: %s', $config->getFactorName())); | |||||
$form->appendChild($control); | |||||
} | |||||
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. | |||||
$step_duration = $this->getTimestepDuration(); | |||||
$now = $this->getCurrentTimestep(); | |||||
$timesteps = $this->getAllowedTimesteps(); | |||||
$timesteps = array_fuse($timesteps); | |||||
$min_step = min($timesteps); | |||||
$session_phid = $viewer->getSession()->getPHID(); | |||||
foreach ($challenges as $challenge) { | |||||
$challenge_timestep = (int)$challenge->getChallengeKey(); | |||||
// This challenge isn't for one of the timesteps you'd be able to respond | |||||
// to if you submitted the form right now, so we're good to keep going. | |||||
if (!isset($timesteps[$challenge_timestep])) { | |||||
continue; | |||||
} | |||||
// This is the number of timesteps you need to wait for the problem | |||||
// timestep to leave the window, rounded up. | |||||
$wait_steps = ($challenge_timestep - $min_step) + 1; | |||||
$wait_duration = ($wait_steps * $step_duration); | |||||
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 seconds for the code to cycle, then try '. | |||||
'again.', | |||||
new PhutilNumber($wait_duration))); | |||||
amckinley: Do we have any kind of existing animated progress bar UI elements with a countdown? I looked… | |||||
Done Inline ActionsNothing client side / in JS right now. There's one in the background job thing (bulk editor / bulk exporter) but it "animates" by reloading the page. This is probably not terribly difficult to build and the current UI isn't super hot so maybe I'll take a stab at it. epriestley: Nothing client side / in JS right now. There's one in the background job thing (bulk editor /… | |||||
Not Done Inline ActionsIt occurs to me that the code in the Countdown app is probably up to the challenge for just showing a little timer. amckinley: It occurs to me that the code in the Countdown app is probably up to the challenge for just… | |||||
} | |||||
} | |||||
return null; | |||||
} | } | ||||
public function processValidateFactorForm( | protected function newResultFromChallengeResponse( | ||||
PhabricatorAuthFactorConfig $config, | PhabricatorAuthFactorConfig $config, | ||||
PhabricatorUser $viewer, | PhabricatorUser $viewer, | ||||
AphrontRequest $request) { | AphrontRequest $request, | ||||
array $challenges) { | |||||
$code = $request->getStr($this->getParameterName($config, 'totpcode')); | $code = $request->getStr($this->getParameterName($config, 'totpcode')); | ||||
$key = new PhutilOpaqueEnvelope($config->getFactorSecret()); | $key = new PhutilOpaqueEnvelope($config->getFactorSecret()); | ||||
$result = id(new PhabricatorAuthFactorResult()) | $result = $this->newResult() | ||||
->setValue($code); | ->setValue($code); | ||||
if (self::verifyTOTPCode($viewer, $key, $code)) { | if ($this->verifyTOTPCode($viewer, $key, (string)$code)) { | ||||
$result->setIsValid(true); | $result->setIsValid(true); | ||||
} else { | } else { | ||||
if (strlen($code)) { | if (strlen($code)) { | ||||
$hint = pht('Invalid'); | $error_message = pht('Invalid'); | ||||
} else { | } else { | ||||
$hint = pht('Required'); | $error_message = pht('Required'); | ||||
} | } | ||||
$result->setHint($hint); | $result->setErrorMessage($error_message); | ||||
} | } | ||||
return $result; | return $result; | ||||
} | } | ||||
public static function generateNewTOTPKey() { | public static function generateNewTOTPKey() { | ||||
return strtoupper(Filesystem::readRandomCharacters(32)); | return strtoupper(Filesystem::readRandomCharacters(32)); | ||||
} | } | ||||
public static function verifyTOTPCode( | private function verifyTOTPCode( | ||||
PhabricatorUser $user, | PhabricatorUser $user, | ||||
PhutilOpaqueEnvelope $key, | PhutilOpaqueEnvelope $key, | ||||
$code) { | $code) { | ||||
$now = (int)(time() / 30); | $now = (int)(time() / 30); | ||||
// Allow the user to enter a code a few minutes away on either side, in | // Allow the user to enter a code a few minutes away on either side, in | ||||
// case the server or client has some clock skew. | // case the server or client has some clock skew. | ||||
for ($offset = -2; $offset <= 2; $offset++) { | for ($offset = -2; $offset <= 2; $offset++) { | ||||
Not Done Inline ActionsIt's not worth changing if this code is about to go away, but I'm surprised it doesn't use getAllowedTimesteps. amckinley: It's not worth changing if this code is about to go away, but I'm surprised it doesn't use… | |||||
Done Inline ActionsYeah, I just didn't want to break this by accident between now and when I (hopefully) make it vanish. epriestley: Yeah, I just didn't want to break this by accident between now and when I (hopefully) make it… | |||||
$real = self::getTOTPCode($key, $now + $offset); | $real = self::getTOTPCode($key, $now + $offset); | ||||
if (phutil_hashes_are_identical($real, $code)) { | if (phutil_hashes_are_identical($real, $code)) { | ||||
return true; | return true; | ||||
} | } | ||||
} | } | ||||
// TODO: After validating a code, this should mark it as used and prevent | // TODO: After validating a code, this should mark it as used and prevent | ||||
// it from being reused. | // it from being reused. | ||||
return false; | return false; | ||||
} | } | ||||
Done Inline ActionsParticularly, this is eventually going to change to require you to submit a response to a specific challenge we issued earlier, not just a valid response for the current timestep. Until it does, a lot of the other checks can't be completely effective. epriestley: Particularly, this is eventually going to change to require you to submit a response to a… | |||||
public static function base32Decode($buf) { | public static function base32Decode($buf) { | ||||
$buf = strtoupper($buf); | $buf = strtoupper($buf); | ||||
$map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | $map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | ||||
$map = str_split($map); | $map = str_split($map); | ||||
$map = array_flip($map); | $map = array_flip($map); | ||||
▲ Show 20 Lines • Show All 78 Lines • ▼ Show 20 Lines | private function renderQRCode($uri) { | ||||
return phutil_tag( | return phutil_tag( | ||||
'table', | 'table', | ||||
array( | array( | ||||
'style' => 'margin: 24px auto;', | 'style' => 'margin: 24px auto;', | ||||
), | ), | ||||
$rows); | $rows); | ||||
} | } | ||||
private function getTimestepDuration() { | |||||
return 30; | |||||
} | |||||
private function getCurrentTimestep() { | |||||
$duration = $this->getTimestepDuration(); | |||||
return (int)(PhabricatorTime::getNow() / $duration); | |||||
} | |||||
private function getAllowedTimesteps() { | |||||
$now = $this->getCurrentTimestep(); | |||||
return range($now - 2, $now + 2); | |||||
} | |||||
} | } |
Do we have any kind of existing animated progress bar UI elements with a countdown? I looked around in /uiexample/ but didn't see anything obvious.