@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Explicitly mark MFA challenges as "answered" and "completed"

Summary:
Depends on D19893. Ref T13222. See PHI873. A challenge is "answered" if you provide a valid response. A challenge is "completed" if we let you through the MFA check and do whatever actual action the check is protecting.

If you only have one MFA factor, challenges will be "completed" immediately after they are "answered". However, if you have two or more factors, it's possible to "answer" one or more prompts, but fewer than all of the prompts, and end up with "answered" challenges that are not "completed".

In the future, it may also be possible to answer all the challenges but then have an error occur before they are marked "completed" (for example, a unique key collision in the transaction code). For now, nothing interesting happens between "answered" and "completed". This would take the form of the caller explicitly providing flags like "wait to mark the challenges as completed until I do something" and "okay, mark the challenges as completed now".

This change prevents all token reuse, even on the same workflow. Future changes will let the answered challenges "stick" to the client form so you don't have to re-answer challenges for a short period of time if you hit a unique key collision.

Test Plan:
- Used a token to get through an MFA gate.
- Tried to go through another gate, was told to wait for a long time for the next challenge window.

Reviewers: amckinley

Reviewed By: amckinley

Subscribers: PHID-OPKG-gm6ozazyms6q6i22gyam

Maniphest Tasks: T13222

Differential Revision: https://secure.phabricator.com/D19894

