diff --git a/resources/sql/autopatches/20140218.passwords.3.vcsextend.sql b/resources/sql/autopatches/20140218.passwords.3.vcsextend.sql new file mode 100644 index 0000000000..38753cc6d3 --- /dev/null +++ b/resources/sql/autopatches/20140218.passwords.3.vcsextend.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_repository.repository_vcspassword + CHANGE passwordHash passwordHash VARCHAR(128) COLLATE utf8_bin NOT NULL; diff --git a/resources/sql/autopatches/20140218.passwords.4.vcs.php b/resources/sql/autopatches/20140218.passwords.4.vcs.php new file mode 100644 index 0000000000..5e58c63e86 --- /dev/null +++ b/resources/sql/autopatches/20140218.passwords.4.vcs.php @@ -0,0 +1,27 @@ +establishConnection('w'); + +echo "Upgrading password hashing for VCS passwords.\n"; + +$best_hasher = PhabricatorPasswordHasher::getBestHasher(); +foreach (new LiskMigrationIterator($table) as $password) { + $id = $password->getID(); + + echo "Migrating VCS password {$id}...\n"; + + $input_hash = $password->getPasswordHash(); + $input_envelope = new PhutilOpaqueEnvelope($input_hash); + + $storage_hash = $best_hasher->getPasswordHashForStorage($input_envelope); + + queryfx( + $conn_w, + 'UPDATE %T SET passwordHash = %s WHERE id = %d', + $table->getTableName(), + $storage_hash->openEnvelope(), + $id); +} + +echo "Done.\n"; diff --git a/src/applications/diffusion/controller/DiffusionServeController.php b/src/applications/diffusion/controller/DiffusionServeController.php index 9cd7b2aee0..403522a056 100644 --- a/src/applications/diffusion/controller/DiffusionServeController.php +++ b/src/applications/diffusion/controller/DiffusionServeController.php @@ -1,592 +1,604 @@ getHTTPHeader('Content-Type'); $user_agent = idx($_SERVER, 'HTTP_USER_AGENT'); $vcs = null; if ($request->getExists('service')) { $service = $request->getStr('service'); // We get this initially for `info/refs`. // Git also gives us a User-Agent like "git/1.8.2.3". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if (strncmp($user_agent, "git/", 4) === 0) { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-upload-pack-request') { // We get this for `git-upload-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-receive-pack-request') { // We get this for `git-receive-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($request->getExists('cmd')) { // Mercurial also sends an Accept header like // "application/mercurial-0.1", and a User-Agent like // "mercurial/proto-1.0". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL; } else { // Subversion also sends an initial OPTIONS request (vs GET/POST), and // has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2) // serf/1.3.2". $dav = $request->getHTTPHeader('DAV'); $dav = new PhutilURI($dav); if ($dav->getDomain() === 'subversion.tigris.org') { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN; } } return $vcs; } private static function getCallsign(AphrontRequest $request) { $uri = $request->getRequestURI(); $regex = '@^/diffusion/(?P[A-Z]+)(/|$)@'; $matches = null; if (!preg_match($regex, (string)$uri, $matches)) { return null; } return $matches['callsign']; } public function processRequest() { $request = $this->getRequest(); $callsign = self::getCallsign($request); // If authentication credentials have been provided, try to find a user // that actually matches those credentials. if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { $username = $_SERVER['PHP_AUTH_USER']; $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']); $viewer = $this->authenticateHTTPRepositoryUser($username, $password); if (!$viewer) { return new PhabricatorVCSResponse( 403, pht('Invalid credentials.')); } } else { // User hasn't provided credentials, which means we count them as // being "not logged in". $viewer = new PhabricatorUser(); } $allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public'); $allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth'); if (!$allow_public) { if (!$viewer->isLoggedIn()) { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access repositories.')); } else { return new PhabricatorVCSResponse( 403, pht('Public and authenticated HTTP access are both forbidden.')); } } } try { $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withCallsigns(array($callsign)) ->executeOne(); if (!$repository) { return new PhabricatorVCSResponse( 404, pht('No such repository exists.')); } } catch (PhabricatorPolicyException $ex) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to access this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'This repository requires authentication, which is forbidden '. 'over HTTP.')); } } } if (!$repository->isTracked()) { return new PhabricatorVCSResponse( 403, pht('This repository is inactive.')); } $is_push = !$this->isReadOnlyRequest($repository); switch ($repository->getServeOverHTTP()) { case PhabricatorRepository::SERVE_READONLY: if ($is_push) { return new PhabricatorVCSResponse( 403, pht('This repository is read-only over HTTP.')); } break; case PhabricatorRepository::SERVE_READWRITE: if ($is_push) { $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionCapabilityPush::CAPABILITY); if (!$can_push) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to push to this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to push to this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'Pushing to this repository requires authentication, '. 'which is forbidden over HTTP.')); } } } } break; case PhabricatorRepository::SERVE_OFF: default: return new PhabricatorVCSResponse( 403, pht('This repository is not available over HTTP.')); } $vcs_type = $repository->getVersionControlSystem(); $req_type = $this->isVCSRequest($request); if ($vcs_type != $req_type) { switch ($req_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = new PhabricatorVCSResponse( 500, pht('This is not a Git repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = new PhabricatorVCSResponse( 500, pht('This is not a Mercurial repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht('This is not a Subversion repository.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown request type.')); break; } } else { switch ($vcs_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = $this->serveGitRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = $this->serveMercurialRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht( 'Phabricator does not support HTTP access to Subversion '. 'repositories.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown version control system.')); break; } } $code = $result->getHTTPResponseCode(); if ($is_push && ($code == 200)) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); unset($unguarded); } return $result; } private function isReadOnlyRequest( PhabricatorRepository $repository) { $request = $this->getRequest(); $method = $_SERVER['REQUEST_METHOD']; // TODO: This implementation is safe by default, but very incomplete. switch ($repository->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $service = $request->getStr('service'); $path = $this->getRequestDirectoryPath($repository); // NOTE: Service names are the reverse of what you might expect, as they // are from the point of view of the server. The main read service is // "git-upload-pack", and the main write service is "git-receive-pack". if ($method == 'GET' && $path == '/info/refs' && $service == 'git-upload-pack') { return true; } if ($path == '/git-upload-pack') { return true; } break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $cmd = $request->getStr('cmd'); if ($cmd == 'batch') { $cmds = idx($this->getMercurialArguments(), 'cmds'); return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds); } return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: break; } return false; } /** * @phutil-external-symbol class PhabricatorStartup */ private function serveGitRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $request_path = $this->getRequestDirectoryPath($repository); $repository_root = $repository->getLocalPath(); // Rebuild the query string to strip `__magic__` parameters and prevent // issues where we might interpret inputs like "service=read&service=write" // differently than the server does and pass it an unsafe command. // NOTE: This does not use getPassthroughRequestParameters() because // that code is HTTP-method agnostic and will encode POST data. $query_data = $_GET; foreach ($query_data as $key => $value) { if (!strncmp($key, '__', 2)) { unset($query_data[$key]); } } $query_string = http_build_query($query_data, '', '&'); // We're about to wipe out PATH with the rest of the environment, so // resolve the binary first. $bin = Filesystem::resolveBinary('git-http-backend'); if (!$bin) { throw new Exception("Unable to find `git-http-backend` in PATH!"); } $env = array( 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], 'QUERY_STRING' => $query_string, 'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'), 'HTTP_CONTENT_ENCODING' => $request->getHTTPHeader('Content-Encoding'), 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], 'GIT_PROJECT_ROOT' => $repository_root, 'GIT_HTTP_EXPORT_ALL' => '1', 'PATH_INFO' => $request_path, 'REMOTE_USER' => $viewer->getUsername(), // TODO: Set these correctly. // GIT_COMMITTER_NAME // GIT_COMMITTER_EMAIL ) + $this->getCommonEnvironment($viewer); $input = PhabricatorStartup::getRawInput(); $command = csprintf('%s', $bin); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->write($input) ->resolve(); if ($err) { if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) { // Ignore the error if the response passes this special check for // validity. $err = 0; } } if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } return id(new DiffusionGitResponse())->setGitData($stdout); } private function getRequestDirectoryPath(PhabricatorRepository $repository) { $request = $this->getRequest(); $request_path = $request->getRequestURI()->getPath(); $base_path = preg_replace('@^/diffusion/[A-Z]+@', '', $request_path); // For Git repositories, strip an optional directory component if it // isn't the name of a known Git resource. This allows users to clone // repositories as "/diffusion/X/anything.git", for example. if ($repository->isGit()) { $known = array( 'info', 'git-upload-pack', 'git-receive-pack', ); foreach ($known as $key => $path) { $known[$key] = preg_quote($path, '@'); } $known = implode('|', $known); if (preg_match('@^/([^/]+)/('.$known.')(/|$)@', $base_path)) { $base_path = preg_replace('@^/([^/]+)@', '', $base_path); } } return $base_path; } private function authenticateHTTPRepositoryUser( $username, PhutilOpaqueEnvelope $password) { if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) { // No HTTP auth permitted. return null; } if (!strlen($username)) { // No username. return null; } if (!strlen($password->openEnvelope())) { // No password. return null; } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($username)) ->executeOne(); if (!$user) { // Username doesn't match anything. return null; } if (!$user->isUserActivated()) { // User is not activated. return null; } $password_entry = id(new PhabricatorRepositoryVCSPassword()) ->loadOneWhere('userPHID = %s', $user->getPHID()); if (!$password_entry) { // User doesn't have a password set. return null; } if (!$password_entry->comparePassword($password, $user)) { // Password doesn't match. return null; } + // If the user's password is stored using a less-than-optimal hash, upgrade + // them to the strongest available hash. + + $hash_envelope = new PhutilOpaqueEnvelope( + $password_entry->getPasswordHash()); + if (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) { + $password_entry->setPassword($password, $user); + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + $password_entry->save(); + unset($unguarded); + } + return $user; } private function serveMercurialRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $bin = Filesystem::resolveBinary('hg'); if (!$bin) { throw new Exception("Unable to find `hg` in PATH!"); } $env = $this->getCommonEnvironment($viewer); $input = PhabricatorStartup::getRawInput(); $cmd = $request->getStr('cmd'); $args = $this->getMercurialArguments(); $args = $this->formatMercurialArguments($cmd, $args); if (strlen($input)) { $input = strlen($input)."\n".$input."0\n"; } $command = csprintf('%s serve --stdio', $bin); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->setCWD($repository->getLocalPath()) ->write("{$cmd}\n{$args}{$input}") ->resolve(); if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } if ($cmd == 'getbundle' || $cmd == 'changegroup' || $cmd == 'changegroupsubset') { // We're not completely sure that "changegroup" and "changegroupsubset" // actually work, they're for very old Mercurial. $body = gzcompress($stdout); } else if ($cmd == 'unbundle') { // This includes diagnostic information and anything echoed by commit // hooks. We ignore `stdout` since it just has protocol garbage, and // substitute `stderr`. $body = strlen($stderr)."\n".$stderr; } else { list($length, $body) = explode("\n", $stdout, 2); } return id(new DiffusionMercurialResponse())->setContent($body); } private function getMercurialArguments() { // Mercurial sends arguments in HTTP headers. "Why?", you might wonder, // "Why would you do this?". $args_raw = array(); for ($ii = 1; ; $ii++) { $header = 'HTTP_X_HGARG_'.$ii; if (!array_key_exists($header, $_SERVER)) { break; } $args_raw[] = $_SERVER[$header]; } $args_raw = implode('', $args_raw); return id(new PhutilQueryStringParser()) ->parseQueryString($args_raw); } private function formatMercurialArguments($command, array $arguments) { $spec = DiffusionMercurialWireProtocol::getCommandArgs($command); $out = array(); // Mercurial takes normal arguments like this: // // name // value $has_star = false; foreach ($spec as $arg_key) { if ($arg_key == '*') { $has_star = true; continue; } if (isset($arguments[$arg_key])) { $value = $arguments[$arg_key]; $size = strlen($value); $out[] = "{$arg_key} {$size}\n{$value}"; unset($arguments[$arg_key]); } } if ($has_star) { // Mercurial takes arguments for variable argument lists roughly like // this: // // * // argname1 // argvalue1 // argname2 // argvalue2 $count = count($arguments); $out[] = "* {$count}\n"; foreach ($arguments as $key => $value) { if (in_array($key, $spec)) { // We already added this argument above, so skip it. continue; } $size = strlen($value); $out[] = "{$key} {$size}\n{$value}"; } } return implode('', $out); } private function isValidGitShallowCloneResponse($stdout, $stderr) { // If you execute `git clone --depth N ...`, git sends a request which // `git-http-backend` responds to by emitting valid output and then exiting // with a failure code and an error message. If we ignore this error, // everything works. // This is a pretty funky fix: it would be nice to more precisely detect // that a request is a `--depth N` clone request, but we don't have any code // to decode protocol frames yet. Instead, look for reasonable evidence // in the error and output that we're looking at a `--depth` clone. // For evidence this isn't completely crazy, see: // https://github.com/schacon/grack/pull/7 $stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m'; $stderr_regexp = '(The remote end hung up unexpectedly)'; $has_pack = preg_match($stdout_regexp, $stdout); $is_hangup = preg_match($stderr_regexp, $stderr); return $has_pack && $is_hangup; } private function getCommonEnvironment(PhabricatorUser $viewer) { $remote_addr = $this->getRequest()->getRemoteAddr(); return array( DiffusionCommitHookEngine::ENV_USER => $viewer->getUsername(), DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS => $remote_addr, DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'http', ); } } diff --git a/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php b/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php index 25684c1c2e..0f4829d2ec 100644 --- a/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php +++ b/src/applications/diffusion/panel/DiffusionSetPasswordPanel.php @@ -1,212 +1,232 @@ getUser(); $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); 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 ($user->comparePassword($envelope)) { $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($user) ->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()) ->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()) ->setName('password') ->setLabel(pht('New VCS Password')) ->setError($e_password)) ->appendChild( id(new AphrontFormPasswordControl()) ->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 (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) { + $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($user); 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/repository/storage/PhabricatorRepositoryVCSPassword.php b/src/applications/repository/storage/PhabricatorRepositoryVCSPassword.php index ffdd65b958..9230bd5911 100644 --- a/src/applications/repository/storage/PhabricatorRepositoryVCSPassword.php +++ b/src/applications/repository/storage/PhabricatorRepositoryVCSPassword.php @@ -1,34 +1,46 @@ setPasswordHash($this->hashPassword($password, $user)); + $hash_envelope = $this->hashPassword($password, $user); + return $this->setPasswordHash($hash_envelope->openEnvelope()); } public function comparePassword( PhutilOpaqueEnvelope $password, PhabricatorUser $user) { - $hash = $this->hashPassword($password, $user); - return ($hash == $this->getPasswordHash()); + return PhabricatorPasswordHasher::comparePassword( + $this->getPasswordHashInput($password, $user), + new PhutilOpaqueEnvelope($this->getPasswordHash())); } - private function hashPassword( + private function getPasswordHashInput( PhutilOpaqueEnvelope $password, PhabricatorUser $user) { - if ($user->getPHID() != $this->getUserPHID()) { throw new Exception("User does not match password user PHID!"); } - return PhabricatorHash::digestPassword($password, $user->getPHID()); + $raw_input = PhabricatorHash::digestPassword($password, $user->getPHID()); + return new PhutilOpaqueEnvelope($raw_input); + } + + private function hashPassword( + PhutilOpaqueEnvelope $password, + PhabricatorUser $user) { + + $input_envelope = $this->getPasswordHashInput($password, $user); + + $best_hasher = PhabricatorPasswordHasher::getBestHasher(); + return $best_hasher->getPasswordHashForStorage($input_envelope); } } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php b/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php index 9db5e08880..769ab03a90 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelPassword.php @@ -1,199 +1,180 @@ getUser(); $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. $token = $request->getStr('token'); $valid_token = false; if ($token) { $email_address = $request->getStr('email'); $email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $email_address); if ($email) { $valid_token = $user->validateEmailToken($email, $token); } } $e_old = true; $e_new = true; $e_conf = true; $errors = array(); if ($request->isFormPost()) { if (!$valid_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 ($valid_token) { // 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'); } return id(new AphrontRedirectResponse())->setURI($next); } } $hash_envelope = new PhutilOpaqueEnvelope($user->getPasswordHash()); if (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) { - $best_hash = PhabricatorPasswordHasher::getBestHasher(); $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('token', $token); if (!$valid_token) { $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Old Password')) ->setError($e_old) ->setName('old_pw')); } $form ->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('New Password')) ->setError($e_new) ->setName('new_pw')); $form ->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Confirm Password')) ->setCaption($len_caption) ->setError($e_conf) ->setName('conf_pw')); $form ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Change Password'))); - if (!strlen($user->getPasswordHash())) { - $current_name = pht('None'); - } else { - try { - $current_hasher = PhabricatorPasswordHasher::getHasherForHash( - new PhutilOpaqueEnvelope($user->getPasswordHash())); - $current_name = $current_hasher->getHumanReadableName(); - } catch (Exception $ex) { - $current_name = pht('Unknown'); - } - } - $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Current Algorithm')) - ->setValue($current_name)); - - try { - $best_hasher = PhabricatorPasswordHasher::getBestHasher(); - $best_name = $best_hasher->getHumanReadableName(); - } catch (Exception $ex) { - $best_name = pht('Unknown'); - } + ->setValue(PhabricatorPasswordHasher::getCurrentAlgorithmName( + new PhutilOpaqueEnvelope($user->getPasswordHash())))); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Best Available Algorithm')) - ->setValue($best_name)); + ->setValue(PhabricatorPasswordHasher::getBestAlgorithmName())); $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 0dcdb0e7c8..b344893689 100644 --- a/src/infrastructure/util/password/PhabricatorPasswordHasher.php +++ b/src/infrastructure/util/password/PhabricatorPasswordHasher.php @@ -1,388 +1,424 @@ 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 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 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) { $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'); + } + } + + + /** + * 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'); + } + } + }