mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-24 14:30:56 +01:00
When accepting a TOTP response, require it respond explicitly to a specific challenge
Summary: Depends on D19890. Ref T13222. See PHI873. Currently, we only validate TOTP responses against the current (realtime) timestep. Instead, also validate them against a specific challenge. This mostly just moves us toward more specifically preventing responses from being reused, and supporting flows which must look more like this (SMS/push). One rough edge here is that during the T+3 and T+4 windows (you request a prompt, then wait 60-120 seconds to respond) only past responses actually work (the current code on your device won't). For example: - At T+0, you request MFA. We issue a T+0 challenge that accepts codes T-2, T-1, T+0, T+1, and T+2. The challenge locks out T+3 and T+4 to prevent the window from overlapping with the next challenge we may issue (see D19890). - If you wait 60 seconds until T+3 to actually submit a code, the realtime valid responses are T+1, T+2, T+3, T+4, T+5. The challenge valid responses are T-2, T-1, T+0, T+1, and T+2. Only T+1 and T+2 are in the intersection. Your device is showing T+3 if the clock is right, so if you type in what's shown on your device it won't be accepted. - This //may// get refined in future changes, but, in the worst case, it's probably fine if it doesn't. Beyond 120s you'll get a new challenge and a full [-2, ..., +2] window to respond, so this lockout is temporary even if you manage to hit it. - If this //doesn't// get refined, I'll change the UI to say "This factor recently issued a challenge which has expired, wait N seconds." to smooth this over a bit. Test Plan: - Went through MFA. - Added a new TOTP factor. - Hit some error cases on purpose. - Tried to use an old code a moment after it expired, got rejected. - Waited 60+ seconds, tried to use the current displayed factor, got rejected (this isn't great, but currently expected). Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13222 Differential Revision: https://secure.phabricator.com/D19893
This commit is contained in:
parent
0673e79d6d
commit
657f3c3806
1 changed files with 69 additions and 31 deletions
|
@ -77,10 +77,10 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
|
||||||
|
|
||||||
$e_code = true;
|
$e_code = true;
|
||||||
if ($request->getExists('totp')) {
|
if ($request->getExists('totp')) {
|
||||||
$okay = $this->verifyTOTPCode(
|
$okay = (bool)$this->getTimestepAtWhichResponseIsValid(
|
||||||
$user,
|
$this->getAllowedTimesteps($this->getCurrentTimestep()),
|
||||||
new PhutilOpaqueEnvelope($key),
|
new PhutilOpaqueEnvelope($key),
|
||||||
$code);
|
(string)$code);
|
||||||
|
|
||||||
if ($okay) {
|
if ($okay) {
|
||||||
$config = $this->newConfigForUser($user)
|
$config = $this->newConfigForUser($user)
|
||||||
|
@ -240,6 +240,8 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
|
||||||
$engine = $config->getSessionEngine();
|
$engine = $config->getSessionEngine();
|
||||||
$workflow_key = $engine->getWorkflowKey();
|
$workflow_key = $engine->getWorkflowKey();
|
||||||
|
|
||||||
|
$current_timestep = $this->getCurrentTimestep();
|
||||||
|
|
||||||
foreach ($challenges as $challenge) {
|
foreach ($challenges as $challenge) {
|
||||||
$challenge_timestep = (int)$challenge->getChallengeKey();
|
$challenge_timestep = (int)$challenge->getChallengeKey();
|
||||||
$wait_duration = ($challenge->getChallengeTTL() - $now) + 1;
|
$wait_duration = ($challenge->getChallengeTTL() - $now) + 1;
|
||||||
|
@ -265,6 +267,22 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
|
||||||
'again.',
|
'again.',
|
||||||
new PhutilNumber($wait_duration)));
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -277,12 +295,36 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
|
||||||
array $challenges) {
|
array $challenges) {
|
||||||
|
|
||||||
$code = $request->getStr($this->getParameterName($config, 'totpcode'));
|
$code = $request->getStr($this->getParameterName($config, 'totpcode'));
|
||||||
$key = new PhutilOpaqueEnvelope($config->getFactorSecret());
|
|
||||||
|
|
||||||
$result = $this->newResult()
|
$result = $this->newResult()
|
||||||
->setValue($code);
|
->setValue($code);
|
||||||
|
|
||||||
if ($this->verifyTOTPCode($viewer, $key, (string)$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);
|
||||||
|
|
||||||
|
$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()),
|
||||||
|
(string)$code);
|
||||||
|
|
||||||
|
if ($valid_timestep) {
|
||||||
$result->setIsValid(true);
|
$result->setIsValid(true);
|
||||||
} else {
|
} else {
|
||||||
if (strlen($code)) {
|
if (strlen($code)) {
|
||||||
|
@ -300,29 +342,6 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
|
||||||
return strtoupper(Filesystem::readRandomCharacters(32));
|
return strtoupper(Filesystem::readRandomCharacters(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function verifyTOTPCode(
|
|
||||||
PhabricatorUser $user,
|
|
||||||
PhutilOpaqueEnvelope $key,
|
|
||||||
$code) {
|
|
||||||
|
|
||||||
$now = (int)(time() / 30);
|
|
||||||
|
|
||||||
// Allow the user to enter a code a few minutes away on either side, in
|
|
||||||
// case the server or client has some clock skew.
|
|
||||||
for ($offset = -2; $offset <= 2; $offset++) {
|
|
||||||
$real = self::getTOTPCode($key, $now + $offset);
|
|
||||||
if (phutil_hashes_are_identical($real, $code)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: After validating a code, this should mark it as used and prevent
|
|
||||||
// it from being reused.
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public static function base32Decode($buf) {
|
public static function base32Decode($buf) {
|
||||||
$buf = strtoupper($buf);
|
$buf = strtoupper($buf);
|
||||||
|
|
||||||
|
@ -424,15 +443,34 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
|
||||||
return (int)(PhabricatorTime::getNow() / $duration);
|
return (int)(PhabricatorTime::getNow() / $duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAllowedTimesteps() {
|
private function getAllowedTimesteps($at_timestep) {
|
||||||
$current_step = $this->getCurrentTimestep();
|
|
||||||
$window = $this->getTimestepWindowSize();
|
$window = $this->getTimestepWindowSize();
|
||||||
return range($current_step - $window, $current_step + $window);
|
$range = range($at_timestep - $window, $at_timestep + $window);
|
||||||
|
return array_fuse($range);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getTimestepWindowSize() {
|
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 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue