2018-01-20 19:22:09 -08:00
|
|
|
<?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;
|
|
|
|
}
|
|
|
|
|
2018-01-23 08:10:54 -08:00
|
|
|
public function setObject(PhabricatorAuthPasswordHashInterface $object) {
|
2018-01-20 19:22:09 -08:00
|
|
|
$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;
|
|
|
|
}
|
|
|
|
|
2018-01-21 16:20:01 -08:00
|
|
|
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'));
|
|
|
|
}
|
|
|
|
|
2018-01-21 15:42:24 -08:00
|
|
|
// 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.
|
2018-01-21 16:20:01 -08:00
|
|
|
|
2018-01-21 15:42:24 -08:00
|
|
|
if ($this->getObject()->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'));
|
|
|
|
}
|
2018-01-21 16:20:01 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-01-20 19:22:09 -08:00
|
|
|
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();
|
|
|
|
|
2018-02-10 17:42:02 -08:00
|
|
|
// NOTE: We must save the change before applying transactions because
|
|
|
|
// the editor will reload the object to obtain a read lock.
|
|
|
|
$password->save();
|
|
|
|
|
2018-01-20 19:22:09 -08:00
|
|
|
$xactions = array();
|
|
|
|
|
|
|
|
$xactions[] = $password->getApplicationTransactionTemplate()
|
|
|
|
->setTransactionType($upgrade_type)
|
|
|
|
->setNewValue($new_hasher->getHashName());
|
|
|
|
|
|
|
|
$editor = $password->getApplicationTransactionEditor()
|
|
|
|
->setActor($viewer)
|
|
|
|
->setContinueOnNoEffect(true)
|
|
|
|
->setContinueOnMissingFields(true)
|
|
|
|
->setContentSource($content_source)
|
2018-01-21 07:26:10 -08:00
|
|
|
->setOldHasher($old_hasher)
|
2018-01-20 19:22:09 -08:00
|
|
|
->applyTransactions($password, $xactions);
|
|
|
|
}
|
|
|
|
unset($unguarded);
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|