diff --git a/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php b/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php index c45acb7185..21e00c7076 100644 --- a/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php +++ b/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php @@ -1,252 +1,274 @@ <?php final class DiffusionSetPasswordPanel extends PhabricatorSettingsPanel { public function isEditableByAdministrators() { return true; } public function getPanelKey() { return 'vcspassword'; } public function getPanelName() { return pht('VCS Password'); } public function getPanelGroup() { return pht('Authentication'); } public function isEnabled() { return PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth'); } public function processRequest(AphrontRequest $request) { $viewer = $request->getUser(); $user = $this->getUser(); $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, '/settings/'); $vcspassword = id(new PhabricatorRepositoryVCSPassword()) ->loadOneWhere( 'userPHID = %s', $user->getPHID()); if (!$vcspassword) { $vcspassword = id(new PhabricatorRepositoryVCSPassword()); $vcspassword->setUserPHID($user->getPHID()); } $panel_uri = $this->getPanelURI('?saved=true'); $errors = array(); $e_password = true; $e_confirm = true; if ($request->isFormPost()) { if ($request->getBool('remove')) { if ($vcspassword->getID()) { $vcspassword->delete(); return id(new AphrontRedirectResponse())->setURI($panel_uri); } } $new_password = $request->getStr('password'); $confirm = $request->getStr('confirm'); if (!strlen($new_password)) { $e_password = pht('Required'); $errors[] = pht('Password is required.'); } else { $e_password = null; } if (!strlen($confirm)) { $e_confirm = pht('Required'); $errors[] = pht('You must confirm the new password.'); } else { $e_confirm = null; } if (!$errors) { $envelope = new PhutilOpaqueEnvelope($new_password); + try { + // NOTE: This test is against $viewer (not $user), so that the error + // message below makes sense in the case that the two are different, + // and because an admin reusing their own password is bad, while + // system agents generally do not have passwords anyway. + + $same_password = $viewer->comparePassword($envelope); + } catch (PhabricatorPasswordHasherUnavailableException $ex) { + // If we're missing the hasher, just let the user continue. + $same_password = false; + } + if ($new_password !== $confirm) { $e_password = pht('Does Not Match'); $e_confirm = pht('Does Not Match'); $errors[] = pht('Password and confirmation do not match.'); - } else if ($viewer->comparePassword($envelope)) { - // NOTE: The above test is against $viewer (not $user), so that the - // error message below makes sense in the case that the two are - // different, and because an admin reusing their own password is bad, - // while system agents generally do not have passwords anyway. - + } else if ($same_password) { $e_password = pht('Not Unique'); $e_confirm = pht('Not Unique'); $errors[] = pht( 'This password is the same as another password associated '. 'with your account. You must use a unique password for '. 'VCS access.'); } else if ( PhabricatorCommonPasswords::isCommonPassword($new_password)) { $e_password = pht('Very Weak'); $e_confirm = pht('Very Weak'); $errors[] = pht( 'This password is extremely weak: it is one of the most common '. 'passwords in use. Choose a stronger password.'); } if (!$errors) { $vcspassword->setPassword($envelope, $user); $vcspassword->save(); return id(new AphrontRedirectResponse())->setURI($panel_uri); } } } $title = pht('Set VCS Password'); $form = id(new AphrontFormView()) ->setUser($viewer) ->appendRemarkupInstructions( pht( 'To access repositories hosted by Phabricator over HTTP, you must '. 'set a version control password. This password should be unique.'. "\n\n". "This password applies to all repositories available over ". "HTTP.")); if ($vcspassword->getID()) { $form ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setLabel(pht('Current Password')) ->setDisabled(true) ->setValue('********************')); } else { $form ->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Current Password')) ->setValue(phutil_tag('em', array(), pht('No Password Set')))); } $form ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setName('password') ->setLabel(pht('New VCS Password')) ->setError($e_password)) ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setName('confirm') ->setLabel(pht('Confirm VCS Password')) ->setError($e_confirm)) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Change Password'))); if (!$vcspassword->getID()) { $is_serious = PhabricatorEnv::getEnvConfig( 'phabricator.serious-business'); $suggest = Filesystem::readRandomBytes(128); $suggest = preg_replace('([^A-Za-z0-9/!().,;{}^&*%~])', '', $suggest); $suggest = substr($suggest, 0, 20); if ($is_serious) { $form->appendRemarkupInstructions( pht( 'Having trouble coming up with a good password? Try this randomly '. 'generated one, made by a computer:'. "\n\n". "`%s`", $suggest)); } else { $form->appendRemarkupInstructions( pht( 'Having trouble coming up with a good password? Try this '. 'artisinal password, hand made in small batches by our expert '. 'craftspeople: '. "\n\n". "`%s`", $suggest)); } } $hash_envelope = new PhutilOpaqueEnvelope($vcspassword->getPasswordHash()); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Current Algorithm')) ->setValue( PhabricatorPasswordHasher::getCurrentAlgorithmName($hash_envelope))); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Best Available Algorithm')) ->setValue(PhabricatorPasswordHasher::getBestAlgorithmName())); if (strlen($hash_envelope->openEnvelope())) { - if (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) { + try { + $can_upgrade = PhabricatorPasswordHasher::canUpgradeHash( + $hash_envelope); + } catch (PhabricatorPasswordHasherUnavailableException $ex) { + $can_upgrade = false; + $errors[] = pht( + 'Your VCS password is currently hashed using an algorithm which is '. + 'no longer available on this install.'); + $errors[] = pht( + 'Because the algorithm implementation is missing, your password '. + 'can not be used.'); + $errors[] = pht( + 'You can set a new password to replace the old password.'); + } + + if ($can_upgrade) { $errors[] = pht( 'The strength of your stored VCS password hash can be upgraded. '. 'To upgrade, either: use the password to authenticate with a '. 'repository; or change your password.'); } } $object_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setForm($form) ->setFormErrors($errors); $remove_form = id(new AphrontFormView()) ->setUser($viewer); if ($vcspassword->getID()) { $remove_form ->addHiddenInput('remove', true) ->appendRemarkupInstructions( pht( 'You can remove your VCS password, which will prevent your '. 'account from accessing repositories.')) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Remove Password'))); } else { $remove_form->appendRemarkupInstructions( pht( 'You do not currently have a VCS password set. If you set one, you '. 'can remove it here later.')); } $remove_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Remove VCS Password')) ->setForm($remove_form); $saved = null; if ($request->getBool('saved')) { $saved = id(new AphrontErrorView()) ->setSeverity(AphrontErrorView::SEVERITY_NOTICE) ->setTitle(pht('Password Updated')) ->appendChild(pht('Your VCS password has been updated.')); } return array( $saved, $object_box, $remove_box, ); } } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php b/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php index 2aef196b96..eeb9b3410e 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php @@ -1,203 +1,224 @@ <?php final class PhabricatorSettingsPanelPassword extends PhabricatorSettingsPanel { public function getPanelKey() { return 'password'; } public function getPanelName() { return pht('Password'); } public function getPanelGroup() { return pht('Authentication'); } public function isEnabled() { // There's no sense in showing a change password panel if the user // can't change their password... if (!PhabricatorEnv::getEnvConfig('account.editable')) { return false; } // ...or this install doesn't support password authentication at all. if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) { return false; } return true; } public function processRequest(AphrontRequest $request) { $user = $request->getUser(); $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $user, $request, '/settings/'); $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; // NOTE: To change your password, you need to prove you own the account, // either by providing the old password or by carrying a token to // the workflow from a password reset email. $key = $request->getStr('key'); $token = null; if ($key) { $token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withObjectPHIDs(array($user->getPHID())) ->withTokenTypes( array(PhabricatorAuthSessionEngine::PASSWORD_TEMPORARY_TOKEN_TYPE)) ->withTokenCodes(array(PhabricatorHash::digest($key))) ->withExpired(false) ->executeOne(); } $e_old = true; $e_new = true; $e_conf = true; $errors = array(); if ($request->isFormPost()) { if (!$token) { $envelope = new PhutilOpaqueEnvelope($request->getStr('old_pw')); if (!$user->comparePassword($envelope)) { $errors[] = pht('The old password you entered is incorrect.'); $e_old = pht('Invalid'); } } $pass = $request->getStr('new_pw'); $conf = $request->getStr('conf_pw'); if (strlen($pass) < $min_len) { $errors[] = pht('Your new password is too short.'); $e_new = pht('Too Short'); } else if ($pass !== $conf) { $errors[] = pht('New password and confirmation do not match.'); $e_conf = pht('Invalid'); } else if (PhabricatorCommonPasswords::isCommonPassword($pass)) { $e_new = pht('Very Weak'); $e_conf = pht('Very Weak'); $errors[] = pht( 'Your new password is very weak: it is one of the most common '. 'passwords in use. Choose a stronger password.'); } if (!$errors) { // This write is unguarded because the CSRF token has already // been checked in the call to $request->isFormPost() and // the CSRF token depends on the password hash, so when it // is changed here the CSRF token check will fail. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $envelope = new PhutilOpaqueEnvelope($pass); id(new PhabricatorUserEditor()) ->setActor($user) ->changePassword($user, $envelope); unset($unguarded); if ($token) { // Destroy the token. $token->delete(); // If this is a password set/reset, kick the user to the home page // after we update their account. $next = '/'; } else { $next = $this->getPanelURI('?saved=true'); } id(new PhabricatorAuthSessionEngine())->terminateLoginSessions( $user, $request->getCookie(PhabricatorCookies::COOKIE_SESSION)); return id(new AphrontRedirectResponse())->setURI($next); } } $hash_envelope = new PhutilOpaqueEnvelope($user->getPasswordHash()); if (strlen($hash_envelope->openEnvelope())) { - if (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) { + try { + $can_upgrade = PhabricatorPasswordHasher::canUpgradeHash( + $hash_envelope); + } catch (PhabricatorPasswordHasherUnavailableException $ex) { + $can_upgrade = false; + + // Only show this stuff if we aren't on the reset workflow. We can + // do resets regardless of the old hasher's availability. + if (!$token) { + $errors[] = pht( + 'Your password is currently hashed using an algorithm which is '. + 'no longer available on this install.'); + $errors[] = pht( + 'Because the algorithm implementation is missing, your password '. + 'can not be used or updated.'); + $errors[] = pht( + 'To set a new password, request a password reset link from the '. + 'login screen and then follow the instructions.'); + } + } + + if ($can_upgrade) { $errors[] = pht( 'The strength of your stored password hash can be upgraded. '. 'To upgrade, either: log out and log in using your password; or '. 'change your password.'); } } $len_caption = null; if ($min_len) { $len_caption = pht('Minimum password length: %d characters.', $min_len); } $form = new AphrontFormView(); $form ->setUser($user) ->addHiddenInput('key', $key); if (!$token) { $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Old Password')) ->setError($e_old) ->setName('old_pw')); } $form ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setLabel(pht('New Password')) ->setError($e_new) ->setName('new_pw')); $form ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setLabel(pht('Confirm Password')) ->setCaption($len_caption) ->setError($e_conf) ->setName('conf_pw')); $form ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Change Password'))); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Current Algorithm')) ->setValue(PhabricatorPasswordHasher::getCurrentAlgorithmName( new PhutilOpaqueEnvelope($user->getPasswordHash())))); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Best Available Algorithm')) ->setValue(PhabricatorPasswordHasher::getBestAlgorithmName())); $form->appendRemarkupInstructions( pht( 'NOTE: Changing your password will terminate any other outstanding '. 'login sessions.')); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Change Password')) ->setFormSaved($request->getStr('saved')) ->setFormErrors($errors) ->setForm($form); return array( $form_box, ); } } diff --git a/src/infrastructure/util/password/PhabricatorPasswordHasher.php b/src/infrastructure/util/password/PhabricatorPasswordHasher.php index 338a7615ee..4409163509 100644 --- a/src/infrastructure/util/password/PhabricatorPasswordHasher.php +++ b/src/infrastructure/util/password/PhabricatorPasswordHasher.php @@ -1,429 +1,431 @@ <?php /** * Provides a mechanism for hashing passwords, like "iterated md5", "bcrypt", * "scrypt", etc. * * Hashers define suitability and strength, and the system automatically * chooses the strongest available hasher and can prompt users to upgrade as * soon as a stronger hasher is available. * * @task hasher Implementing a Hasher * @task hashing Using Hashers */ abstract class PhabricatorPasswordHasher extends Phobject { const MAXIMUM_STORAGE_SIZE = 128; /* -( Implementing a Hasher )---------------------------------------------- */ /** * Return a human-readable description of this hasher, like "Iterated MD5". * * @return string Human readable hash name. * @task hasher */ abstract public function getHumanReadableName(); /** * Return a short, unique, key identifying this hasher, like "md5" or * "bcrypt". This identifier should not be translated. * * @return string Short, unique hash name. * @task hasher */ abstract public function getHashName(); /** * Return the maximum byte length of hashes produced by this hasher. This is * used to prevent storage overflows. * * @return int Maximum number of bytes in hashes this class produces. * @task hasher */ abstract public function getHashLength(); /** * Return `true` to indicate that any required extensions or dependencies * are available, and this hasher is able to perform hashing. * * @return bool True if this hasher can execute. * @task hasher */ abstract public function canHashPasswords(); /** * Return a human-readable string describing why this hasher is unable * to operate. For example, "To use bcrypt, upgrade to PHP 5.5.0 or newer.". * * @return string Human-readable description of how to enable this hasher. * @task hasher */ abstract public function getInstallInstructions(); /** * Return an indicator of this hasher's strength. When choosing to hash * new passwords, the strongest available hasher which is usuable for new * passwords will be used, and the presence of a stronger hasher will * prompt users to update their hashes. * * Generally, this method should return a larger number than hashers it is * preferable to, but a smaller number than hashers which are better than it * is. This number does not need to correspond directly with the actual hash * strength. * * @return float Strength of this hasher. * @task hasher */ abstract public function getStrength(); /** * Return a short human-readable indicator of this hasher's strength, like * "Weak", "Okay", or "Good". * * This is only used to help administrators make decisions about * configuration. * * @return string Short human-readable description of hash strength. * @task hasher */ abstract public function getHumanReadableStrength(); /** * Produce a password hash. * * @param PhutilOpaqueEnvelope Text to be hashed. * @return PhutilOpaqueEnvelope Hashed text. * @task hasher */ abstract protected function getPasswordHash(PhutilOpaqueEnvelope $envelope); /** * Verify that a password matches a hash. * * The default implementation checks for equality; if a hasher embeds salt in * hashes it should override this method and perform a salt-aware comparison. * * @param PhutilOpaqueEnvelope Password to compare. * @param PhutilOpaqueEnvelope Bare password hash. * @return bool True if the passwords match. * @task hasher */ protected function verifyPassword( PhutilOpaqueEnvelope $password, PhutilOpaqueEnvelope $hash) { $actual_hash = $this->getPasswordHash($password)->openEnvelope(); $expect_hash = $hash->openEnvelope(); return ($actual_hash === $expect_hash); } /** * Check if an existing hash created by this algorithm is upgradeable. * * The default implementation returns `false`. However, hash algorithms which * have (for example) an internal cost function may be able to upgrade an * existing hash to a stronger one with a higher cost. * * @param PhutilOpaqueEnvelope Bare hash. * @return bool True if the hash can be upgraded without * changing the algorithm (for example, to a * higher cost). * @task hasher */ protected function canUpgradeInternalHash(PhutilOpaqueEnvelope $hash) { return false; } /* -( Using Hashers )------------------------------------------------------ */ /** * Get the hash of a password for storage. * * @param PhutilOpaqueEnvelope Password text. * @return PhutilOpaqueEnvelope Hashed text. * @task hashing */ final public function getPasswordHashForStorage( PhutilOpaqueEnvelope $envelope) { $name = $this->getHashName(); $hash = $this->getPasswordHash($envelope); $actual_len = strlen($hash->openEnvelope()); $expect_len = $this->getHashLength(); if ($actual_len > $expect_len) { throw new Exception( pht( "Password hash '%s' produced a hash of length %d, but a ". "maximum length of %d was expected.", $name, new PhutilNumber($actual_len), new PhutilNumber($expect_len))); } return new PhutilOpaqueEnvelope($name.':'.$hash->openEnvelope()); } /** * Parse a storage hash into its components, like the hash type and hash * data. * * @return map Dictionary of information about the hash. * @task hashing */ private static function parseHashFromStorage(PhutilOpaqueEnvelope $hash) { $raw_hash = $hash->openEnvelope(); if (strpos($raw_hash, ':') === false) { throw new Exception( pht( 'Malformed password hash, expected "name:hash".')); } list($name, $hash) = explode(':', $raw_hash); return array( 'name' => $name, 'hash' => new PhutilOpaqueEnvelope($hash), ); } /** * Get all available password hashers. This may include hashers which can not * actually be used (for example, a required extension is missing). * * @return list<PhabicatorPasswordHasher> Hasher objects. * @task hashing */ public static function getAllHashers() { $objects = id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorPasswordHasher') ->loadObjects(); $map = array(); foreach ($objects as $object) { $name = $object->getHashName(); $potential_length = strlen($name) + $object->getHashLength() + 1; $maximum_length = self::MAXIMUM_STORAGE_SIZE; if ($potential_length > $maximum_length) { throw new Exception( pht( 'Hasher "%s" may produce hashes which are too long to fit in '. 'storage. %d characters are available, but its hashes may be '. 'up to %d characters in length.', $name, $maximum_length, $potential_length)); } if (isset($map[$name])) { throw new Exception( pht( 'Two hashers use the same hash name ("%s"), "%s" and "%s". Each '. 'hasher must have a unique name.', $name, get_class($object), get_class($map[$name]))); } $map[$name] = $object; } return $map; } /** * Get all usable password hashers. This may include hashers which are * not desirable or advisable. * * @return list<PhabicatorPasswordHasher> Hasher objects. * @task hashing */ public static function getAllUsableHashers() { $hashers = self::getAllHashers(); foreach ($hashers as $key => $hasher) { if (!$hasher->canHashPasswords()) { unset($hashers[$key]); } } return $hashers; } /** * Get the best (strongest) available hasher. * * @return PhabicatorPasswordHasher Best hasher. * @task hashing */ public static function getBestHasher() { $hashers = self::getAllUsableHashers(); $hashers = msort($hashers, 'getStrength'); $hasher = last($hashers); if (!$hasher) { throw new PhabricatorPasswordHasherUnavailableException( pht( 'There are no password hashers available which are usable for '. 'new passwords.')); } return $hasher; } /** * Get the hashser for a given stored hash. * * @return PhabicatorPasswordHasher Corresponding hasher. * @task hashing */ public static function getHasherForHash(PhutilOpaqueEnvelope $hash) { $info = self::parseHashFromStorage($hash); $name = $info['name']; $usable = self::getAllUsableHashers(); if (isset($usable[$name])) { return $usable[$name]; } $all = self::getAllHashers(); if (isset($all[$name])) { throw new PhabricatorPasswordHasherUnavailableException( pht( 'Attempting to compare a password saved with the "%s" hash. The '. 'hasher exists, but is not currently usable. %s', $name, $all[$name]->getInstallInstructions())); } throw new PhabricatorPasswordHasherUnavailableException( pht( 'Attempting to compare a password saved with the "%s" hash. No such '. 'hasher is known to Phabricator.', $name)); } /** * Test if a password is using an weaker hash than the strongest available * hash. This can be used to prompt users to upgrade, or automatically upgrade * on login. * * @return bool True to indicate that rehashing this password will improve * the hash strength. * @task hashing */ public static function canUpgradeHash(PhutilOpaqueEnvelope $hash) { if (!strlen($hash->openEnvelope())) { throw new Exception( pht('Expected a password hash, received nothing!')); } $current_hasher = self::getHasherForHash($hash); $best_hasher = self::getBestHasher(); if ($current_hasher->getHashName() != $best_hasher->getHashName()) { // If the algorithm isn't the best one, we can upgrade. return true; } $info = self::parseHashFromStorage($hash); if ($current_hasher->canUpgradeInternalHash($info['hash'])) { // If the algorithm provides an internal upgrade, we can also upgrade. return true; } // Already on the best algorithm with the best settings. return false; } /** * Generate a new hash for a password, using the best available hasher. * * @param PhutilOpaqueEnvelope Password to hash. * @return PhutilOpaqueEnvelope Hashed password, using best available * hasher. * @task hashing */ public static function generateNewPasswordHash( PhutilOpaqueEnvelope $password) { $hasher = self::getBestHasher(); return $hasher->getPasswordHashForStorage($password); } /** * Compare a password to a stored hash. * * @param PhutilOpaqueEnvelope Password to compare. * @param PhutilOpaqueEnvelope Stored password hash. * @return bool True if the passwords match. * @task hashing */ public static function comparePassword( PhutilOpaqueEnvelope $password, PhutilOpaqueEnvelope $hash) { $hasher = self::getHasherForHash($hash); $parts = self::parseHashFromStorage($hash); return $hasher->verifyPassword($password, $parts['hash']); } /** * Get the human-readable algorithm name for a given hash. * * @param PhutilOpaqueEnvelope Storage hash. * @return string Human-readable algorithm name. */ public static function getCurrentAlgorithmName(PhutilOpaqueEnvelope $hash) { $raw_hash = $hash->openEnvelope(); if (!strlen($raw_hash)) { return pht('None'); } try { $current_hasher = PhabricatorPasswordHasher::getHasherForHash($hash); return $current_hasher->getHumanReadableName(); } catch (Exception $ex) { - return pht('Unknown'); + $info = self::parseHashFromStorage($hash); + $name = $info['name']; + return pht('Unknown ("%s")', $name); } } /** * Get the human-readable algorithm name for the best available hash. * * @return string Human-readable name for best hash. */ public static function getBestAlgorithmName() { try { $best_hasher = PhabricatorPasswordHasher::getBestHasher(); return $best_hasher->getHumanReadableName(); } catch (Exception $ex) { return pht('Unknown'); } } }