@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
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}