@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
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Carry MFA responses which have been "answered" but not "completed" through the MFA workflow

Summary:
Depends on D19894. Ref T13222. See PHI873. When you provide a correct response to an MFA challenge, we mark it as "answered".

Currently, we never let you reuse an "answered" token. That's usually fine, but if you have 2+ factors on your account and get one or more (but fewer than all of them) right when you submit the form, you need to answer them all again, possibly after waiting for a lockout period. This is needless.

When you answer a challenge correctly, add a hidden input with a code proving you got it right so you don't need to provide another answer for a little while.

Why not just put your response in a form input, e.g. `<input type="hidden" name="totp-response" value="123456" />`?

- We may allow the "answered" response to be valid for a different amount of time than the actual answer. For TOTP, we currently allow a response to remain valid for 60 seconds, but the actual code you entered might expire sooner.
- In some cases, there's no response we can provide (with push + approve MFA, you don't enter a code, you just tap "yes, allow this" on your phone). Conceivably, we may not be able to re-verify a push+approve code if the remote implements one-shot answers.
- The "responseToken" stuff may end up embedded in normal forms in some cases in the future, and this approach just generally reduces the amount of plaintext MFA we have floating around.

Test Plan:
- Added 2 MFA tokens to my account.
- Hit the MFA prompt.
- Provided one good response and one bad response.
- Submitted the form.
- Old behavior: good response gets locked out for ~120 seconds.
- New behavior: good response is marked "answered", fixing the other response lets me submit the form.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13222

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

