@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.

Allow different MFA factor types (SMS, TOTP, Duo, ...) to share "sync" tokens when enrolling new factors

Summary:
Depends on D20019. Ref T13222. Currently, TOTP uses a temporary token to make sure you've set up the app on your phone properly and that you're providing an answer to a secret which we generated (not an attacker-generated secret).

However, most factor types need some kind of sync token. SMS needs to send you a code; Duo needs to store a transaction ID. Turn this "TOTP" token into an "MFA Sync" token and lift the implementation up to the base class.

Also, slightly simplify some of the HTTP form gymnastics.

Test Plan:
- Hit the TOTP enroll screen.
- Reloaded it, got new secrets.
- Reloaded it more than 10 times, got told to stop generating new challenges.
- Answered a challenge properly, got a new TOTP factor.
- Grepped for removed class name.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13222

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

+166 -78
+2 -2
src/__phutil_library_map__.php
··· 2265 2265 'PhabricatorAuthLoginMessageType' => 'applications/auth/message/PhabricatorAuthLoginMessageType.php', 2266 2266 'PhabricatorAuthLogoutConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthLogoutConduitAPIMethod.php', 2267 2267 'PhabricatorAuthMFAEditEngineExtension' => 'applications/auth/engineextension/PhabricatorAuthMFAEditEngineExtension.php', 2268 + 'PhabricatorAuthMFASyncTemporaryTokenType' => 'applications/auth/factor/PhabricatorAuthMFASyncTemporaryTokenType.php', 2268 2269 'PhabricatorAuthMainMenuBarExtension' => 'applications/auth/extension/PhabricatorAuthMainMenuBarExtension.php', 2269 2270 'PhabricatorAuthManagementCachePKCS8Workflow' => 'applications/auth/management/PhabricatorAuthManagementCachePKCS8Workflow.php', 2270 2271 'PhabricatorAuthManagementLDAPWorkflow' => 'applications/auth/management/PhabricatorAuthManagementLDAPWorkflow.php', ··· 2359 2360 'PhabricatorAuthSetPasswordController' => 'applications/auth/controller/PhabricatorAuthSetPasswordController.php', 2360 2361 'PhabricatorAuthSetupCheck' => 'applications/config/check/PhabricatorAuthSetupCheck.php', 2361 2362 'PhabricatorAuthStartController' => 'applications/auth/controller/PhabricatorAuthStartController.php', 2362 - 'PhabricatorAuthTOTPKeyTemporaryTokenType' => 'applications/auth/factor/PhabricatorAuthTOTPKeyTemporaryTokenType.php', 2363 2363 'PhabricatorAuthTemporaryToken' => 'applications/auth/storage/PhabricatorAuthTemporaryToken.php', 2364 2364 'PhabricatorAuthTemporaryTokenGarbageCollector' => 'applications/auth/garbagecollector/PhabricatorAuthTemporaryTokenGarbageCollector.php', 2365 2365 'PhabricatorAuthTemporaryTokenQuery' => 'applications/auth/query/PhabricatorAuthTemporaryTokenQuery.php', ··· 7986 7986 'PhabricatorAuthLoginMessageType' => 'PhabricatorAuthMessageType', 7987 7987 'PhabricatorAuthLogoutConduitAPIMethod' => 'PhabricatorAuthConduitAPIMethod', 7988 7988 'PhabricatorAuthMFAEditEngineExtension' => 'PhabricatorEditEngineExtension', 7989 + 'PhabricatorAuthMFASyncTemporaryTokenType' => 'PhabricatorAuthTemporaryTokenType', 7989 7990 'PhabricatorAuthMainMenuBarExtension' => 'PhabricatorMainMenuBarExtension', 7990 7991 'PhabricatorAuthManagementCachePKCS8Workflow' => 'PhabricatorAuthManagementWorkflow', 7991 7992 'PhabricatorAuthManagementLDAPWorkflow' => 'PhabricatorAuthManagementWorkflow', ··· 8101 8102 'PhabricatorAuthSetPasswordController' => 'PhabricatorAuthController', 8102 8103 'PhabricatorAuthSetupCheck' => 'PhabricatorSetupCheck', 8103 8104 'PhabricatorAuthStartController' => 'PhabricatorAuthController', 8104 - 'PhabricatorAuthTOTPKeyTemporaryTokenType' => 'PhabricatorAuthTemporaryTokenType', 8105 8105 'PhabricatorAuthTemporaryToken' => array( 8106 8106 'PhabricatorAuthDAO', 8107 8107 'PhabricatorPolicyInterface',
+100
src/applications/auth/factor/PhabricatorAuthFactor.php
··· 245 245 } 246 246 247 247 248 + /* -( Synchronizing New Factors )------------------------------------------ */ 249 + 250 + 251 + final protected function loadMFASyncToken( 252 + AphrontRequest $request, 253 + AphrontFormView $form, 254 + PhabricatorUser $user) { 255 + 256 + // If the form included a synchronization key, load the corresponding 257 + // token. The user must synchronize to a key we generated because this 258 + // raises the barrier to theoretical attacks where an attacker might 259 + // provide a known key for factors like TOTP. 260 + 261 + // (We store and verify the hash of the key, not the key itself, to limit 262 + // how useful the data in the table is to an attacker.) 263 + 264 + $sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE; 265 + $sync_token = null; 266 + 267 + $sync_key = $request->getStr($this->getMFASyncTokenFormKey()); 268 + if (strlen($sync_key)) { 269 + $sync_key_digest = PhabricatorHash::digestWithNamedKey( 270 + $sync_key, 271 + PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); 272 + 273 + $sync_token = id(new PhabricatorAuthTemporaryTokenQuery()) 274 + ->setViewer($user) 275 + ->withTokenResources(array($user->getPHID())) 276 + ->withTokenTypes(array($sync_type)) 277 + ->withExpired(false) 278 + ->withTokenCodes(array($sync_key_digest)) 279 + ->executeOne(); 280 + } 281 + 282 + if (!$sync_token) { 283 + 284 + // Don't generate a new sync token if there are too many outstanding 285 + // tokens already. This is mostly relevant for push factors like SMS, 286 + // where generating a token has the side effect of sending a user a 287 + // message. 288 + 289 + $outstanding_limit = 10; 290 + $outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery()) 291 + ->setViewer($user) 292 + ->withTokenResources(array($user->getPHID())) 293 + ->withTokenTypes(array($sync_type)) 294 + ->withExpired(false) 295 + ->execute(); 296 + if (count($outstanding_tokens) > $outstanding_limit) { 297 + throw new Exception( 298 + pht( 299 + 'Your account has too many outstanding, incomplete MFA '. 300 + 'synchronization attempts. Wait an hour and try again.')); 301 + } 302 + 303 + $now = PhabricatorTime::getNow(); 304 + 305 + $sync_key = Filesystem::readRandomCharacters(32); 306 + $sync_key_digest = PhabricatorHash::digestWithNamedKey( 307 + $sync_key, 308 + PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); 309 + $sync_ttl = $this->getMFASyncTokenTTL(); 310 + 311 + $sync_token = id(new PhabricatorAuthTemporaryToken()) 312 + ->setIsNewTemporaryToken(true) 313 + ->setTokenResource($user->getPHID()) 314 + ->setTokenType($sync_type) 315 + ->setTokenCode($sync_key_digest) 316 + ->setTokenExpires($now + $sync_ttl); 317 + 318 + // Note that property generation is unguarded, since factors that push 319 + // a challenge generally need to perform a write there. 320 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 321 + $properties = $this->newMFASyncTokenProperties($user); 322 + 323 + foreach ($properties as $key => $value) { 324 + $sync_token->setTemporaryTokenProperty($key, $value); 325 + } 326 + 327 + $sync_token->save(); 328 + unset($unguarded); 329 + } 330 + 331 + $form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key); 332 + 333 + return $sync_token; 334 + } 335 + 336 + protected function newMFASyncTokenProperties(PhabricatorUser $user) { 337 + return array(); 338 + } 339 + 340 + private function getMFASyncTokenFormKey() { 341 + return 'sync.key'; 342 + } 343 + 344 + private function getMFASyncTokenTTL() { 345 + return phutil_units('1 hour in seconds'); 346 + } 347 + 248 348 }
+18
src/applications/auth/factor/PhabricatorAuthMFASyncTemporaryTokenType.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthMFASyncTemporaryTokenType 4 + extends PhabricatorAuthTemporaryTokenType { 5 + 6 + const TOKENTYPE = 'mfa.sync'; 7 + const DIGEST_KEY = 'mfa.sync'; 8 + 9 + public function getTokenTypeDisplayName() { 10 + return pht('MFA Sync'); 11 + } 12 + 13 + public function getTokenReadableTypeName( 14 + PhabricatorAuthTemporaryToken $token) { 15 + return pht('MFA Sync Token'); 16 + } 17 + 18 + }
-17
src/applications/auth/factor/PhabricatorAuthTOTPKeyTemporaryTokenType.php
··· 1 - <?php 2 - 3 - final class PhabricatorAuthTOTPKeyTemporaryTokenType 4 - extends PhabricatorAuthTemporaryTokenType { 5 - 6 - const TOKENTYPE = 'mfa:totp:key'; 7 - 8 - public function getTokenTypeDisplayName() { 9 - return pht('TOTP Synchronization'); 10 - } 11 - 12 - public function getTokenReadableTypeName( 13 - PhabricatorAuthTemporaryToken $token) { 14 - return pht('TOTP Sync Token'); 15 - } 16 - 17 - }
+18 -58
src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
··· 2 2 3 3 final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { 4 4 5 - const DIGEST_TEMPORARY_KEY = 'mfa.totp.sync'; 6 - 7 5 public function getFactorKey() { 8 6 return 'totp'; 9 7 } ··· 31 29 AphrontRequest $request, 32 30 PhabricatorUser $user) { 33 31 34 - $totp_token_type = PhabricatorAuthTOTPKeyTemporaryTokenType::TOKENTYPE; 35 - 36 - $key = $request->getStr('totpkey'); 37 - if (strlen($key)) { 38 - // If the user is providing a key, make sure it's a key we generated. 39 - // This raises the barrier to theoretical attacks where an attacker might 40 - // provide a known key (such attacks are already prevented by CSRF, but 41 - // this is a second barrier to overcome). 42 - 43 - // (We store and verify the hash of the key, not the key itself, to limit 44 - // how useful the data in the table is to an attacker.) 45 - 46 - $token_code = PhabricatorHash::digestWithNamedKey( 47 - $key, 48 - self::DIGEST_TEMPORARY_KEY); 49 - 50 - $temporary_token = id(new PhabricatorAuthTemporaryTokenQuery()) 51 - ->setViewer($user) 52 - ->withTokenResources(array($user->getPHID())) 53 - ->withTokenTypes(array($totp_token_type)) 54 - ->withExpired(false) 55 - ->withTokenCodes(array($token_code)) 56 - ->executeOne(); 57 - if (!$temporary_token) { 58 - // If we don't have a matching token, regenerate the key below. 59 - $key = null; 60 - } 61 - } 62 - 63 - if (!strlen($key)) { 64 - $key = self::generateNewTOTPKey(); 65 - 66 - // Mark this key as one we generated, so the user is allowed to submit 67 - // a response for it. 68 - 69 - $token_code = PhabricatorHash::digestWithNamedKey( 70 - $key, 71 - self::DIGEST_TEMPORARY_KEY); 72 - 73 - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 74 - id(new PhabricatorAuthTemporaryToken()) 75 - ->setTokenResource($user->getPHID()) 76 - ->setTokenType($totp_token_type) 77 - ->setTokenExpires(time() + phutil_units('1 hour in seconds')) 78 - ->setTokenCode($token_code) 79 - ->save(); 80 - unset($unguarded); 81 - } 32 + $sync_token = $this->loadMFASyncToken( 33 + $request, 34 + $form, 35 + $user); 36 + $secret = $sync_token->getTemporaryTokenProperty('secret'); 82 37 83 38 $code = $request->getStr('totpcode'); 84 39 85 40 $e_code = true; 86 - if ($request->getExists('totp')) { 41 + if (!$sync_token->getIsNewTemporaryToken()) { 87 42 $okay = (bool)$this->getTimestepAtWhichResponseIsValid( 88 43 $this->getAllowedTimesteps($this->getCurrentTimestep()), 89 - new PhutilOpaqueEnvelope($key), 44 + new PhutilOpaqueEnvelope($secret), 90 45 $code); 91 46 92 47 if ($okay) { 93 48 $config = $this->newConfigForUser($user) 94 49 ->setFactorName(pht('Mobile App (TOTP)')) 95 - ->setFactorSecret($key); 50 + ->setFactorSecret($secret) 51 + ->setMFASyncToken($sync_token); 96 52 97 53 return $config; 98 54 } else { ··· 103 59 } 104 60 } 105 61 } 106 - 107 - $form->addHiddenInput('totp', true); 108 - $form->addHiddenInput('totpkey', $key); 109 62 110 63 $form->appendRemarkupInstructions( 111 64 pht( ··· 126 79 'otpauth://totp/%s:%s?secret=%s&issuer=%s', 127 80 $issuer, 128 81 $user->getUsername(), 129 - $key, 82 + $secret, 130 83 $issuer); 131 84 132 85 $qrcode = $this->renderQRCode($uri); ··· 135 88 $form->appendChild( 136 89 id(new AphrontFormStaticControl()) 137 90 ->setLabel(pht('Key')) 138 - ->setValue(phutil_tag('strong', array(), $key))); 91 + ->setValue(phutil_tag('strong', array(), $secret))); 139 92 140 93 $form->appendInstructions( 141 94 pht( ··· 526 479 527 480 return $value; 528 481 } 482 + 483 + protected function newMFASyncTokenProperties(PhabricatorUser $user) { 484 + return array( 485 + 'secret' => self::generateNewTOTPKey(), 486 + ); 487 + } 488 + 529 489 }
+10
src/applications/auth/storage/PhabricatorAuthFactorConfig.php
··· 15 15 16 16 private $sessionEngine; 17 17 private $factorProvider = self::ATTACHABLE; 18 + private $mfaSyncToken; 18 19 19 20 protected function getConfiguration() { 20 21 return array( ··· 59 60 } 60 61 61 62 return $this->sessionEngine; 63 + } 64 + 65 + public function setMFASyncToken(PhabricatorAuthTemporaryToken $token) { 66 + $this->mfaSyncToken = $token; 67 + return $this; 68 + } 69 + 70 + public function getMFASyncToken() { 71 + return $this->mfaSyncToken; 62 72 } 63 73 64 74
+11 -1
src/applications/auth/storage/PhabricatorAuthTemporaryToken.php
··· 10 10 protected $tokenExpires; 11 11 protected $tokenCode; 12 12 protected $userPHID; 13 - protected $properties; 13 + protected $properties = array(); 14 + 15 + private $isNew = false; 14 16 15 17 protected function getConfiguration() { 16 18 return array( ··· 114 116 return $this->getTemporaryTokenProperty('force-full-session', false); 115 117 } 116 118 119 + public function setIsNewTemporaryToken($is_new) { 120 + $this->isNew = $is_new; 121 + return $this; 122 + } 123 + 124 + public function getIsNewTemporaryToken() { 125 + return $this->isNew; 126 + } 117 127 118 128 119 129 /* -( PhabricatorPolicyInterface )----------------------------------------- */
+7
src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php
··· 266 266 267 267 $config->save(); 268 268 269 + // If we used a temporary token to handle synchronizing the factor, 270 + // revoke it now. 271 + $sync_token = $config->getMFASyncToken(); 272 + if ($sync_token) { 273 + $sync_token->revokeToken(); 274 + } 275 + 269 276 $log = PhabricatorUserLog::initializeNewLog( 270 277 $viewer, 271 278 $user->getPHID(),