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

Consolidate password verification/revocation logic in a new PhabricatorAuthPasswordEngine

Summary:
Ref T13043. This provides a new piece of shared infrastructure that VCS passwords and account passwords can use to validate passwords that users enter.

This isn't reachable by anything yet.

The test coverage of the "upgrade" flow (where we rehash a password to use a stronger hasher) isn't great in this diff, I'll expand that in the next change and then start migrating things.

Test Plan: Added a bunch of unit tests.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13043

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

+402 -4
+5 -1
src/__phutil_library_map__.php
··· 2091 2091 'PhabricatorAuthOneTimeLoginTemporaryTokenType' => 'applications/auth/tokentype/PhabricatorAuthOneTimeLoginTemporaryTokenType.php', 2092 2092 'PhabricatorAuthPassword' => 'applications/auth/storage/PhabricatorAuthPassword.php', 2093 2093 'PhabricatorAuthPasswordEditor' => 'applications/auth/editor/PhabricatorAuthPasswordEditor.php', 2094 + 'PhabricatorAuthPasswordEngine' => 'applications/auth/engine/PhabricatorAuthPasswordEngine.php', 2094 2095 'PhabricatorAuthPasswordPHIDType' => 'applications/auth/phid/PhabricatorAuthPasswordPHIDType.php', 2095 2096 'PhabricatorAuthPasswordQuery' => 'applications/auth/query/PhabricatorAuthPasswordQuery.php', 2096 2097 'PhabricatorAuthPasswordResetTemporaryTokenType' => 'applications/auth/tokentype/PhabricatorAuthPasswordResetTemporaryTokenType.php', ··· 2099 2100 'PhabricatorAuthPasswordTestCase' => 'applications/auth/__tests__/PhabricatorAuthPasswordTestCase.php', 2100 2101 'PhabricatorAuthPasswordTransaction' => 'applications/auth/storage/PhabricatorAuthPasswordTransaction.php', 2101 2102 'PhabricatorAuthPasswordTransactionType' => 'applications/auth/xaction/PhabricatorAuthPasswordTransactionType.php', 2103 + 'PhabricatorAuthPasswordUpgradeTransaction' => 'applications/auth/xaction/PhabricatorAuthPasswordUpgradeTransaction.php', 2102 2104 'PhabricatorAuthProvider' => 'applications/auth/provider/PhabricatorAuthProvider.php', 2103 2105 'PhabricatorAuthProviderConfig' => 'applications/auth/storage/PhabricatorAuthProviderConfig.php', 2104 2106 'PhabricatorAuthProviderConfigController' => 'applications/auth/controller/config/PhabricatorAuthProviderConfigController.php', ··· 7383 7385 'PhabricatorApplicationTransactionInterface', 7384 7386 ), 7385 7387 'PhabricatorAuthPasswordEditor' => 'PhabricatorApplicationTransactionEditor', 7388 + 'PhabricatorAuthPasswordEngine' => 'Phobject', 7386 7389 'PhabricatorAuthPasswordPHIDType' => 'PhabricatorPHIDType', 7387 7390 'PhabricatorAuthPasswordQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 7388 7391 'PhabricatorAuthPasswordResetTemporaryTokenType' => 'PhabricatorAuthTemporaryTokenType', 7389 7392 'PhabricatorAuthPasswordRevokeTransaction' => 'PhabricatorAuthPasswordTransactionType', 7390 7393 'PhabricatorAuthPasswordRevoker' => 'PhabricatorAuthRevoker', 7391 7394 'PhabricatorAuthPasswordTestCase' => 'PhabricatorTestCase', 7392 - 'PhabricatorAuthPasswordTransaction' => 'PhabricatorApplicationTransaction', 7395 + 'PhabricatorAuthPasswordTransaction' => 'PhabricatorModularTransaction', 7393 7396 'PhabricatorAuthPasswordTransactionType' => 'PhabricatorModularTransactionType', 7397 + 'PhabricatorAuthPasswordUpgradeTransaction' => 'PhabricatorAuthPasswordTransactionType', 7394 7398 'PhabricatorAuthProvider' => 'Phobject', 7395 7399 'PhabricatorAuthProviderConfig' => array( 7396 7400 'PhabricatorAuthDAO',
+92
src/applications/auth/__tests__/PhabricatorAuthPasswordTestCase.php
··· 28 28 pht('Bad password should not match.')); 29 29 } 30 30 31 + public function testPasswordEngine() { 32 + $password1 = new PhutilOpaqueEnvelope('the quick'); 33 + $password2 = new PhutilOpaqueEnvelope('brown fox'); 34 + 35 + $user = $this->generateNewTestUser(); 36 + $test_type = PhabricatorAuthPassword::PASSWORD_TYPE_TEST; 37 + $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; 38 + $content_source = $this->newContentSource(); 39 + 40 + $engine = id(new PhabricatorAuthPasswordEngine()) 41 + ->setViewer($user) 42 + ->setContentSource($content_source) 43 + ->setPasswordType($test_type) 44 + ->setObject($user); 45 + 46 + $account_engine = id(new PhabricatorAuthPasswordEngine()) 47 + ->setViewer($user) 48 + ->setContentSource($content_source) 49 + ->setPasswordType($account_type) 50 + ->setObject($user); 51 + 52 + // We haven't set any passwords yet, so both passwords should be 53 + // invalid. 54 + $this->assertFalse($engine->isValidPassword($password1)); 55 + $this->assertFalse($engine->isValidPassword($password2)); 56 + 57 + $pass = PhabricatorAuthPassword::initializeNewPassword($user, $test_type) 58 + ->setPassword($password1, $user) 59 + ->save(); 60 + 61 + // The password should now be valid. 62 + $this->assertTrue($engine->isValidPassword($password1)); 63 + $this->assertFalse($engine->isValidPassword($password2)); 64 + 65 + // But, since the password is a "test" password, it should not be a valid 66 + // "account" password. 67 + $this->assertFalse($account_engine->isValidPassword($password1)); 68 + $this->assertFalse($account_engine->isValidPassword($password2)); 69 + 70 + // Both passwords are unique for the "test" engine, since an active 71 + // password of a given type doesn't collide with itself. 72 + $this->assertTrue($engine->isUniquePassword($password1)); 73 + $this->assertTrue($engine->isUniquePassword($password2)); 74 + 75 + // The "test" password is no longer unique for the "account" engine. 76 + $this->assertFalse($account_engine->isUniquePassword($password1)); 77 + $this->assertTrue($account_engine->isUniquePassword($password2)); 78 + 79 + $this->revokePassword($user, $pass); 80 + 81 + // Now that we've revoked the password, it should no longer be valid. 82 + $this->assertFalse($engine->isValidPassword($password1)); 83 + $this->assertFalse($engine->isValidPassword($password2)); 84 + 85 + // But it should be a revoked password. 86 + $this->assertTrue($engine->isRevokedPassword($password1)); 87 + $this->assertFalse($engine->isRevokedPassword($password2)); 88 + 89 + // It should be revoked for both roles: revoking a "test" password also 90 + // prevents you from choosing it as a new "account" password. 91 + $this->assertTrue($account_engine->isRevokedPassword($password1)); 92 + $this->assertFalse($account_engine->isValidPassword($password2)); 93 + 94 + // The revoked password makes this password non-unique for all account 95 + // types. 96 + $this->assertFalse($engine->isUniquePassword($password1)); 97 + $this->assertTrue($engine->isUniquePassword($password2)); 98 + $this->assertFalse($account_engine->isUniquePassword($password1)); 99 + $this->assertTrue($account_engine->isUniquePassword($password2)); 100 + } 101 + 102 + private function revokePassword( 103 + PhabricatorUser $actor, 104 + PhabricatorAuthPassword $password) { 105 + 106 + $content_source = $this->newContentSource(); 107 + $revoke_type = PhabricatorAuthPasswordRevokeTransaction::TRANSACTIONTYPE; 108 + 109 + $xactions = array(); 110 + 111 + $xactions[] = $password->getApplicationTransactionTemplate() 112 + ->setTransactionType($revoke_type) 113 + ->setNewValue(true); 114 + 115 + $editor = $password->getApplicationTransactionEditor() 116 + ->setActor($actor) 117 + ->setContinueOnNoEffect(true) 118 + ->setContinueOnMissingFields(true) 119 + ->setContentSource($content_source) 120 + ->applyTransactions($password, $xactions); 121 + } 122 + 31 123 }
+240
src/applications/auth/engine/PhabricatorAuthPasswordEngine.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthPasswordEngine 4 + extends Phobject { 5 + 6 + private $viewer; 7 + private $contentSource; 8 + private $object; 9 + private $passwordType; 10 + private $upgradeHashers = true; 11 + 12 + public function setViewer(PhabricatorUser $viewer) { 13 + $this->viewer = $viewer; 14 + return $this; 15 + } 16 + 17 + public function getViewer() { 18 + return $this->viewer; 19 + } 20 + 21 + public function setContentSource(PhabricatorContentSource $content_source) { 22 + $this->contentSource = $content_source; 23 + return $this; 24 + } 25 + 26 + public function getContentSource() { 27 + return $this->contentSource; 28 + } 29 + 30 + public function setObject($object) { 31 + $this->object = $object; 32 + return $this; 33 + } 34 + 35 + public function getObject() { 36 + return $this->object; 37 + } 38 + 39 + public function setPasswordType($password_type) { 40 + $this->passwordType = $password_type; 41 + return $this; 42 + } 43 + 44 + public function getPasswordType() { 45 + return $this->passwordType; 46 + } 47 + 48 + public function setUpgradeHashers($upgrade_hashers) { 49 + $this->upgradeHashers = $upgrade_hashers; 50 + return $this; 51 + } 52 + 53 + public function getUpgradeHashers() { 54 + return $this->upgradeHashers; 55 + } 56 + 57 + public function isValidPassword(PhutilOpaqueEnvelope $envelope) { 58 + $this->requireSetup(); 59 + 60 + $password_type = $this->getPasswordType(); 61 + 62 + $passwords = $this->newQuery() 63 + ->withPasswordTypes(array($password_type)) 64 + ->withIsRevoked(false) 65 + ->execute(); 66 + 67 + $matches = $this->getMatches($envelope, $passwords); 68 + if (!$matches) { 69 + return false; 70 + } 71 + 72 + if ($this->shouldUpgradeHashers()) { 73 + $this->upgradeHashers($envelope, $matches); 74 + } 75 + 76 + return true; 77 + } 78 + 79 + public function isUniquePassword(PhutilOpaqueEnvelope $envelope) { 80 + $this->requireSetup(); 81 + 82 + $password_type = $this->getPasswordType(); 83 + 84 + // To test that the password is unique, we're loading all active and 85 + // revoked passwords for all roles for the given user, then throwing out 86 + // the active passwords for the current role (so a password can't 87 + // collide with itself). 88 + 89 + // Note that two different objects can have the same password (say, 90 + // users @alice and @bailey). We're only preventing @alice from using 91 + // the same password for everything. 92 + 93 + $passwords = $this->newQuery() 94 + ->execute(); 95 + 96 + foreach ($passwords as $key => $password) { 97 + $same_type = ($password->getPasswordType() === $password_type); 98 + $is_active = !$password->getIsRevoked(); 99 + 100 + if ($same_type && $is_active) { 101 + unset($passwords[$key]); 102 + } 103 + } 104 + 105 + $matches = $this->getMatches($envelope, $passwords); 106 + 107 + return !$matches; 108 + } 109 + 110 + public function isRevokedPassword(PhutilOpaqueEnvelope $envelope) { 111 + $this->requireSetup(); 112 + 113 + // To test if a password is revoked, we're loading all revoked passwords 114 + // across all roles for the given user. If a password was revoked in one 115 + // role, you can't reuse it in a different role. 116 + 117 + $passwords = $this->newQuery() 118 + ->withIsRevoked(true) 119 + ->execute(); 120 + 121 + $matches = $this->getMatches($envelope, $passwords); 122 + 123 + return (bool)$matches; 124 + } 125 + 126 + private function requireSetup() { 127 + if (!$this->getObject()) { 128 + throw new PhutilInvalidStateException('setObject'); 129 + } 130 + 131 + if (!$this->getPasswordType()) { 132 + throw new PhutilInvalidStateException('setPasswordType'); 133 + } 134 + 135 + if (!$this->getViewer()) { 136 + throw new PhutilInvalidStateException('setViewer'); 137 + } 138 + 139 + if ($this->shouldUpgradeHashers()) { 140 + if (!$this->getContentSource()) { 141 + throw new PhutilInvalidStateException('setContentSource'); 142 + } 143 + } 144 + } 145 + 146 + private function shouldUpgradeHashers() { 147 + if (!$this->getUpgradeHashers()) { 148 + return false; 149 + } 150 + 151 + if (PhabricatorEnv::isReadOnly()) { 152 + // Don't try to upgrade hashers if we're in read-only mode, since we 153 + // won't be able to write the new hash to the database. 154 + return false; 155 + } 156 + 157 + return true; 158 + } 159 + 160 + private function newQuery() { 161 + $viewer = $this->getViewer(); 162 + $object = $this->getObject(); 163 + $password_type = $this->getPasswordType(); 164 + 165 + return id(new PhabricatorAuthPasswordQuery()) 166 + ->setViewer($viewer) 167 + ->withObjectPHIDs(array($object->getPHID())); 168 + } 169 + 170 + private function getMatches( 171 + PhutilOpaqueEnvelope $envelope, 172 + array $passwords) { 173 + 174 + $object = $this->getObject(); 175 + 176 + $matches = array(); 177 + foreach ($passwords as $password) { 178 + try { 179 + $is_match = $password->comparePassword($envelope, $object); 180 + } catch (PhabricatorPasswordHasherUnavailableException $ex) { 181 + $is_match = false; 182 + } 183 + 184 + if ($is_match) { 185 + $matches[] = $password; 186 + } 187 + } 188 + 189 + return $matches; 190 + } 191 + 192 + private function upgradeHashers( 193 + PhutilOpaqueEnvelope $envelope, 194 + array $passwords) { 195 + 196 + assert_instances_of($passwords, 'PhabricatorAuthPassword'); 197 + 198 + $need_upgrade = array(); 199 + foreach ($passwords as $password) { 200 + if (!$password->canUpgrade()) { 201 + continue; 202 + } 203 + $need_upgrade[] = $password; 204 + } 205 + 206 + if (!$need_upgrade) { 207 + return; 208 + } 209 + 210 + $upgrade_type = PhabricatorAuthPasswordUpgradeTransaction::TRANSACTIONTYPE; 211 + $viewer = $this->getViewer(); 212 + $content_source = $this->getContentSource(); 213 + 214 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 215 + foreach ($need_upgrade as $password) { 216 + 217 + // This does the actual upgrade. We then apply a transaction to make 218 + // the upgrade more visible and auditable. 219 + $old_hasher = $password->getHasher(); 220 + $password->upgradePasswordHasher($envelope, $this->getObject()); 221 + $new_hasher = $password->getHasher(); 222 + 223 + $xactions = array(); 224 + 225 + $xactions[] = $password->getApplicationTransactionTemplate() 226 + ->setTransactionType($upgrade_type) 227 + ->setOldValue($old_hasher->getHashName()) 228 + ->setNewValue($new_hasher->getHashName()); 229 + 230 + $editor = $password->getApplicationTransactionEditor() 231 + ->setActor($viewer) 232 + ->setContinueOnNoEffect(true) 233 + ->setContinueOnMissingFields(true) 234 + ->setContentSource($content_source) 235 + ->applyTransactions($password, $xactions); 236 + } 237 + unset($unguarded); 238 + } 239 + 240 + }
+40 -2
src/applications/auth/storage/PhabricatorAuthPassword.php
··· 58 58 return $this; 59 59 } 60 60 61 + public function getHasher() { 62 + $hash = $this->newPasswordEnvelope(); 63 + return PhabricatorPasswordHasher::getHasherForHash($hash); 64 + } 65 + 66 + public function canUpgrade() { 67 + $hash = $this->newPasswordEnvelope(); 68 + return PhabricatorPasswordHasher::canUpgradeHash($hash); 69 + } 70 + 71 + public function upgradePasswordHasher( 72 + PhutilOpaqueEnvelope $envelope, 73 + PhabricatorUser $object) { 74 + 75 + // Before we make changes, double check that this is really the correct 76 + // password. It could be really bad if we "upgraded" a password and changed 77 + // the secret! 78 + 79 + if (!$this->comparePassword($envelope, $object)) { 80 + throw new Exception( 81 + pht( 82 + 'Attempting to upgrade password hasher, but the password for the '. 83 + 'upgrade is not the stored credential!')); 84 + } 85 + 86 + return $this->setPassword($envelope, $object); 87 + } 88 + 61 89 public function setPassword( 62 90 PhutilOpaqueEnvelope $password, 63 91 PhabricatorUser $object) { 64 92 65 93 $hasher = PhabricatorPasswordHasher::getBestHasher(); 94 + return $this->setPasswordWithHasher($password, $object, $hasher); 95 + } 96 + 97 + public function setPasswordWithHasher( 98 + PhutilOpaqueEnvelope $password, 99 + PhabricatorUser $object, 100 + PhabricatorPasswordHasher $hasher) { 66 101 67 102 $digest = $this->digestPassword($password, $object); 68 103 $hash = $hasher->getPasswordHashForStorage($digest); ··· 76 111 PhabricatorUser $object) { 77 112 78 113 $digest = $this->digestPassword($password, $object); 79 - $raw_hash = $this->getPasswordHash(); 80 - $hash = new PhutilOpaqueEnvelope($raw_hash); 114 + $hash = $this->newPasswordEnvelope(); 81 115 82 116 return PhabricatorPasswordHasher::comparePassword($digest, $hash); 117 + } 118 + 119 + private function newPasswordEnvelope() { 120 + return new PhutilOpaqueEnvelope($this->getPasswordHash()); 83 121 } 84 122 85 123 private function digestPassword(
+1 -1
src/applications/auth/storage/PhabricatorAuthPasswordTransaction.php
··· 1 1 <?php 2 2 3 3 final class PhabricatorAuthPasswordTransaction 4 - extends PhabricatorApplicationTransaction { 4 + extends PhabricatorModularTransaction { 5 5 6 6 public function getApplicationName() { 7 7 return 'auth';
+24
src/applications/auth/xaction/PhabricatorAuthPasswordUpgradeTransaction.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthPasswordUpgradeTransaction 4 + extends PhabricatorAuthPasswordTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'password.upgrade'; 7 + 8 + public function generateOldValue($object) { 9 + return $this->getStorage()->getOldValue(); 10 + } 11 + 12 + public function generateNewValue($object, $value) { 13 + return (bool)$value; 14 + } 15 + 16 + public function getTitle() { 17 + return pht( 18 + '%s upgraded the hash algorithm for this password from "%s" to "%s".', 19 + $this->renderAuthor(), 20 + $this->renderOldValue(), 21 + $this->renderNewValue()); 22 + } 23 + 24 + }