@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

Move account passwords to shared infrastructure

Summary:
Ref T13043. This moves user account passwords to the new shared infrastructure.

There's a lot of code changes here, but essentially all of it is the same as the VCS password logic in D18898.

Test Plan:
- Ran migration.
- Spot checked table for general sanity.
- Logged in with an existing password.
- Hit all error conditions on "change password", "set password", "register new account" flows.
- Verified that changing password logs out other sessions.
- Verified that revoked passwords of a different type can't be selected.
- Changed passwords a bunch.
- Verified that salt regenerates properly after password change.
- Tried to login with the wrong password, which didn't work.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13043

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

+203 -199
+2 -1
resources/sql/autopatches/20180120.auth.03.vcsdata.sql
··· 1 1 INSERT INTO {$NAMESPACE}_auth.auth_password 2 2 (objectPHID, phid, passwordType, passwordHash, isRevoked, 3 3 dateCreated, dateModified) 4 - SELECT userPHID, '', 'vcs', passwordHash, 0, dateCreated, dateModified 4 + SELECT userPHID, CONCAT('XVCS', id), 'vcs', passwordHash, 0, 5 + dateCreated, dateModified 5 6 FROM {$NAMESPACE}_repository.repository_vcspassword;
+3 -1
resources/sql/autopatches/20180120.auth.04.vcsphid.php
··· 6 6 $table = new PhabricatorAuthPassword(); 7 7 $conn = $table->establishConnection('w'); 8 8 9 + $password_type = PhabricatorAuthPasswordPHIDType::TYPECONST; 10 + 9 11 foreach (new LiskMigrationIterator($table) as $row) { 10 - if ($row->getPHID()) { 12 + if (phid_get_type($row->getPHID()) == $password_type) { 11 13 continue; 12 14 } 13 15
+7
resources/sql/autopatches/20180121.auth.03.accountdata.sql
··· 1 + INSERT INTO {$NAMESPACE}_auth.auth_password 2 + (objectPHID, phid, passwordType, passwordHash, passwordSalt, isRevoked, 3 + dateCreated, dateModified) 4 + SELECT phid, CONCAT('XACCOUNT', id), 'account', passwordHash, passwordSalt, 0, 5 + dateCreated, dateModified 6 + FROM {$NAMESPACE}_user.user 7 + WHERE passwordHash != '';
+24
resources/sql/autopatches/20180121.auth.04.accountphid.php
··· 1 + <?php 2 + 3 + // Populate account passwords (which we copied from the user table in the last 4 + // migration) with new PHIDs. 5 + 6 + $table = new PhabricatorAuthPassword(); 7 + $conn = $table->establishConnection('w'); 8 + 9 + $password_type = PhabricatorAuthPasswordPHIDType::TYPECONST; 10 + 11 + foreach (new LiskMigrationIterator($table) as $row) { 12 + if (phid_get_type($row->getPHID()) == $password_type) { 13 + continue; 14 + } 15 + 16 + $new_phid = $row->generatePHID(); 17 + 18 + queryfx( 19 + $conn, 20 + 'UPDATE %T SET phid = %s WHERE id = %d', 21 + $table->getTableName(), 22 + $new_phid, 23 + $row->getID()); 24 + }
+24 -21
src/applications/auth/controller/PhabricatorAuthRegisterController.php
··· 61 61 $default_username = $account->getUsername(); 62 62 $default_realname = $account->getRealName(); 63 63 64 + $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; 65 + $content_source = PhabricatorContentSource::newFromRequest($request); 66 + 64 67 $default_email = $account->getEmail(); 65 68 66 69 if ($invite) { ··· 285 288 if ($must_set_password) { 286 289 $value_password = $request->getStr('password'); 287 290 $value_confirm = $request->getStr('confirm'); 288 - if (!strlen($value_password)) { 289 - $e_password = pht('Required'); 290 - $errors[] = pht('You must choose a password.'); 291 - } else if ($value_password !== $value_confirm) { 292 - $e_password = pht('No Match'); 293 - $errors[] = pht('Password and confirmation must match.'); 294 - } else if (strlen($value_password) < $min_len) { 295 - $e_password = pht('Too Short'); 296 - $errors[] = pht( 297 - 'Password is too short (must be at least %d characters long).', 298 - $min_len); 299 - } else if ( 300 - PhabricatorCommonPasswords::isCommonPassword($value_password)) { 291 + 292 + $password_envelope = new PhutilOpaqueEnvelope($value_password); 293 + $confirm_envelope = new PhutilOpaqueEnvelope($value_confirm); 294 + 295 + $engine = id(new PhabricatorAuthPasswordEngine()) 296 + ->setViewer($user) 297 + ->setContentSource($content_source) 298 + ->setPasswordType($account_type) 299 + ->setObject($user); 301 300 302 - $e_password = pht('Very Weak'); 303 - $errors[] = pht( 304 - 'Password is pathologically weak. This password is one of the '. 305 - 'most common passwords in use, and is extremely easy for '. 306 - 'attackers to guess. You must choose a stronger password.'); 307 - } else { 301 + try { 302 + $engine->checkNewPassword($password_envelope, $confirm_envelope); 308 303 $e_password = null; 304 + } catch (PhabricatorAuthPasswordException $ex) { 305 + $errors[] = $ex->getMessage(); 306 + $e_password = $ex->getPasswordError(); 309 307 } 310 308 } 311 309 ··· 408 406 409 407 $editor->createNewUser($user, $email_obj, $allow_reassign_email); 410 408 if ($must_set_password) { 411 - $envelope = new PhutilOpaqueEnvelope($value_password); 412 - $editor->changePassword($user, $envelope); 409 + $password_object = PhabricatorAuthPassword::initializeNewPassword( 410 + $user, 411 + $account_type); 412 + 413 + $password_object 414 + ->setPassword($password_envelope, $user) 415 + ->save(); 413 416 } 414 417 415 418 if ($is_setup) {
+41 -39
src/applications/auth/controller/PhabricatorAuthSetPasswordController.php
··· 40 40 return new Aphront404Response(); 41 41 } 42 42 43 - $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); 44 - $min_len = (int)$min_len; 43 + $content_source = PhabricatorContentSource::newFromRequest($request); 44 + $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; 45 + 46 + $password_objects = id(new PhabricatorAuthPasswordQuery()) 47 + ->setViewer($viewer) 48 + ->withObjectPHIDs(array($viewer->getPHID())) 49 + ->withPasswordTypes(array($account_type)) 50 + ->withIsRevoked(false) 51 + ->execute(); 52 + if ($password_objects) { 53 + $password_object = head($password_objects); 54 + $has_password = true; 55 + } else { 56 + $password_object = PhabricatorAuthPassword::initializeNewPassword( 57 + $viewer, 58 + $account_type); 59 + $has_password = false; 60 + } 61 + 62 + $engine = id(new PhabricatorAuthPasswordEngine()) 63 + ->setViewer($viewer) 64 + ->setContentSource($content_source) 65 + ->setPasswordType($account_type) 66 + ->setObject($viewer); 45 67 46 68 $e_password = true; 47 69 $e_confirm = true; ··· 50 72 $password = $request->getStr('password'); 51 73 $confirm = $request->getStr('confirm'); 52 74 53 - $e_password = null; 54 - $e_confirm = null; 75 + $password_envelope = new PhutilOpaqueEnvelope($password); 76 + $confirm_envelope = new PhutilOpaqueEnvelope($confirm); 55 77 56 - if (!strlen($password)) { 57 - $errors[] = pht('You must choose a password or skip this step.'); 58 - $e_password = pht('Required'); 59 - } else if (strlen($password) < $min_len) { 60 - $errors[] = pht( 61 - 'The selected password is too short. Passwords must be a minimum '. 62 - 'of %s characters.', 63 - new PhutilNumber($min_len)); 64 - $e_password = pht('Too Short'); 65 - } else if (!strlen($confirm)) { 66 - $errors[] = pht('You must confirm the selecetd password.'); 67 - $e_confirm = pht('Required'); 68 - } else if ($password !== $confirm) { 69 - $errors[] = pht('The password and confirmation do not match.'); 70 - $e_password = pht('Invalid'); 71 - $e_confirm = pht('Invalid'); 72 - } else if (PhabricatorCommonPasswords::isCommonPassword($password)) { 73 - $e_password = pht('Very Weak'); 74 - $errors[] = pht( 75 - 'The selected password is very weak: it is one of the most common '. 76 - 'passwords in use. Choose a stronger password.'); 78 + try { 79 + $engine->checkNewPassword($password_envelope, $confirm_envelope, true); 80 + $e_password = null; 81 + $e_confirm = null; 82 + } catch (PhabricatorAuthPasswordException $ex) { 83 + $errors[] = $ex->getMessage(); 84 + $e_password = $ex->getPasswordError(); 85 + $e_confirm = $ex->getConfirmError(); 77 86 } 78 87 79 88 if (!$errors) { 80 - $envelope = new PhutilOpaqueEnvelope($password); 81 - 82 - // This write is unguarded because the CSRF token has already 83 - // been checked in the call to $request->isFormPost() and 84 - // the CSRF token depends on the password hash, so when it 85 - // is changed here the CSRF token check will fail. 86 - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 87 - 88 - id(new PhabricatorUserEditor()) 89 - ->setActor($viewer) 90 - ->changePassword($viewer, $envelope); 91 - 92 - unset($unguarded); 89 + $password_object 90 + ->setPassword($password_envelope, $viewer) 91 + ->save(); 93 92 94 93 // Destroy the token. 95 94 $auth_token->delete(); ··· 98 97 } 99 98 } 100 99 100 + $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); 101 + $min_len = (int)$min_len; 102 + 101 103 $len_caption = null; 102 104 if ($min_len) { 103 105 $len_caption = pht('Minimum password length: %d characters.', $min_len); 104 106 } 105 107 106 - if ($viewer->hasPassword()) { 108 + if ($has_password) { 107 109 $title = pht('Reset Password'); 108 110 $crumb = pht('Reset Password'); 109 111 $submit = pht('Reset Password');
+20 -13
src/applications/auth/engine/PhabricatorAuthPasswordEngine.php
··· 110 110 pht('Very Weak')); 111 111 } 112 112 113 - if ($this->isRevokedPassword($password)) { 114 - throw new PhabricatorAuthPasswordException( 115 - pht( 116 - 'The password you entered has been revoked. You can not reuse '. 117 - 'a password which has been revoked. Choose a new password.'), 118 - pht('Revoked')); 119 - } 113 + // If we're creating a brand new object (like registering a new user) 114 + // and it does not have a PHID yet, it isn't possible for it to have any 115 + // revoked passwords or colliding passwords either, so we can skip these 116 + // checks. 117 + 118 + if ($this->getObject()->getPHID()) { 119 + if ($this->isRevokedPassword($password)) { 120 + throw new PhabricatorAuthPasswordException( 121 + pht( 122 + 'The password you entered has been revoked. You can not reuse '. 123 + 'a password which has been revoked. Choose a new password.'), 124 + pht('Revoked')); 125 + } 120 126 121 - if (!$this->isUniquePassword($password)) { 122 - throw new PhabricatorAuthPasswordException( 123 - pht( 124 - 'The password you entered is the same as another password '. 125 - 'associated with your account. Each password must be unique.'), 126 - pht('Not Unique')); 127 + if (!$this->isUniquePassword($password)) { 128 + throw new PhabricatorAuthPasswordException( 129 + pht( 130 + 'The password you entered is the same as another password '. 131 + 'associated with your account. Each password must be unique.'), 132 + pht('Not Unique')); 133 + } 127 134 } 128 135 } 129 136
+9 -14
src/applications/auth/provider/PhabricatorPasswordAuthProvider.php
··· 253 253 254 254 $request = $controller->getRequest(); 255 255 $viewer = $request->getUser(); 256 + $content_source = PhabricatorContentSource::newFromRequest($request); 256 257 257 258 $require_captcha = false; 258 259 $captcha_valid = false; ··· 285 286 286 287 if ($user) { 287 288 $envelope = new PhutilOpaqueEnvelope($request->getStr('password')); 288 - if ($user->comparePassword($envelope)) { 289 - $account = $this->loadOrCreateAccount($user->getPHID()); 290 - $log_user = $user; 291 289 292 - // If the user's password is stored using a less-than-optimal 293 - // hash, upgrade them to the strongest available hash. 294 - 295 - $hash_envelope = new PhutilOpaqueEnvelope( 296 - $user->getPasswordHash()); 297 - if (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) { 298 - $user->setPassword($envelope); 290 + $engine = id(new PhabricatorAuthPasswordEngine()) 291 + ->setViewer($user) 292 + ->setContentSource($content_source) 293 + ->setPasswordType(PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT) 294 + ->setObject($user); 299 295 300 - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 301 - $user->save(); 302 - unset($unguarded); 303 - } 296 + if ($engine->isValidPassword($envelope)) { 297 + $account = $this->loadOrCreateAccount($user->getPHID()); 298 + $log_user = $user; 304 299 } 305 300 } 306 301 }
+1 -1
src/applications/auth/storage/PhabricatorAuthPassword.php
··· 127 127 return PhabricatorPasswordHasher::comparePassword($digest, $hash); 128 128 } 129 129 130 - private function newPasswordEnvelope() { 130 + public function newPasswordEnvelope() { 131 131 return new PhutilOpaqueEnvelope($this->getPasswordHash()); 132 132 } 133 133
-27
src/applications/people/editor/PhabricatorUserEditor.php
··· 132 132 /** 133 133 * @task edit 134 134 */ 135 - public function changePassword( 136 - PhabricatorUser $user, 137 - PhutilOpaqueEnvelope $envelope) { 138 - 139 - if (!$user->getID()) { 140 - throw new Exception(pht('User has not been created yet!')); 141 - } 142 - 143 - $user->openTransaction(); 144 - $user->reload(); 145 - 146 - $user->setPassword($envelope); 147 - $user->save(); 148 - 149 - $log = PhabricatorUserLog::initializeNewLog( 150 - $this->requireActor(), 151 - $user->getPHID(), 152 - PhabricatorUserLog::ACTION_CHANGE_PASSWORD); 153 - $log->save(); 154 - 155 - $user->saveTransaction(); 156 - } 157 - 158 - 159 - /** 160 - * @task edit 161 - */ 162 135 public function changeUsername(PhabricatorUser $user, $username) { 163 136 $actor = $this->requireActor(); 164 137
+26 -52
src/applications/people/storage/PhabricatorUser.php
··· 263 263 PhabricatorPeopleUserPHIDType::TYPECONST); 264 264 } 265 265 266 - public function hasPassword() { 267 - return (bool)strlen($this->passwordHash); 268 - } 269 - 270 - public function setPassword(PhutilOpaqueEnvelope $envelope) { 271 - if (!$this->getPHID()) { 272 - throw new Exception( 273 - pht( 274 - 'You can not set a password for an unsaved user because their PHID '. 275 - 'is a salt component in the password hash.')); 276 - } 277 - 278 - if (!strlen($envelope->openEnvelope())) { 279 - $this->setPasswordHash(''); 280 - } else { 281 - $this->setPasswordSalt(md5(Filesystem::readRandomBytes(32))); 282 - $hash = $this->hashPassword($envelope); 283 - $this->setPasswordHash($hash->openEnvelope()); 284 - } 285 - return $this; 286 - } 287 - 288 266 public function getMonogram() { 289 267 return '@'.$this->getUsername(); 290 268 } ··· 330 308 331 309 private function generateConduitCertificate() { 332 310 return Filesystem::readRandomCharacters(255); 333 - } 334 - 335 - public function comparePassword(PhutilOpaqueEnvelope $envelope) { 336 - if (!strlen($envelope->openEnvelope())) { 337 - return false; 338 - } 339 - if (!strlen($this->getPasswordHash())) { 340 - return false; 341 - } 342 - 343 - return PhabricatorPasswordHasher::comparePassword( 344 - $this->getPasswordHashInput($envelope), 345 - new PhutilOpaqueEnvelope($this->getPasswordHash())); 346 - } 347 - 348 - private function getPasswordHashInput(PhutilOpaqueEnvelope $password) { 349 - $input = 350 - $this->getUsername(). 351 - $password->openEnvelope(). 352 - $this->getPHID(). 353 - $this->getPasswordSalt(); 354 - 355 - return new PhutilOpaqueEnvelope($input); 356 - } 357 - 358 - private function hashPassword(PhutilOpaqueEnvelope $password) { 359 - $hasher = PhabricatorPasswordHasher::getBestHasher(); 360 - 361 - $input_envelope = $this->getPasswordHashInput($password); 362 - return $hasher->getPasswordHashForStorage($input_envelope); 363 311 } 364 312 365 313 const CSRF_CYCLE_FREQUENCY = 3600; ··· 1669 1617 $digest = PhabricatorHash::weakDigest($digest, $salt); 1670 1618 } 1671 1619 return new PhutilOpaqueEnvelope($digest); 1620 + case PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT: 1621 + // Account passwords use this weird mess of salt and do not digest 1622 + // the input to a standard length. 1623 + 1624 + // TODO: We should build a migration pathway forward from this which 1625 + // uses a better (HMAC SHA256) digest algorithm. Beyond this being 1626 + // a weird special case, there are two actual problems with this, 1627 + // although neither are particularly severe: 1628 + 1629 + // First, because we do not normalize the length of passwords, this 1630 + // algorithm may make us vulnerable to DOS attacks where attacker 1631 + // attempt to use very long inputs to slow down hashers. 1632 + 1633 + // Second, because the username is part of the hash algorithm, renaming 1634 + // a user breaks their password. This isn't a huge deal but it's pretty 1635 + // silly. There's no security justification for this behavior, I just 1636 + // didn't think about the implication when I wrote it originally. 1637 + 1638 + $parts = array( 1639 + $this->getUsername(), 1640 + $envelope->openEnvelope(), 1641 + $this->getPHID(), 1642 + $password->getPasswordSalt(), 1643 + ); 1644 + 1645 + return new PhutilOpaqueEnvelope(implode('', $parts)); 1672 1646 } 1673 1647 1674 1648 // For passwords which do not have some crazy legacy reason to use some
+46 -30
src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php
··· 26 26 27 27 public function processRequest(AphrontRequest $request) { 28 28 $user = $request->getUser(); 29 + $content_source = PhabricatorContentSource::newFromRequest($request); 29 30 30 31 $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( 31 32 $user, ··· 40 41 // registration or password reset. If this flow changes, that flow may 41 42 // also need to change. 42 43 44 + $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; 45 + 46 + $password_objects = id(new PhabricatorAuthPasswordQuery()) 47 + ->setViewer($user) 48 + ->withObjectPHIDs(array($user->getPHID())) 49 + ->withPasswordTypes(array($account_type)) 50 + ->withIsRevoked(false) 51 + ->execute(); 52 + if ($password_objects) { 53 + $password_object = head($password_objects); 54 + } else { 55 + $password_object = PhabricatorAuthPassword::initializeNewPassword( 56 + $user, 57 + $account_type); 58 + } 59 + 43 60 $e_old = true; 44 61 $e_new = true; 45 62 $e_conf = true; ··· 47 64 $errors = array(); 48 65 if ($request->isFormPost()) { 49 66 $envelope = new PhutilOpaqueEnvelope($request->getStr('old_pw')); 50 - if (!$user->comparePassword($envelope)) { 67 + 68 + $engine = id(new PhabricatorAuthPasswordEngine()) 69 + ->setViewer($user) 70 + ->setContentSource($content_source) 71 + ->setPasswordType($account_type) 72 + ->setObject($user); 73 + 74 + if (!strlen($envelope->openEnvelope())) { 75 + $errors[] = pht('You must enter your current password.'); 76 + $e_old = pht('Required'); 77 + } else if (!$engine->isValidPassword($envelope)) { 51 78 $errors[] = pht('The old password you entered is incorrect.'); 52 79 $e_old = pht('Invalid'); 80 + } else { 81 + $e_old = null; 53 82 } 54 83 55 84 $pass = $request->getStr('new_pw'); 56 85 $conf = $request->getStr('conf_pw'); 86 + $password_envelope = new PhutilOpaqueEnvelope($pass); 87 + $confirm_envelope = new PhutilOpaqueEnvelope($conf); 57 88 58 - if (strlen($pass) < $min_len) { 59 - $errors[] = pht('Your new password is too short.'); 60 - $e_new = pht('Too Short'); 61 - } else if ($pass !== $conf) { 62 - $errors[] = pht('New password and confirmation do not match.'); 63 - $e_conf = pht('Invalid'); 64 - } else if (PhabricatorCommonPasswords::isCommonPassword($pass)) { 65 - $e_new = pht('Very Weak'); 66 - $e_conf = pht('Very Weak'); 67 - $errors[] = pht( 68 - 'Your new password is very weak: it is one of the most common '. 69 - 'passwords in use. Choose a stronger password.'); 89 + try { 90 + $engine->checkNewPassword($password_envelope, $confirm_envelope); 91 + $e_new = null; 92 + $e_conf = null; 93 + } catch (PhabricatorAuthPasswordException $ex) { 94 + $errors[] = $ex->getMessage(); 95 + $e_new = $ex->getPasswordError(); 96 + $e_conf = $ex->getConfirmError(); 70 97 } 71 98 72 99 if (!$errors) { 73 - // This write is unguarded because the CSRF token has already 74 - // been checked in the call to $request->isFormPost() and 75 - // the CSRF token depends on the password hash, so when it 76 - // is changed here the CSRF token check will fail. 77 - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 78 - 79 - $envelope = new PhutilOpaqueEnvelope($pass); 80 - id(new PhabricatorUserEditor()) 81 - ->setActor($user) 82 - ->changePassword($user, $envelope); 83 - 84 - unset($unguarded); 100 + $password_object 101 + ->setPassword($password_envelope, $user) 102 + ->save(); 85 103 86 104 $next = $this->getPanelURI('?saved=true'); 87 105 ··· 93 111 } 94 112 } 95 113 96 - $hash_envelope = new PhutilOpaqueEnvelope($user->getPasswordHash()); 97 - if (strlen($hash_envelope->openEnvelope())) { 114 + if ($password_object->getID()) { 98 115 try { 99 - $can_upgrade = PhabricatorPasswordHasher::canUpgradeHash( 100 - $hash_envelope); 116 + $can_upgrade = $password_object->canUpgrade(); 101 117 } catch (PhabricatorPasswordHasherUnavailableException $ex) { 102 118 $can_upgrade = false; 103 119 ··· 154 170 $properties->addProperty( 155 171 pht('Current Algorithm'), 156 172 PhabricatorPasswordHasher::getCurrentAlgorithmName( 157 - new PhutilOpaqueEnvelope($user->getPasswordHash()))); 173 + $password_object->newPasswordEnvelope())); 158 174 159 175 $properties->addProperty( 160 176 pht('Best Available Algorithm'),