1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-12 16:46:15 +01:00
phorge-phorge/src/applications/auth/engine/PhabricatorAuthPasswordEngine.php
epriestley 1f6a4cfffe Prevent users from selecting excessively bad passwords based on their username or email address
Summary:
Ref T13216. We occasionally receive HackerOne reports concerned that you can select your username as a password. I suspect very few users actually do this and that this is mostly a compliance/checklist sort of issue, not a real security issue.

On this install, we have about 41,000 user accounts. Of these, 100 have their username as a password (account or VCS). A substantial subset of these are either explicitly intentional ("demo", "bugmenot") or obvious test accounts ("test" in name, or name is a nonsensical string of gibberish, or looks like "tryphab" or similar) or just a bunch of numbers (?), or clearly a "researcher" doing this on purpose (e.g., name includes "pentest" or "xss" or similar).

So I'm not sure real users are actually very inclined to do this, and we can't really ever stop them from picking awful passwords anyway. But we //can// stop researchers from reporting that this is an issue.

Don't allow users to select passwords which contain words in a blocklist: their username, real name, email addresses, or the install's domain name. These words also aren't allowed to contain the password (that is, neither your password nor your username may be a substring of the other one). We also do a little normalization to try to split apart email addresses, domains, and real names, so I can't have "evan1234" as my password.

Test Plan:
  - Added unit tests and made them pass.
  - Tried to set my password to a bunch of variations of my username / email / domain name / real name / etc, got rejected.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13216

Differential Revision: https://secure.phabricator.com/D19776
2018-11-06 12:44:07 -08:00

386 lines
11 KiB
PHP