+174 -6
+2
resources/sql/autopatches/20181217.auth.01.digest.sql
··· 1 + ALTER TABLE {$NAMESPACE}_auth.auth_challenge 2 + ADD responseDigest VARCHAR(255) COLLATE {$COLLATE_TEXT};
+2
resources/sql/autopatches/20181217.auth.02.ttl.sql
··· 1 + ALTER TABLE {$NAMESPACE}_auth.auth_challenge 2 + ADD responseTTL INT UNSIGNED;
+2
resources/sql/autopatches/20181217.auth.03.completed.sql
··· 1 + ALTER TABLE {$NAMESPACE}_auth.auth_challenge 2 + ADD isCompleted BOOL NOT NULL;
+12
src/applications/auth/engine/PhabricatorAuthSessionEngine.php
··· 576 576 continue; 577 577 } 578 578 579 + $issued_challenges = idx($challenge_map, $factor_phid, array()); 580 + 579 581 $impl = $factor->requireImplementation(); 580 582 581 583 $validation_result = $impl->getResultFromChallengeResponse( ··· 592 594 } 593 595 594 596 if ($ok) { 597 + // We're letting you through, so mark all the challenges you 598 + // responded to as completed. These challenges can never be used 599 + // again, even by the same session and workflow: you can't use the 600 + // same response to take two different actions, even if those actions 601 + // are of the same type. 602 + foreach ($validation_results as $validation_result) { 603 + $challenge = $validation_result->getAnsweredChallenge() 604 + ->markChallengeAsCompleted(); 605 + } 606 + 595 607 // Give the user a credit back for a successful factor verification. 596 608 PhabricatorSystemActionEngine::willTakeAction( 597 609 array($viewer->getPHID()),
+1 -1
src/applications/auth/factor/PhabricatorAuthFactor.php
··· 45 45 46 46 $engine = $config->getSessionEngine(); 47 47 48 - return id(new PhabricatorAuthChallenge()) 48 + return PhabricatorAuthChallenge::initializeNewChallenge() 49 49 ->setUserPHID($viewer->getPHID()) 50 50 ->setSessionPHID($viewer->getSession()->getPHID()) 51 51 ->setFactorPHID($config->getPHID())
+21 -4
src/applications/auth/factor/PhabricatorAuthFactorResult.php
··· 3 3 final class PhabricatorAuthFactorResult 4 4 extends Phobject { 5 5 6 - private $isValid = false; 6 + private $answeredChallenge; 7 7 private $isWait = false; 8 8 private $errorMessage; 9 9 private $value; 10 10 private $issuedChallenges = array(); 11 11 12 - public function setIsValid($is_valid) { 13 - $this->isValid = $is_valid; 12 + public function setAnsweredChallenge(PhabricatorAuthChallenge $challenge) { 13 + if (!$challenge->getIsAnsweredChallenge()) { 14 + throw new PhutilInvalidStateException('markChallengeAsAnswered'); 15 + } 16 + 17 + if ($challenge->getIsCompleted()) { 18 + throw new Exception( 19 + pht( 20 + 'A completed challenge was provided as an answered challenge. '. 21 + 'The underlying factor is implemented improperly, challenges '. 22 + 'may not be reused.')); 23 + } 24 + 25 + $this->answeredChallenge = $challenge; 26 + 14 27 return $this; 15 28 } 16 29 30 + public function getAnsweredChallenge() { 31 + return $this->answeredChallenge; 32 + } 33 + 17 34 public function getIsValid() { 18 - return $this->isValid; 35 + return (bool)$this->getAnsweredChallenge(); 19 36 } 20 37 21 38 public function setIsWait($is_wait) {
+21 -1
src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
··· 283 283 'the code to cycle, then try again.', 284 284 new PhutilNumber($wait_duration))); 285 285 } 286 + 287 + if ($challenge->getIsReusedChallenge()) { 288 + return $this->newResult() 289 + ->setIsWait(true) 290 + ->setErrorMessage( 291 + pht( 292 + 'You recently provided a response to this factor. Responses '. 293 + 'may not be reused. Wait %s second(s) for the code to cycle, '. 294 + 'then try again.', 295 + new PhutilNumber($wait_duration))); 296 + } 286 297 } 287 298 288 299 return null; ··· 325 336 (string)$code); 326 337 327 338 if ($valid_timestep) { 328 - $result->setIsValid(true); 339 + $now = PhabricatorTime::getNow(); 340 + $step_duration = $this->getTimestepDuration(); 341 + $step_window = $this->getTimestepWindowSize(); 342 + $ttl = $now + ($step_duration * $step_window); 343 + 344 + $challenge 345 + ->setProperty('totp.timestep', $valid_timestep) 346 + ->markChallengeAsAnswered($ttl); 347 + 348 + $result->setAnsweredChallenge($challenge); 329 349 } else { 330 350 if (strlen($code)) { 331 351 $error_message = pht('Invalid');
+113
src/applications/auth/storage/PhabricatorAuthChallenge.php
··· 10 10 protected $workflowKey; 11 11 protected $challengeKey; 12 12 protected $challengeTTL; 13 + protected $responseDigest; 14 + protected $responseTTL; 15 + protected $isCompleted; 13 16 protected $properties = array(); 14 17 18 + private $responseToken; 19 + 20 + const TOKEN_DIGEST_KEY = 'auth.challenge.token'; 21 + 22 + public static function initializeNewChallenge() { 23 + return id(new self()) 24 + ->setIsCompleted(0); 25 + } 26 + 15 27 protected function getConfiguration() { 16 28 return array( 17 29 self::CONFIG_SERIALIZATION => array( ··· 22 34 'challengeKey' => 'text255', 23 35 'challengeTTL' => 'epoch', 24 36 'workflowKey' => 'text255', 37 + 'responseDigest' => 'text255?', 38 + 'responseTTL' => 'epoch?', 39 + 'isCompleted' => 'bool', 25 40 ), 26 41 self::CONFIG_KEY_SCHEMA => array( 27 42 'key_issued' => array( ··· 36 51 37 52 public function getPHIDType() { 38 53 return PhabricatorAuthChallengePHIDType::TYPECONST; 54 + } 55 + 56 + public function getIsReusedChallenge() { 57 + if ($this->getIsCompleted()) { 58 + return true; 59 + } 60 + 61 + // TODO: A challenge is "reused" if it has been answered previously and 62 + // the request doesn't include proof that the client provided the answer. 63 + // Since we aren't tracking client responses yet, any answered challenge 64 + // is always a reused challenge for now. 65 + 66 + return $this->getIsAnsweredChallenge(); 67 + } 68 + 69 + public function getIsAnsweredChallenge() { 70 + return (bool)$this->getResponseDigest(); 71 + } 72 + 73 + public function markChallengeAsAnswered($ttl) { 74 + $token = Filesystem::readRandomCharacters(32); 75 + $token = new PhutilOpaqueEnvelope($token); 76 + 77 + return $this 78 + ->setResponseToken($token, $ttl) 79 + ->save(); 80 + } 81 + 82 + public function markChallengeAsCompleted() { 83 + return $this 84 + ->setIsCompleted(true) 85 + ->save(); 86 + } 87 + 88 + public function setResponseToken(PhutilOpaqueEnvelope $token, $ttl) { 89 + if (!$this->getUserPHID()) { 90 + throw new PhutilInvalidStateException('setUserPHID'); 91 + } 92 + 93 + if ($this->responseToken) { 94 + throw new Exception( 95 + pht( 96 + 'This challenge already has a response token; you can not '. 97 + 'set a new response token.')); 98 + } 99 + 100 + $now = PhabricatorTime::getNow(); 101 + if ($ttl < $now) { 102 + throw new Exception( 103 + pht( 104 + 'Response TTL is invalid: TTLs must be an epoch timestamp '. 105 + 'coresponding to a future time (did you use a relative TTL by '. 106 + 'mistake?).')); 107 + } 108 + 109 + if (preg_match('/ /', $token->openEnvelope())) { 110 + throw new Exception( 111 + pht( 112 + 'The response token for this challenge is invalid: response '. 113 + 'tokens may not include spaces.')); 114 + } 115 + 116 + $digest = PhabricatorHash::digestWithNamedKey( 117 + $token->openEnvelope(), 118 + self::TOKEN_DIGEST_KEY); 119 + 120 + if ($this->responseDigest !== null) { 121 + if (!phutil_hashes_are_identical($digest, $this->responseDigest)) { 122 + throw new Exception( 123 + pht( 124 + 'Invalid response token for this challenge: token digest does '. 125 + 'not match stored digest.')); 126 + } 127 + } else { 128 + $this->responseDigest = $digest; 129 + } 130 + 131 + $this->responseToken = $token; 132 + $this->responseTTL = $ttl; 133 + 134 + return $this; 135 + } 136 + 137 + public function setResponseDigest($value) { 138 + throw new Exception( 139 + pht( 140 + 'You can not set the response digest for a challenge directly. '. 141 + 'Instead, set a response token. A response digest will be computed '. 142 + 'automatically.')); 143 + } 144 + 145 + public function setProperty($key, $value) { 146 + $this->properties[$key] = $value; 147 + return $this; 148 + } 149 + 150 + public function getProperty($key, $default = null) { 151 + return $this->properties[$key]; 39 152 } 40 153 41 154