3 final class PhabricatorAuthPasswordEngine
7 private $contentSource;
10 private $upgradeHashers = true;
12 public function setViewer(PhabricatorUser
$viewer) {
13 $this->viewer
= $viewer;
17 public function getViewer() {
21 public function setContentSource(PhabricatorContentSource
$content_source) {
22 $this->contentSource
= $content_source;
26 public function getContentSource() {
27 return $this->contentSource
;
30 public function setObject(PhabricatorAuthPasswordHashInterface
$object) {
31 $this->object = $object;
35 public function getObject() {
39 public function setPasswordType($password_type) {
40 $this->passwordType
= $password_type;
44 public function getPasswordType() {
45 return $this->passwordType
;
48 public function setUpgradeHashers($upgrade_hashers) {
49 $this->upgradeHashers
= $upgrade_hashers;
53 public function getUpgradeHashers() {
54 return $this->upgradeHashers
;
57 public function checkNewPassword(
58 PhutilOpaqueEnvelope
$password,
59 PhutilOpaqueEnvelope
$confirm,
62 $raw_password = $password->openEnvelope();
64 if (!strlen($raw_password)) {
66 throw new PhabricatorAuthPasswordException(
67 pht('You must choose a password or skip this step.'),
70 throw new PhabricatorAuthPasswordException(
71 pht('You must choose a password.'),
76 $min_len = PhabricatorEnv
::getEnvConfig('account.minimum-password-length');
77 $min_len = (int)$min_len;
79 if (strlen($raw_password) < $min_len) {
80 throw new PhabricatorAuthPasswordException(
82 'The selected password is too short. Passwords must be a minimum '.
83 'of %s characters long.',
84 new PhutilNumber($min_len)),
89 $raw_confirm = $confirm->openEnvelope();
91 if (!strlen($raw_confirm)) {
92 throw new PhabricatorAuthPasswordException(
93 pht('You must confirm the selected password.'),
98 if ($raw_password !== $raw_confirm) {
99 throw new PhabricatorAuthPasswordException(
100 pht('The password and confirmation do not match.'),
105 if (PhabricatorCommonPasswords
::isCommonPassword($raw_password)) {
106 throw new PhabricatorAuthPasswordException(
108 'The selected password is very weak: it is one of the most common '.
109 'passwords in use. Choose a stronger password.'),
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
118 $object = $this->getObject();
120 if ($object->getPHID()) {
121 if ($this->isRevokedPassword($password)) {
122 throw new PhabricatorAuthPasswordException(
124 'The password you entered has been revoked. You can not reuse '.
125 'a password which has been revoked. Choose a new password.'),
129 if (!$this->isUniquePassword($password)) {
130 throw new PhabricatorAuthPasswordException(
132 'The password you entered is the same as another password '.
133 'associated with your account. Each password must be unique.'),
138 // Prevent use of passwords which are similar to any object identifier.
139 // For example, if your username is "alincoln", your password may not be
140 // "alincoln", "lincoln", or "alincoln1".
141 $viewer = $this->getViewer();
142 $blocklist = $object->newPasswordBlocklist($viewer, $this);
144 // Smallest number of overlapping characters that we'll consider to be
146 $minimum_similarity = 4;
148 // Add the domain name to the blocklist.
149 $base_uri = PhabricatorEnv
::getAnyBaseURI();
150 $base_uri = new PhutilURI($base_uri);
151 $blocklist[] = $base_uri->getDomain();
153 // Generate additional subterms by splitting the raw blocklist on
154 // characters like "@", " " (space), and "." to break up email addresses,
155 // readable names, and domain names into components.
156 $terms_map = array();
157 foreach ($blocklist as $term) {
158 $terms_map[$term] = $term;
159 foreach (preg_split('/[ @.]/', $term) as $subterm) {
160 $terms_map[$subterm] = $term;
164 // Skip very short terms: it's okay if your password has the substring
165 // "com" in it somewhere even if the install is on "mycompany.com".
166 foreach ($terms_map as $term => $source) {
167 if (strlen($term) < $minimum_similarity) {
168 unset($terms_map[$term]);
172 // Normalize terms for comparison.
173 $normal_map = array();
174 foreach ($terms_map as $term => $source) {
175 $term = phutil_utf8_strtolower($term);
176 $normal_map[$term] = $source;
179 // Finally, make sure that none of the terms appear in the password,
180 // and that the password does not appear in any of the terms.
181 $normal_password = phutil_utf8_strtolower($raw_password);
182 if (strlen($normal_password) >= $minimum_similarity) {
183 foreach ($normal_map as $term => $source) {
185 // See T2312. This may be required if the term list includes numeric
186 // strings like "12345", which will be cast to integers when used as
188 $term = phutil_string_cast($term);
190 if (strpos($term, $normal_password) === false &&
191 strpos($normal_password, $term) === false) {
195 throw new PhabricatorAuthPasswordException(
197 'The password you entered is very similar to a nonsecret account '.
198 'identifier (like a username or email address). Choose a more '.
199 'distinct password.'),
200 pht('Not Distinct'));
205 public function isValidPassword(PhutilOpaqueEnvelope
$envelope) {
206 $this->requireSetup();
208 $password_type = $this->getPasswordType();
210 $passwords = $this->newQuery()
211 ->withPasswordTypes(array($password_type))
212 ->withIsRevoked(false)
215 $matches = $this->getMatches($envelope, $passwords);
220 if ($this->shouldUpgradeHashers()) {
221 $this->upgradeHashers($envelope, $matches);
227 public function isUniquePassword(PhutilOpaqueEnvelope
$envelope) {
228 $this->requireSetup();
230 $password_type = $this->getPasswordType();
232 // To test that the password is unique, we're loading all active and
233 // revoked passwords for all roles for the given user, then throwing out
234 // the active passwords for the current role (so a password can't
235 // collide with itself).
237 // Note that two different objects can have the same password (say,
238 // users @alice and @bailey). We're only preventing @alice from using
239 // the same password for everything.
241 $passwords = $this->newQuery()
244 foreach ($passwords as $key => $password) {
245 $same_type = ($password->getPasswordType() === $password_type);
246 $is_active = !$password->getIsRevoked();
248 if ($same_type && $is_active) {
249 unset($passwords[$key]);
253 $matches = $this->getMatches($envelope, $passwords);
258 public function isRevokedPassword(PhutilOpaqueEnvelope
$envelope) {
259 $this->requireSetup();
261 // To test if a password is revoked, we're loading all revoked passwords
262 // across all roles for the given user. If a password was revoked in one
263 // role, you can't reuse it in a different role.
265 $passwords = $this->newQuery()
266 ->withIsRevoked(true)
269 $matches = $this->getMatches($envelope, $passwords);
271 return (bool)$matches;
274 private function requireSetup() {
275 if (!$this->getObject()) {
276 throw new PhutilInvalidStateException('setObject');
279 if (!$this->getPasswordType()) {
280 throw new PhutilInvalidStateException('setPasswordType');
283 if (!$this->getViewer()) {
284 throw new PhutilInvalidStateException('setViewer');
287 if ($this->shouldUpgradeHashers()) {
288 if (!$this->getContentSource()) {
289 throw new PhutilInvalidStateException('setContentSource');
294 private function shouldUpgradeHashers() {
295 if (!$this->getUpgradeHashers()) {
299 if (PhabricatorEnv
::isReadOnly()) {
300 // Don't try to upgrade hashers if we're in read-only mode, since we
301 // won't be able to write the new hash to the database.
308 private function newQuery() {
309 $viewer = $this->getViewer();
310 $object = $this->getObject();
311 $password_type = $this->getPasswordType();
313 return id(new PhabricatorAuthPasswordQuery())
315 ->withObjectPHIDs(array($object->getPHID()));
318 private function getMatches(
319 PhutilOpaqueEnvelope
$envelope,
322 $object = $this->getObject();
325 foreach ($passwords as $password) {
327 $is_match = $password->comparePassword($envelope, $object);
328 } catch (PhabricatorPasswordHasherUnavailableException
$ex) {
333 $matches[] = $password;
340 private function upgradeHashers(
341 PhutilOpaqueEnvelope
$envelope,
344 assert_instances_of($passwords, 'PhabricatorAuthPassword');
346 $need_upgrade = array();
347 foreach ($passwords as $password) {
348 if (!$password->canUpgrade()) {
351 $need_upgrade[] = $password;
354 if (!$need_upgrade) {
358 $upgrade_type = PhabricatorAuthPasswordUpgradeTransaction
::TRANSACTIONTYPE
;
359 $viewer = $this->getViewer();
360 $content_source = $this->getContentSource();
362 $unguarded = AphrontWriteGuard
::beginScopedUnguardedWrites();
363 foreach ($need_upgrade as $password) {
365 // This does the actual upgrade. We then apply a transaction to make
366 // the upgrade more visible and auditable.
367 $old_hasher = $password->getHasher();
368 $password->upgradePasswordHasher($envelope, $this->getObject());
369 $new_hasher = $password->getHasher();
371 // NOTE: We must save the change before applying transactions because
372 // the editor will reload the object to obtain a read lock.
377 $xactions[] = $password->getApplicationTransactionTemplate()
378 ->setTransactionType($upgrade_type)
379 ->setNewValue($new_hasher->getHashName());
381 $editor = $password->getApplicationTransactionEditor()
383 ->setContinueOnNoEffect(true)
384 ->setContinueOnMissingFields(true)
385 ->setContentSource($content_source)
386 ->setOldHasher($old_hasher)
387 ->applyTransactions($password, $xactions);