@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 398 lines 12 kB view raw
1<?php 2 3final class PhabricatorAuthPasswordEngine 4 extends Phobject { 5 6 private $viewer; 7 private $contentSource; 8 private $object; 9 private $passwordType; 10 private $upgradeHashers = true; 11 12 public function setViewer(PhabricatorUser $viewer) { 13 $this->viewer = $viewer; 14 return $this; 15 } 16 17 public function getViewer() { 18 return $this->viewer; 19 } 20 21 public function setContentSource(PhabricatorContentSource $content_source) { 22 $this->contentSource = $content_source; 23 return $this; 24 } 25 26 public function getContentSource() { 27 return $this->contentSource; 28 } 29 30 public function setObject(PhabricatorAuthPasswordHashInterface $object) { 31 $this->object = $object; 32 return $this; 33 } 34 35 public function getObject() { 36 return $this->object; 37 } 38 39 public function setPasswordType($password_type) { 40 $this->passwordType = $password_type; 41 return $this; 42 } 43 44 public function getPasswordType() { 45 return $this->passwordType; 46 } 47 48 public function setUpgradeHashers($upgrade_hashers) { 49 $this->upgradeHashers = $upgrade_hashers; 50 return $this; 51 } 52 53 public function getUpgradeHashers() { 54 return $this->upgradeHashers; 55 } 56 57 public function checkNewPassword( 58 PhutilOpaqueEnvelope $password, 59 PhutilOpaqueEnvelope $confirm, 60 $can_skip = false) { 61 62 $raw_password = $password->openEnvelope(); 63 64 if (!strlen($raw_password)) { 65 if ($can_skip) { 66 throw new PhabricatorAuthPasswordException( 67 pht('You must choose a password or skip this step.'), 68 pht('Required')); 69 } else { 70 throw new PhabricatorAuthPasswordException( 71 pht('You must choose a password.'), 72 pht('Required')); 73 } 74 } 75 76 $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); 77 $min_len = (int)$min_len; 78 if ($min_len) { 79 if (strlen($raw_password) < $min_len) { 80 throw new PhabricatorAuthPasswordException( 81 pht( 82 'The selected password is too short. Passwords must be a minimum '. 83 'of %s characters long.', 84 new PhutilNumber($min_len)), 85 pht('Too Short')); 86 } 87 } 88 89 $raw_confirm = $confirm->openEnvelope(); 90 91 if (!strlen($raw_confirm)) { 92 throw new PhabricatorAuthPasswordException( 93 pht('You must confirm the selected password.'), 94 null, 95 pht('Required')); 96 } 97 98 if ($raw_password !== $raw_confirm) { 99 throw new PhabricatorAuthPasswordException( 100 pht('The password and confirmation do not match.'), 101 pht('Invalid'), 102 pht('Invalid')); 103 } 104 105 if (PhabricatorCommonPasswords::isCommonPassword($raw_password)) { 106 throw new PhabricatorAuthPasswordException( 107 pht( 108 'The selected password is very weak: it is one of the most common '. 109 'passwords in use. Choose a stronger password.'), 110 pht('Very Weak')); 111 } 112 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 116 // checks. 117 118 $object = $this->getObject(); 119 120 if ($object->getPHID()) { 121 if ($this->isRevokedPassword($password)) { 122 throw new PhabricatorAuthPasswordException( 123 pht( 124 'The password you entered has been revoked. You can not reuse '. 125 'a password which has been revoked. Choose a new password.'), 126 pht('Revoked')); 127 } 128 129 if (!$this->isUniquePassword($password)) { 130 throw new PhabricatorAuthPasswordException( 131 pht( 132 'The password you entered is the same as another password '. 133 'associated with your account. Each password must be unique.'), 134 pht('Not Unique')); 135 } 136 } 137 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); 143 144 // Smallest number of overlapping characters that we'll consider to be 145 // too similar. 146 $minimum_similarity = 4; 147 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(); 152 153 $blocklist = array_filter($blocklist); 154 155 // Generate additional subterms by splitting the raw blocklist on 156 // characters like "@", " " (space), and "." to break up email addresses, 157 // readable names, and domain names into components. 158 $terms_map = array(); 159 foreach ($blocklist as $term) { 160 $terms_map[$term] = $term; 161 foreach (preg_split('/[ @.]/', $term) as $subterm) { 162 $terms_map[$subterm] = $term; 163 } 164 } 165 166 // Skip very short terms: it's okay if your password has the substring 167 // "com" in it somewhere even if the install is on "mycompany.com". 168 foreach ($terms_map as $term => $source) { 169 if (strlen($term) < $minimum_similarity) { 170 unset($terms_map[$term]); 171 } 172 } 173 174 // Normalize terms for comparison. 175 $normal_map = array(); 176 foreach ($terms_map as $term => $source) { 177 $term = phutil_utf8_strtolower($term); 178 $normal_map[$term] = $source; 179 } 180 181 // Finally, make sure that none of the terms appear in the password, 182 // and that the password does not appear in any of the terms. 183 $normal_password = phutil_utf8_strtolower($raw_password); 184 if (strlen($normal_password) >= $minimum_similarity) { 185 foreach ($normal_map as $term => $source) { 186 187 // See T2312. This may be required if the term list includes numeric 188 // strings like "12345", which will be cast to integers when used as 189 // array keys. 190 $term = phutil_string_cast($term); 191 192 if (strpos($term, $normal_password) === false && 193 strpos($normal_password, $term) === false) { 194 continue; 195 } 196 197 throw new PhabricatorAuthPasswordException( 198 pht( 199 'The password you entered is very similar to a nonsecret account '. 200 'identifier (like a username or email address). Choose a more '. 201 'distinct password.'), 202 pht('Not Distinct')); 203 } 204 } 205 } 206 207 public function isValidPassword(PhutilOpaqueEnvelope $envelope) { 208 $this->requireSetup(); 209 210 $password_type = $this->getPasswordType(); 211 212 $passwords = $this->newQuery() 213 ->withPasswordTypes(array($password_type)) 214 ->withIsRevoked(false) 215 ->execute(); 216 217 $matches = $this->getMatches($envelope, $passwords); 218 if (!$matches) { 219 return false; 220 } 221 222 if ($this->shouldUpgradeHashers()) { 223 $this->upgradeHashers($envelope, $matches); 224 } 225 226 return true; 227 } 228 229 public function isUniquePassword(PhutilOpaqueEnvelope $envelope) { 230 $this->requireSetup(); 231 232 $password_type = $this->getPasswordType(); 233 234 // To test that the password is unique, we're loading all active and 235 // revoked passwords for all roles for the given user, then throwing out 236 // the active passwords for the current role (so a password can't 237 // collide with itself). 238 239 // Note that two different objects can have the same password (say, 240 // users @alice and @bailey). We're only preventing @alice from using 241 // the same password for everything. 242 243 $passwords = $this->newQuery() 244 ->execute(); 245 246 foreach ($passwords as $key => $password) { 247 $same_type = ($password->getPasswordType() === $password_type); 248 $is_active = !$password->getIsRevoked(); 249 250 if ($same_type && $is_active) { 251 unset($passwords[$key]); 252 } 253 } 254 255 $matches = $this->getMatches($envelope, $passwords); 256 257 return !$matches; 258 } 259 260 public function isRevokedPassword(PhutilOpaqueEnvelope $envelope) { 261 $this->requireSetup(); 262 263 // To test if a password is revoked, we're loading all revoked passwords 264 // across all roles for the given user. If a password was revoked in one 265 // role, you can't reuse it in a different role. 266 267 $passwords = $this->newQuery() 268 ->withIsRevoked(true) 269 ->execute(); 270 271 $matches = $this->getMatches($envelope, $passwords); 272 273 return (bool)$matches; 274 } 275 276 private function requireSetup() { 277 if (!$this->getObject()) { 278 throw new PhutilInvalidStateException('setObject'); 279 } 280 281 if (!$this->getPasswordType()) { 282 throw new PhutilInvalidStateException('setPasswordType'); 283 } 284 285 if (!$this->getViewer()) { 286 throw new PhutilInvalidStateException('setViewer'); 287 } 288 289 if ($this->shouldUpgradeHashers()) { 290 if (!$this->getContentSource()) { 291 throw new PhutilInvalidStateException('setContentSource'); 292 } 293 } 294 } 295 296 private function shouldUpgradeHashers() { 297 if (!$this->getUpgradeHashers()) { 298 return false; 299 } 300 301 if (PhabricatorEnv::isReadOnly()) { 302 // Don't try to upgrade hashers if we're in read-only mode, since we 303 // won't be able to write the new hash to the database. 304 return false; 305 } 306 307 return true; 308 } 309 310 private function newQuery() { 311 $viewer = $this->getViewer(); 312 $object = $this->getObject(); 313 $password_type = $this->getPasswordType(); 314 315 return id(new PhabricatorAuthPasswordQuery()) 316 ->setViewer($viewer) 317 ->withObjectPHIDs(array($object->getPHID())); 318 } 319 320 private function getMatches( 321 PhutilOpaqueEnvelope $envelope, 322 array $passwords) { 323 324 $object = $this->getObject(); 325 326 $matches = array(); 327 foreach ($passwords as $password) { 328 try { 329 $is_match = $password->comparePassword($envelope, $object); 330 } catch (PhabricatorPasswordHasherUnavailableException $ex) { 331 $is_match = false; 332 } 333 334 if ($is_match) { 335 $matches[] = $password; 336 } 337 } 338 339 return $matches; 340 } 341 342 /** 343 * @param PhutilOpaqueEnvelope $envelope 344 * @param array<PhabricatorAuthPassword> $passwords 345 */ 346 private function upgradeHashers( 347 PhutilOpaqueEnvelope $envelope, 348 array $passwords) { 349 350 assert_instances_of($passwords, PhabricatorAuthPassword::class); 351 352 $need_upgrade = array(); 353 foreach ($passwords as $password) { 354 if (!$password->canUpgrade()) { 355 continue; 356 } 357 $need_upgrade[] = $password; 358 } 359 360 if (!$need_upgrade) { 361 return; 362 } 363 364 $upgrade_type = PhabricatorAuthPasswordUpgradeTransaction::TRANSACTIONTYPE; 365 $viewer = $this->getViewer(); 366 $content_source = $this->getContentSource(); 367 368 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 369 foreach ($need_upgrade as $password) { 370 371 // This does the actual upgrade. We then apply a transaction to make 372 // the upgrade more visible and auditable. 373 $old_hasher = $password->getHasher(); 374 $password->upgradePasswordHasher($envelope, $this->getObject()); 375 $new_hasher = $password->getHasher(); 376 377 // NOTE: We must save the change before applying transactions because 378 // the editor will reload the object to obtain a read lock. 379 $password->save(); 380 381 $xactions = array(); 382 383 $xactions[] = $password->getApplicationTransactionTemplate() 384 ->setTransactionType($upgrade_type) 385 ->setNewValue($new_hasher->getHashName()); 386 387 $editor = $password->getApplicationTransactionEditor() 388 ->setActor($viewer) 389 ->setContinueOnNoEffect(true) 390 ->setContinueOnMissingFields(true) 391 ->setContentSource($content_source) 392 ->setOldHasher($old_hasher) 393 ->applyTransactions($password, $xactions); 394 } 395 unset($unguarded); 396 } 397 398}