<?php
final class PhabricatorAuthPasswordEngine
extends Phobject {
private $viewer;
private $contentSource;
private $object;
private $passwordType;
private $upgradeHashers = true;
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function setContentSource(PhabricatorContentSource $content_source) {
$this->contentSource = $content_source;
return $this;
}
public function getContentSource() {
return $this->contentSource;
}
public function setObject(PhabricatorAuthPasswordHashInterface $object) {
$this->object = $object;
return $this;
}
public function getObject() {
return $this->object;
}
public function setPasswordType($password_type) {
$this->passwordType = $password_type;
return $this;
}
public function getPasswordType() {
return $this->passwordType;
}
public function setUpgradeHashers($upgrade_hashers) {
$this->upgradeHashers = $upgrade_hashers;
return $this;
}
public function getUpgradeHashers() {
return $this->upgradeHashers;
}
public function checkNewPassword(
PhutilOpaqueEnvelope $password,
PhutilOpaqueEnvelope $confirm,
$can_skip = false) {
$raw_password = $password->openEnvelope();
if (!strlen($raw_password)) {
if ($can_skip) {
throw new PhabricatorAuthPasswordException(
pht('You must choose a password or skip this step.'),
pht('Required'));
} else {
throw new PhabricatorAuthPasswordException(
pht('You must choose a password.'),
pht('Required'));
}
}
$min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length');
$min_len = (int)$min_len;
if ($min_len) {
if (strlen($raw_password) < $min_len) {
throw new PhabricatorAuthPasswordException(
pht(
'The selected password is too short. Passwords must be a minimum '.
'of %s characters long.',
new PhutilNumber($min_len)),
pht('Too Short'));
}
}
$raw_confirm = $confirm->openEnvelope();
if (!strlen($raw_confirm)) {
throw new PhabricatorAuthPasswordException(
pht('You must confirm the selected password.'),
null,
pht('Required'));
}
if ($raw_password !== $raw_confirm) {
throw new PhabricatorAuthPasswordException(
pht('The password and confirmation do not match.'),
pht('Invalid'),
pht('Invalid'));
}
if (PhabricatorCommonPasswords::isCommonPassword($raw_password)) {
throw new PhabricatorAuthPasswordException(
pht(
'The selected password is very weak: it is one of the most common '.
'passwords in use. Choose a stronger password.'),
pht('Very Weak'));
}
// If we're creating a brand new object (like registering a new user)
// and it does not have a PHID yet, it isn't possible for it to have any
// revoked passwords or colliding passwords either, so we can skip these
// checks.
$object = $this->getObject();
if ($object->getPHID()) {
if ($this->isRevokedPassword($password)) {
throw new PhabricatorAuthPasswordException(
pht(
'The password you entered has been revoked. You can not reuse '.
'a password which has been revoked. Choose a new password.'),
pht('Revoked'));
}
if (!$this->isUniquePassword($password)) {
throw new PhabricatorAuthPasswordException(
pht(
'The password you entered is the same as another password '.
'associated with your account. Each password must be unique.'),
pht('Not Unique'));
}
}
// Prevent use of passwords which are similar to any object identifier.
// For example, if your username is "alincoln", your password may not be
// "alincoln", "lincoln", or "alincoln1".
$viewer = $this->getViewer();
$blocklist = $object->newPasswordBlocklist($viewer, $this);
// Smallest number of overlapping characters that we'll consider to be
// too similar.
$minimum_similarity = 4;
// Add the domain name to the blocklist.
$base_uri = PhabricatorEnv::getAnyBaseURI();
$base_uri = new PhutilURI($base_uri);
$blocklist[] = $base_uri->getDomain();
// Generate additional subterms by splitting the raw blocklist on
// characters like "@", " " (space), and "." to break up email addresses,
// readable names, and domain names into components.
$terms_map = array();
foreach ($blocklist as $term) {
$terms_map[$term] = $term;
foreach (preg_split('/[ @.]/', $term) as $subterm) {
$terms_map[$subterm] = $term;
}
}
// Skip very short terms: it's okay if your password has the substring
// "com" in it somewhere even if the install is on "mycompany.com".
foreach ($terms_map as $term => $source) {
if (strlen($term) < $minimum_similarity) {
unset($terms_map[$term]);
}
}
// Normalize terms for comparison.
$normal_map = array();
foreach ($terms_map as $term => $source) {
$term = phutil_utf8_strtolower($term);
$normal_map[$term] = $source;
}
// Finally, make sure that none of the terms appear in the password,
// and that the password does not appear in any of the terms.
$normal_password = phutil_utf8_strtolower($raw_password);
if (strlen($normal_password) >= $minimum_similarity) {
foreach ($normal_map as $term => $source) {
if (strpos($term, $normal_password) === false &&
strpos($normal_password, $term) === false) {
continue;
}
throw new PhabricatorAuthPasswordException(
pht(
'The password you entered is very similar to a nonsecret account '.
'identifier (like a username or email address). Choose a more '.
'distinct password.'),
pht('Not Distinct'));
}
}
}
public function isValidPassword(PhutilOpaqueEnvelope $envelope) {
$this->requireSetup();
$password_type = $this->getPasswordType();
$passwords = $this->newQuery()
->withPasswordTypes(array($password_type))
->withIsRevoked(false)
->execute();
$matches = $this->getMatches($envelope, $passwords);
if (!$matches) {
return false;
}
if ($this->shouldUpgradeHashers()) {
$this->upgradeHashers($envelope, $matches);
}
return true;
}
public function isUniquePassword(PhutilOpaqueEnvelope $envelope) {
$this->requireSetup();
$password_type = $this->getPasswordType();
// To test that the password is unique, we're loading all active and
// revoked passwords for all roles for the given user, then throwing out
// the active passwords for the current role (so a password can't
// collide with itself).
// Note that two different objects can have the same password (say,
// users @alice and @bailey). We're only preventing @alice from using
// the same password for everything.
$passwords = $this->newQuery()
->execute();
foreach ($passwords as $key => $password) {
$same_type = ($password->getPasswordType() === $password_type);
$is_active = !$password->getIsRevoked();
if ($same_type && $is_active) {
unset($passwords[$key]);
}
}
$matches = $this->getMatches($envelope, $passwords);
return !$matches;
}
public function isRevokedPassword(PhutilOpaqueEnvelope $envelope) {
$this->requireSetup();
// To test if a password is revoked, we're loading all revoked passwords
// across all roles for the given user. If a password was revoked in one
// role, you can't reuse it in a different role.
$passwords = $this->newQuery()
->withIsRevoked(true)
->execute();
$matches = $this->getMatches($envelope, $passwords);
return (bool)$matches;
}
private function requireSetup() {
if (!$this->getObject()) {
throw new PhutilInvalidStateException('setObject');
}
if (!$this->getPasswordType()) {
throw new PhutilInvalidStateException('setPasswordType');
}
if (!$this->getViewer()) {
throw new PhutilInvalidStateException('setViewer');
}
if ($this->shouldUpgradeHashers()) {
if (!$this->getContentSource()) {
throw new PhutilInvalidStateException('setContentSource');
}
}
}
private function shouldUpgradeHashers() {
if (!$this->getUpgradeHashers()) {
return false;
}
if (PhabricatorEnv::isReadOnly()) {
// Don't try to upgrade hashers if we're in read-only mode, since we
// won't be able to write the new hash to the database.
return false;
}
return true;
}
private function newQuery() {
$viewer = $this->getViewer();
$object = $this->getObject();
$password_type = $this->getPasswordType();
return id(new PhabricatorAuthPasswordQuery())
->setViewer($viewer)
->withObjectPHIDs(array($object->getPHID()));
}
private function getMatches(
PhutilOpaqueEnvelope $envelope,
array $passwords) {
$object = $this->getObject();
$matches = array();
foreach ($passwords as $password) {
try {
$is_match = $password->comparePassword($envelope, $object);
} catch (PhabricatorPasswordHasherUnavailableException $ex) {
$is_match = false;
}
if ($is_match) {
$matches[] = $password;
}
}
return $matches;
}
private function upgradeHashers(
PhutilOpaqueEnvelope $envelope,
array $passwords) {
assert_instances_of($passwords, 'PhabricatorAuthPassword');
$need_upgrade = array();
foreach ($passwords as $password) {
if (!$password->canUpgrade()) {
continue;
}
$need_upgrade[] = $password;
}
if (!$need_upgrade) {
return;
}
$upgrade_type = PhabricatorAuthPasswordUpgradeTransaction::TRANSACTIONTYPE;
$viewer = $this->getViewer();
$content_source = $this->getContentSource();
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
foreach ($need_upgrade as $password) {
// This does the actual upgrade. We then apply a transaction to make
// the upgrade more visible and auditable.
$old_hasher = $password->getHasher();
$password->upgradePasswordHasher($envelope, $this->getObject());
$new_hasher = $password->getHasher();
// NOTE: We must save the change before applying transactions because
// the editor will reload the object to obtain a read lock.
$password->save();
$xactions = array();
$xactions[] = $password->getApplicationTransactionTemplate()
->setTransactionType($upgrade_type)
->setNewValue($new_hasher->getHashName());
$editor = $password->getApplicationTransactionEditor()
->setActor($viewer)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->setContentSource($content_source)
->setOldHasher($old_hasher)
->applyTransactions($password, $xactions);
}
unset($unguarded);
}
}