Guarantee terms in PhabricatorAuthPasswordEngine are strings
[phabricator/blender.git] / src / applications / auth / engine / PhabricatorAuthPasswordEngine.php
bloba1fec4a6d21160b2ec514796ba1c205b5372a26c
1 <?php
3 final class PhabricatorAuthPasswordEngine
4 extends Phobject {
6 private $viewer;
7 private $contentSource;
8 private $object;
9 private $passwordType;
10 private $upgradeHashers = true;
12 public function setViewer(PhabricatorUser $viewer) {
13 $this->viewer = $viewer;
14 return $this;
17 public function getViewer() {
18 return $this->viewer;
21 public function setContentSource(PhabricatorContentSource $content_source) {
22 $this->contentSource = $content_source;
23 return $this;
26 public function getContentSource() {
27 return $this->contentSource;
30 public function setObject(PhabricatorAuthPasswordHashInterface $object) {
31 $this->object = $object;
32 return $this;
35 public function getObject() {
36 return $this->object;
39 public function setPasswordType($password_type) {
40 $this->passwordType = $password_type;
41 return $this;
44 public function getPasswordType() {
45 return $this->passwordType;
48 public function setUpgradeHashers($upgrade_hashers) {
49 $this->upgradeHashers = $upgrade_hashers;
50 return $this;
53 public function getUpgradeHashers() {
54 return $this->upgradeHashers;
57 public function checkNewPassword(
58 PhutilOpaqueEnvelope $password,
59 PhutilOpaqueEnvelope $confirm,
60 $can_skip = false) {
62 $raw_password = $password->openEnvelope();
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'));
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'));
89 $raw_confirm = $confirm->openEnvelope();
91 if (!strlen($raw_confirm)) {
92 throw new PhabricatorAuthPasswordException(
93 pht('You must confirm the selected password.'),
94 null,
95 pht('Required'));
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'));
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'));
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.
118 $object = $this->getObject();
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'));
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'));
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
145 // too similar.
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
187 // array keys.
188 $term = phutil_string_cast($term);
190 if (strpos($term, $normal_password) === false &&
191 strpos($normal_password, $term) === false) {
192 continue;
195 throw new PhabricatorAuthPasswordException(
196 pht(
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)
213 ->execute();
215 $matches = $this->getMatches($envelope, $passwords);
216 if (!$matches) {
217 return false;
220 if ($this->shouldUpgradeHashers()) {
221 $this->upgradeHashers($envelope, $matches);
224 return true;
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()
242 ->execute();
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);
255 return !$matches;
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)
267 ->execute();
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()) {
296 return false;
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.
302 return false;
305 return true;
308 private function newQuery() {
309 $viewer = $this->getViewer();
310 $object = $this->getObject();
311 $password_type = $this->getPasswordType();
313 return id(new PhabricatorAuthPasswordQuery())
314 ->setViewer($viewer)
315 ->withObjectPHIDs(array($object->getPHID()));
318 private function getMatches(
319 PhutilOpaqueEnvelope $envelope,
320 array $passwords) {
322 $object = $this->getObject();
324 $matches = array();
325 foreach ($passwords as $password) {
326 try {
327 $is_match = $password->comparePassword($envelope, $object);
328 } catch (PhabricatorPasswordHasherUnavailableException $ex) {
329 $is_match = false;
332 if ($is_match) {
333 $matches[] = $password;
337 return $matches;
340 private function upgradeHashers(
341 PhutilOpaqueEnvelope $envelope,
342 array $passwords) {
344 assert_instances_of($passwords, 'PhabricatorAuthPassword');
346 $need_upgrade = array();
347 foreach ($passwords as $password) {
348 if (!$password->canUpgrade()) {
349 continue;
351 $need_upgrade[] = $password;
354 if (!$need_upgrade) {
355 return;
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.
373 $password->save();
375 $xactions = array();
377 $xactions[] = $password->getApplicationTransactionTemplate()
378 ->setTransactionType($upgrade_type)
379 ->setNewValue($new_hasher->getHashName());
381 $editor = $password->getApplicationTransactionEditor()
382 ->setActor($viewer)
383 ->setContinueOnNoEffect(true)
384 ->setContinueOnMissingFields(true)
385 ->setContentSource($content_source)
386 ->setOldHasher($old_hasher)
387 ->applyTransactions($password, $xactions);
389 unset($unguarded);