+164 -25
+19
src/applications/auth/engine/PhabricatorAuthSessionEngine.php
··· 517 517 ->withUserPHIDs(array($viewer->getPHID())) 518 518 ->withChallengeTTLBetween($now, null) 519 519 ->execute(); 520 + 521 + PhabricatorAuthChallenge::newChallengeResponsesFromRequest( 522 + $challenges, 523 + $request); 524 + 520 525 $challenge_map = mgroup($challenges, 'getFactorPHID'); 521 526 522 527 $validation_results = array(); ··· 710 715 ->setUser($viewer) 711 716 ->appendRemarkupInstructions(''); 712 717 718 + $answered = array(); 713 719 foreach ($factors as $factor) { 714 720 $result = $validation_results[$factor->getPHID()]; 715 721 ··· 718 724 $form, 719 725 $viewer, 720 726 $result); 727 + 728 + $answered_challenge = $result->getAnsweredChallenge(); 729 + if ($answered_challenge) { 730 + $answered[] = $answered_challenge; 731 + } 721 732 } 722 733 723 734 $form->appendRemarkupInstructions(''); 735 + 736 + if ($answered) { 737 + $http_params = PhabricatorAuthChallenge::newHTTPParametersFromChallenges( 738 + $answered); 739 + foreach ($http_params as $key => $value) { 740 + $form->addHiddenInput($key, $value); 741 + } 742 + } 724 743 725 744 return $form; 726 745 }
+34
src/applications/auth/factor/PhabricatorAuthFactor.php
··· 165 165 AphrontRequest $request, 166 166 array $challenges); 167 167 168 + final protected function newAutomaticControl( 169 + PhabricatorAuthFactorResult $result) { 170 + 171 + $is_answered = (bool)$result->getAnsweredChallenge(); 172 + if ($is_answered) { 173 + return $this->newAnsweredControl($result); 174 + } 175 + 176 + $is_wait = $result->getIsWait(); 177 + if ($is_wait) { 178 + return $this->newWaitControl($result); 179 + } 180 + 181 + return null; 182 + } 183 + 184 + private function newWaitControl( 185 + PhabricatorAuthFactorResult $result) { 186 + 187 + $error = $result->getErrorMessage(); 188 + 189 + return id(new AphrontFormMarkupControl()) 190 + ->setValue($error) 191 + ->setError(pht('Wait')); 192 + } 193 + 194 + private function newAnsweredControl( 195 + PhabricatorAuthFactorResult $result) { 196 + 197 + return id(new AphrontFormMarkupControl()) 198 + ->setValue(pht('Answered!')); 199 + } 200 + 201 + 168 202 }
+10 -8
src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
··· 202 202 PhabricatorUser $viewer, 203 203 PhabricatorAuthFactorResult $result) { 204 204 205 - $value = $result->getValue(); 206 - $error = $result->getErrorMessage(); 207 - $is_wait = $result->getIsWait(); 205 + $control = $this->newAutomaticControl($result); 206 + if (!$control) { 207 + $value = $result->getValue(); 208 + $error = $result->getErrorMessage(); 208 209 209 - if ($is_wait) { 210 - $control = id(new AphrontFormMarkupControl()) 211 - ->setValue($error) 212 - ->setError(pht('Wait')); 213 - } else { 214 210 $control = id(new PHUIFormNumberControl()) 215 211 ->setName($this->getParameterName($config, 'totpcode')) 216 212 ->setDisableAutocomplete(true) ··· 320 316 } 321 317 322 318 $challenge = head($challenges); 319 + 320 + // If the client has already provided a valid answer to this challenge and 321 + // submitted a token proving they answered it, we're all set. 322 + if ($challenge->getIsAnsweredChallenge()) { 323 + return $result->setAnsweredChallenge($challenge); 324 + } 323 325 324 326 $challenge_timestep = (int)$challenge->getChallengeKey(); 325 327 $current_timestep = $this->getCurrentTimestep();
+101 -17
src/applications/auth/storage/PhabricatorAuthChallenge.php
··· 17 17 18 18 private $responseToken; 19 19 20 + const HTTPKEY = '__hisec.challenges__'; 20 21 const TOKEN_DIGEST_KEY = 'auth.challenge.token'; 21 22 22 23 public static function initializeNewChallenge() { ··· 24 25 ->setIsCompleted(0); 25 26 } 26 27 28 + public static function newHTTPParametersFromChallenges(array $challenges) { 29 + assert_instances_of($challenges, __CLASS__); 30 + 31 + $token_list = array(); 32 + foreach ($challenges as $challenge) { 33 + $token = $challenge->getResponseToken(); 34 + if ($token) { 35 + $token_list[] = sprintf( 36 + '%s:%s', 37 + $challenge->getPHID(), 38 + $token->openEnvelope()); 39 + } 40 + } 41 + 42 + if (!$token_list) { 43 + return array(); 44 + } 45 + 46 + $token_list = implode(' ', $token_list); 47 + 48 + return array( 49 + self::HTTPKEY => $token_list, 50 + ); 51 + } 52 + 53 + public static function newChallengeResponsesFromRequest( 54 + array $challenges, 55 + AphrontRequest $request) { 56 + assert_instances_of($challenges, __CLASS__); 57 + 58 + $token_list = $request->getStr(self::HTTPKEY); 59 + $token_list = explode(' ', $token_list); 60 + 61 + $token_map = array(); 62 + foreach ($token_list as $token_element) { 63 + $token_element = trim($token_element, ' '); 64 + 65 + if (!strlen($token_element)) { 66 + continue; 67 + } 68 + 69 + // NOTE: This error message is intentionally not printing the token to 70 + // avoid disclosing it. As a result, it isn't terribly useful, but no 71 + // normal user should ever end up here. 72 + if (!preg_match('/^[^:]+:/', $token_element)) { 73 + throw new Exception( 74 + pht( 75 + 'This request included an improperly formatted MFA challenge '. 76 + 'token and can not be processed.')); 77 + } 78 + 79 + list($phid, $token) = explode(':', $token_element, 2); 80 + 81 + if (isset($token_map[$phid])) { 82 + throw new Exception( 83 + pht( 84 + 'This request improperly specifies an MFA challenge token ("%s") '. 85 + 'multiple times and can not be processed.', 86 + $phid)); 87 + } 88 + 89 + $token_map[$phid] = new PhutilOpaqueEnvelope($token); 90 + } 91 + 92 + $challenges = mpull($challenges, null, 'getPHID'); 93 + 94 + $now = PhabricatorTime::getNow(); 95 + foreach ($challenges as $challenge_phid => $challenge) { 96 + // If the response window has expired, don't attach the token. 97 + if ($challenge->getResponseTTL() < $now) { 98 + continue; 99 + } 100 + 101 + $token = idx($token_map, $challenge_phid); 102 + if (!$token) { 103 + continue; 104 + } 105 + 106 + $challenge->setResponseToken($token); 107 + } 108 + } 109 + 110 + 27 111 protected function getConfiguration() { 28 112 return array( 29 113 self::CONFIG_SERIALIZATION => array( ··· 58 142 return true; 59 143 } 60 144 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. 145 + if (!$this->getIsAnsweredChallenge()) { 146 + return false; 147 + } 148 + 149 + // If the challenge has been answered but the client has provided a token 150 + // proving that they answered it, this is still a valid response. 151 + if ($this->getResponseToken()) { 152 + return false; 153 + } 65 154 66 - return $this->getIsAnsweredChallenge(); 155 + return true; 67 156 } 68 157 69 158 public function getIsAnsweredChallenge() { ··· 75 164 $token = new PhutilOpaqueEnvelope($token); 76 165 77 166 return $this 78 - ->setResponseToken($token, $ttl) 167 + ->setResponseToken($token) 168 + ->setResponseTTL($ttl) 79 169 ->save(); 80 170 } 81 171 ··· 85 175 ->save(); 86 176 } 87 177 88 - public function setResponseToken(PhutilOpaqueEnvelope $token, $ttl) { 178 + public function setResponseToken(PhutilOpaqueEnvelope $token) { 89 179 if (!$this->getUserPHID()) { 90 180 throw new PhutilInvalidStateException('setUserPHID'); 91 181 } ··· 97 187 'set a new response token.')); 98 188 } 99 189 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 190 if (preg_match('/ /', $token->openEnvelope())) { 110 191 throw new Exception( 111 192 pht( ··· 129 210 } 130 211 131 212 $this->responseToken = $token; 132 - $this->responseTTL = $ttl; 133 213 134 214 return $this; 215 + } 216 + 217 + public function getResponseToken() { 218 + return $this->responseToken; 135 219 } 136 220 137 221 public function setResponseDigest($value) {