Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/applications/auth/engine/PhabricatorAuthPasswordEngine.php
12256 views
1
<?php
2
3
final 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
// 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;
161
}
162
}
163
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]);
169
}
170
}
171
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;
177
}
178
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) {
184
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);
189
190
if (strpos($term, $normal_password) === false &&
191
strpos($normal_password, $term) === false) {
192
continue;
193
}
194
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'));
201
}
202
}
203
}
204
205
public function isValidPassword(PhutilOpaqueEnvelope $envelope) {
206
$this->requireSetup();
207
208
$password_type = $this->getPasswordType();
209
210
$passwords = $this->newQuery()
211
->withPasswordTypes(array($password_type))
212
->withIsRevoked(false)
213
->execute();
214
215
$matches = $this->getMatches($envelope, $passwords);
216
if (!$matches) {
217
return false;
218
}
219
220
if ($this->shouldUpgradeHashers()) {
221
$this->upgradeHashers($envelope, $matches);
222
}
223
224
return true;
225
}
226
227
public function isUniquePassword(PhutilOpaqueEnvelope $envelope) {
228
$this->requireSetup();
229
230
$password_type = $this->getPasswordType();
231
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).
236
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.
240
241
$passwords = $this->newQuery()
242
->execute();
243
244
foreach ($passwords as $key => $password) {
245
$same_type = ($password->getPasswordType() === $password_type);
246
$is_active = !$password->getIsRevoked();
247
248
if ($same_type && $is_active) {
249
unset($passwords[$key]);
250
}
251
}
252
253
$matches = $this->getMatches($envelope, $passwords);
254
255
return !$matches;
256
}
257
258
public function isRevokedPassword(PhutilOpaqueEnvelope $envelope) {
259
$this->requireSetup();
260
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.
264
265
$passwords = $this->newQuery()
266
->withIsRevoked(true)
267
->execute();
268
269
$matches = $this->getMatches($envelope, $passwords);
270
271
return (bool)$matches;
272
}
273
274
private function requireSetup() {
275
if (!$this->getObject()) {
276
throw new PhutilInvalidStateException('setObject');
277
}
278
279
if (!$this->getPasswordType()) {
280
throw new PhutilInvalidStateException('setPasswordType');
281
}
282
283
if (!$this->getViewer()) {
284
throw new PhutilInvalidStateException('setViewer');
285
}
286
287
if ($this->shouldUpgradeHashers()) {
288
if (!$this->getContentSource()) {
289
throw new PhutilInvalidStateException('setContentSource');
290
}
291
}
292
}
293
294
private function shouldUpgradeHashers() {
295
if (!$this->getUpgradeHashers()) {
296
return false;
297
}
298
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;
303
}
304
305
return true;
306
}
307
308
private function newQuery() {
309
$viewer = $this->getViewer();
310
$object = $this->getObject();
311
$password_type = $this->getPasswordType();
312
313
return id(new PhabricatorAuthPasswordQuery())
314
->setViewer($viewer)
315
->withObjectPHIDs(array($object->getPHID()));
316
}
317
318
private function getMatches(
319
PhutilOpaqueEnvelope $envelope,
320
array $passwords) {
321
322
$object = $this->getObject();
323
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;
330
}
331
332
if ($is_match) {
333
$matches[] = $password;
334
}
335
}
336
337
return $matches;
338
}
339
340
private function upgradeHashers(
341
PhutilOpaqueEnvelope $envelope,
342
array $passwords) {
343
344
assert_instances_of($passwords, 'PhabricatorAuthPassword');
345
346
$need_upgrade = array();
347
foreach ($passwords as $password) {
348
if (!$password->canUpgrade()) {
349
continue;
350
}
351
$need_upgrade[] = $password;
352
}
353
354
if (!$need_upgrade) {
355
return;
356
}
357
358
$upgrade_type = PhabricatorAuthPasswordUpgradeTransaction::TRANSACTIONTYPE;
359
$viewer = $this->getViewer();
360
$content_source = $this->getContentSource();
361
362
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
363
foreach ($need_upgrade as $password) {
364
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();
370
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();
374
375
$xactions = array();
376
377
$xactions[] = $password->getApplicationTransactionTemplate()
378
->setTransactionType($upgrade_type)
379
->setNewValue($new_hasher->getHashName());
380
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);
388
}
389
unset($unguarded);
390
}
391
392
}
393
394