Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
79.77% |
138 / 173 |
|
72.00% |
18 / 25 |
CRAP | |
0.00% |
0 / 1 |
BotPassword | |
80.23% |
138 / 172 |
|
72.00% |
18 / 25 |
83.98 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
getReplicaDatabase | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getPrimaryDatabase | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
newFromUser | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
newFromCentralId | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
newUnsaved | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
isSaved | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getUserCentralId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAppId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRestrictions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getGrants | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSeparator | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getPassword | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
4.00 | |||
isInvalid | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
save | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
delete | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
invalidateAllPasswordsForUser | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
invalidateAllPasswordsForCentralId | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
removeAllPasswordsForUser | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
removeAllPasswordsForCentralId | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
generatePassword | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
canonicalizeLoginData | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
7 | |||
login | |
89.80% |
44 / 49 |
|
0.00% |
0 / 1 |
15.24 | |||
loginHook | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | /** |
3 | * Utility class for bot passwords |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | */ |
20 | |
21 | namespace MediaWiki\User; |
22 | |
23 | use FormatJson; |
24 | use IDBAccessObject; |
25 | use InvalidPassword; |
26 | use MediaWiki\Auth\AuthenticationResponse; |
27 | use MediaWiki\Auth\Throttler; |
28 | use MediaWiki\Config\Config; |
29 | use MediaWiki\HookContainer\HookRunner; |
30 | use MediaWiki\MainConfigNames; |
31 | use MediaWiki\MediaWikiServices; |
32 | use MediaWiki\Request\WebRequest; |
33 | use MediaWiki\Session\BotPasswordSessionProvider; |
34 | use MediaWiki\Session\SessionManager; |
35 | use MediaWiki\Status\Status; |
36 | use MWRestrictions; |
37 | use ObjectCache; |
38 | use Password; |
39 | use PasswordError; |
40 | use PasswordFactory; |
41 | use stdClass; |
42 | use UnexpectedValueException; |
43 | use Wikimedia\Rdbms\IDatabase; |
44 | use Wikimedia\Rdbms\IReadableDatabase; |
45 | |
46 | /** |
47 | * Utility class for bot passwords |
48 | * @since 1.27 |
49 | */ |
50 | class BotPassword { |
51 | |
52 | public const APPID_MAXLENGTH = 32; |
53 | |
54 | /** |
55 | * Minimum length for a bot password |
56 | */ |
57 | public const PASSWORD_MINLENGTH = 32; |
58 | |
59 | /** |
60 | * Maximum length of the json representation of restrictions |
61 | * @since 1.36 |
62 | */ |
63 | public const RESTRICTIONS_MAXLENGTH = 65535; |
64 | |
65 | /** |
66 | * Maximum length of the json representation of grants |
67 | * @since 1.36 |
68 | */ |
69 | public const GRANTS_MAXLENGTH = 65535; |
70 | |
71 | /** @var bool */ |
72 | private $isSaved; |
73 | |
74 | /** @var int */ |
75 | private $centralId; |
76 | |
77 | /** @var string */ |
78 | private $appId; |
79 | |
80 | /** @var string */ |
81 | private $token; |
82 | |
83 | /** @var MWRestrictions */ |
84 | private $restrictions; |
85 | |
86 | /** @var string[] */ |
87 | private $grants; |
88 | |
89 | /** @var int Defaults to {@see READ_NORMAL} */ |
90 | private $flags; |
91 | |
92 | /** |
93 | * @internal only public for construction in BotPasswordStore |
94 | * |
95 | * @param stdClass $row bot_passwords database row |
96 | * @param bool $isSaved Whether the bot password was read from the database |
97 | * @param int $flags IDBAccessObject read flags |
98 | */ |
99 | public function __construct( $row, $isSaved, $flags = IDBAccessObject::READ_NORMAL ) { |
100 | $this->isSaved = $isSaved; |
101 | $this->flags = $flags; |
102 | |
103 | $this->centralId = (int)$row->bp_user; |
104 | $this->appId = $row->bp_app_id; |
105 | $this->token = $row->bp_token; |
106 | $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions ); |
107 | $this->grants = FormatJson::decode( $row->bp_grants ); |
108 | } |
109 | |
110 | public static function getReplicaDatabase(): IReadableDatabase { |
111 | return MediaWikiServices::getInstance() |
112 | ->getBotPasswordStore() |
113 | ->getReplicaDatabase(); |
114 | } |
115 | |
116 | public static function getPrimaryDatabase(): IDatabase { |
117 | return MediaWikiServices::getInstance() |
118 | ->getBotPasswordStore() |
119 | ->getPrimaryDatabase(); |
120 | } |
121 | |
122 | /** |
123 | * Load a BotPassword from the database |
124 | * @param UserIdentity $userIdentity |
125 | * @param string $appId |
126 | * @param int $flags IDBAccessObject read flags |
127 | * @return BotPassword|null |
128 | */ |
129 | public static function newFromUser( UserIdentity $userIdentity, $appId, $flags = IDBAccessObject::READ_NORMAL ) { |
130 | return MediaWikiServices::getInstance() |
131 | ->getBotPasswordStore() |
132 | ->getByUser( $userIdentity, (string)$appId, (int)$flags ); |
133 | } |
134 | |
135 | /** |
136 | * Load a BotPassword from the database |
137 | * @param int $centralId from CentralIdLookup |
138 | * @param string $appId |
139 | * @param int $flags IDBAccessObject read flags |
140 | * @return BotPassword|null |
141 | */ |
142 | public static function newFromCentralId( $centralId, $appId, $flags = IDBAccessObject::READ_NORMAL ) { |
143 | return MediaWikiServices::getInstance() |
144 | ->getBotPasswordStore() |
145 | ->getByCentralId( (int)$centralId, (string)$appId, (int)$flags ); |
146 | } |
147 | |
148 | /** |
149 | * Create an unsaved BotPassword |
150 | * @param array $data Data to use to create the bot password. Keys are: |
151 | * - user: (UserIdentity) UserIdentity to create the password for. Overrides username and centralId. |
152 | * - username: (string) Username to create the password for. Overrides centralId. |
153 | * - centralId: (int) User central ID to create the password for. |
154 | * - appId: (string, required) App ID for the password. |
155 | * - restrictions: (MWRestrictions, optional) Restrictions. |
156 | * - grants: (string[], optional) Grants. |
157 | * @param int $flags IDBAccessObject read flags |
158 | * @return BotPassword|null |
159 | */ |
160 | public static function newUnsaved( array $data, $flags = IDBAccessObject::READ_NORMAL ) { |
161 | return MediaWikiServices::getInstance() |
162 | ->getBotPasswordStore() |
163 | ->newUnsavedBotPassword( $data, (int)$flags ); |
164 | } |
165 | |
166 | /** |
167 | * Indicate whether this is known to be saved |
168 | * @return bool |
169 | */ |
170 | public function isSaved() { |
171 | return $this->isSaved; |
172 | } |
173 | |
174 | /** |
175 | * Get the central user ID |
176 | * @return int |
177 | */ |
178 | public function getUserCentralId() { |
179 | return $this->centralId; |
180 | } |
181 | |
182 | /** |
183 | * @return string |
184 | */ |
185 | public function getAppId() { |
186 | return $this->appId; |
187 | } |
188 | |
189 | /** |
190 | * @return string |
191 | */ |
192 | public function getToken() { |
193 | return $this->token; |
194 | } |
195 | |
196 | /** |
197 | * @return MWRestrictions |
198 | */ |
199 | public function getRestrictions() { |
200 | return $this->restrictions; |
201 | } |
202 | |
203 | /** |
204 | * @return string[] |
205 | */ |
206 | public function getGrants() { |
207 | return $this->grants; |
208 | } |
209 | |
210 | /** |
211 | * Get the separator for combined user name + app ID |
212 | * @return string |
213 | */ |
214 | public static function getSeparator() { |
215 | $userrightsInterwikiDelimiter = MediaWikiServices::getInstance() |
216 | ->getMainConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter ); |
217 | return $userrightsInterwikiDelimiter; |
218 | } |
219 | |
220 | /** |
221 | * @return Password |
222 | */ |
223 | private function getPassword() { |
224 | if ( ( $this->flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
225 | $db = self::getPrimaryDatabase(); |
226 | } else { |
227 | $db = self::getReplicaDatabase(); |
228 | } |
229 | |
230 | $password = $db->newSelectQueryBuilder() |
231 | ->select( 'bp_password' ) |
232 | ->from( 'bot_passwords' ) |
233 | ->where( [ 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ] ) |
234 | ->recency( $this->flags ) |
235 | ->caller( __METHOD__ )->fetchField(); |
236 | if ( $password === false ) { |
237 | return PasswordFactory::newInvalidPassword(); |
238 | } |
239 | |
240 | $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory(); |
241 | try { |
242 | return $passwordFactory->newFromCiphertext( $password ); |
243 | } catch ( PasswordError $ex ) { |
244 | return PasswordFactory::newInvalidPassword(); |
245 | } |
246 | } |
247 | |
248 | /** |
249 | * Whether the password is currently invalid |
250 | * @since 1.32 |
251 | * @return bool |
252 | */ |
253 | public function isInvalid() { |
254 | return $this->getPassword() instanceof InvalidPassword; |
255 | } |
256 | |
257 | /** |
258 | * Save the BotPassword to the database |
259 | * @param string $operation 'update' or 'insert' |
260 | * @param Password|null $password Password to set. |
261 | * @return Status |
262 | * @throws UnexpectedValueException |
263 | */ |
264 | public function save( $operation, Password $password = null ) { |
265 | // Ensure operation is valid |
266 | if ( $operation !== 'insert' && $operation !== 'update' ) { |
267 | throw new UnexpectedValueException( |
268 | "Expected 'insert' or 'update'; got '{$operation}'." |
269 | ); |
270 | } |
271 | |
272 | $store = MediaWikiServices::getInstance()->getBotPasswordStore(); |
273 | if ( $operation === 'insert' ) { |
274 | $statusValue = $store->insertBotPassword( $this, $password ); |
275 | } else { |
276 | // Must be update, already checked above |
277 | $statusValue = $store->updateBotPassword( $this, $password ); |
278 | } |
279 | |
280 | if ( $statusValue->isGood() ) { |
281 | $this->token = $statusValue->getValue(); |
282 | $this->isSaved = true; |
283 | return Status::newGood(); |
284 | } |
285 | |
286 | // Action failed, status will have code botpasswords-insert-failed or |
287 | // botpasswords-update-failed depending on which action we tried |
288 | return Status::wrap( $statusValue ); |
289 | } |
290 | |
291 | /** |
292 | * Delete the BotPassword from the database |
293 | * @return bool Success |
294 | */ |
295 | public function delete() { |
296 | $ok = MediaWikiServices::getInstance() |
297 | ->getBotPasswordStore() |
298 | ->deleteBotPassword( $this ); |
299 | if ( $ok ) { |
300 | $this->token = '**unsaved**'; |
301 | $this->isSaved = false; |
302 | } |
303 | return $ok; |
304 | } |
305 | |
306 | /** |
307 | * Invalidate all passwords for a user, by name |
308 | * @param string $username |
309 | * @return bool Whether any passwords were invalidated |
310 | */ |
311 | public static function invalidateAllPasswordsForUser( $username ) { |
312 | return MediaWikiServices::getInstance() |
313 | ->getBotPasswordStore() |
314 | ->invalidateUserPasswords( (string)$username ); |
315 | } |
316 | |
317 | /** |
318 | * Invalidate all passwords for a user, by central ID |
319 | * |
320 | * @deprecated since 1.37 |
321 | * |
322 | * @param int $centralId |
323 | * @return bool Whether any passwords were invalidated |
324 | */ |
325 | public static function invalidateAllPasswordsForCentralId( $centralId ) { |
326 | wfDeprecated( __METHOD__, '1.37' ); |
327 | |
328 | $enableBotPasswords = MediaWikiServices::getInstance()->getMainConfig() |
329 | ->get( MainConfigNames::EnableBotPasswords ); |
330 | |
331 | if ( !$enableBotPasswords ) { |
332 | return false; |
333 | } |
334 | |
335 | $dbw = self::getPrimaryDatabase(); |
336 | $dbw->newUpdateQueryBuilder() |
337 | ->update( 'bot_passwords' ) |
338 | ->set( [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ] ) |
339 | ->where( [ 'bp_user' => $centralId ] ) |
340 | ->caller( __METHOD__ )->execute(); |
341 | return (bool)$dbw->affectedRows(); |
342 | } |
343 | |
344 | /** |
345 | * Remove all passwords for a user, by name |
346 | * @param string $username |
347 | * @return bool Whether any passwords were removed |
348 | */ |
349 | public static function removeAllPasswordsForUser( $username ) { |
350 | return MediaWikiServices::getInstance() |
351 | ->getBotPasswordStore() |
352 | ->removeUserPasswords( (string)$username ); |
353 | } |
354 | |
355 | /** |
356 | * Remove all passwords for a user, by central ID |
357 | * |
358 | * @deprecated since 1.37 |
359 | * |
360 | * @param int $centralId |
361 | * @return bool Whether any passwords were removed |
362 | */ |
363 | public static function removeAllPasswordsForCentralId( $centralId ) { |
364 | wfDeprecated( __METHOD__, '1.37' ); |
365 | |
366 | $enableBotPasswords = MediaWikiServices::getInstance()->getMainConfig() |
367 | ->get( MainConfigNames::EnableBotPasswords ); |
368 | |
369 | if ( !$enableBotPasswords ) { |
370 | return false; |
371 | } |
372 | |
373 | $dbw = self::getPrimaryDatabase(); |
374 | $dbw->newDeleteQueryBuilder() |
375 | ->deleteFrom( 'bot_passwords' ) |
376 | ->where( [ 'bp_user' => $centralId ] ) |
377 | ->caller( __METHOD__ )->execute(); |
378 | return (bool)$dbw->affectedRows(); |
379 | } |
380 | |
381 | /** |
382 | * Returns a (raw, unhashed) random password string. |
383 | * @param Config $config |
384 | * @return string |
385 | */ |
386 | public static function generatePassword( $config ) { |
387 | return PasswordFactory::generateRandomPasswordString( self::PASSWORD_MINLENGTH ); |
388 | } |
389 | |
390 | /** |
391 | * There are two ways to login with a bot password: "username@appId", "password" and |
392 | * "username", "appId@password". Transform it so it is always in the first form. |
393 | * Returns [bot username, bot password]. |
394 | * If this cannot be a bot password login just return false. |
395 | * @param string $username |
396 | * @param string $password |
397 | * @return string[]|false |
398 | */ |
399 | public static function canonicalizeLoginData( $username, $password ) { |
400 | $sep = self::getSeparator(); |
401 | // the strlen check helps minimize the password information obtainable from timing |
402 | if ( strlen( $password ) >= self::PASSWORD_MINLENGTH && str_contains( $username, $sep ) ) { |
403 | // the separator is not valid in new usernames but might appear in legacy ones |
404 | if ( preg_match( '/^[0-9a-w]{' . self::PASSWORD_MINLENGTH . ',}$/', $password ) ) { |
405 | return [ $username, $password ]; |
406 | } |
407 | } elseif ( strlen( $password ) > self::PASSWORD_MINLENGTH && str_contains( $password, $sep ) ) { |
408 | $segments = explode( $sep, $password ); |
409 | $password = array_pop( $segments ); |
410 | $appId = implode( $sep, $segments ); |
411 | if ( preg_match( '/^[0-9a-w]{' . self::PASSWORD_MINLENGTH . ',}$/', $password ) ) { |
412 | return [ $username . $sep . $appId, $password ]; |
413 | } |
414 | } |
415 | return false; |
416 | } |
417 | |
418 | /** |
419 | * Try to log the user in |
420 | * @param string $username Combined user name and app ID |
421 | * @param string $password Supplied password |
422 | * @param WebRequest $request |
423 | * @return Status On success, the good status's value is the new Session object |
424 | */ |
425 | public static function login( $username, $password, WebRequest $request ) { |
426 | $enableBotPasswords = MediaWikiServices::getInstance()->getMainConfig() |
427 | ->get( MainConfigNames::EnableBotPasswords ); |
428 | $passwordAttemptThrottle = MediaWikiServices::getInstance()->getMainConfig() |
429 | ->get( MainConfigNames::PasswordAttemptThrottle ); |
430 | if ( !$enableBotPasswords ) { |
431 | return Status::newFatal( 'botpasswords-disabled' ); |
432 | } |
433 | |
434 | $provider = SessionManager::singleton()->getProvider( BotPasswordSessionProvider::class ); |
435 | if ( !$provider ) { |
436 | return Status::newFatal( 'botpasswords-no-provider' ); |
437 | } |
438 | |
439 | $performer = $request->getSession()->getUser(); |
440 | // Split name into name+appId |
441 | $sep = self::getSeparator(); |
442 | if ( !str_contains( $username, $sep ) ) { |
443 | return self::loginHook( |
444 | $username, null, $performer, Status::newFatal( 'botpasswords-invalid-name', $sep ) |
445 | ); |
446 | } |
447 | [ $name, $appId ] = explode( $sep, $username, 2 ); |
448 | |
449 | // Find the named user |
450 | $user = User::newFromName( $name ); |
451 | if ( !$user || $user->isAnon() ) { |
452 | return self::loginHook( $user ?: $name, null, $performer, Status::newFatal( 'nosuchuser', $name ) ); |
453 | } |
454 | |
455 | if ( $user->isLocked() ) { |
456 | return Status::newFatal( 'botpasswords-locked' ); |
457 | } |
458 | |
459 | $throttle = null; |
460 | if ( $passwordAttemptThrottle ) { |
461 | $throttle = new Throttler( $passwordAttemptThrottle, [ |
462 | 'type' => 'botpassword', |
463 | 'cache' => ObjectCache::getLocalClusterInstance(), |
464 | ] ); |
465 | $result = $throttle->increase( $user->getName(), $request->getIP(), __METHOD__ ); |
466 | if ( $result ) { |
467 | $msg = wfMessage( 'login-throttled' )->durationParams( $result['wait'] ); |
468 | return self::loginHook( $user, null, $performer, Status::newFatal( $msg ) ); |
469 | } |
470 | } |
471 | |
472 | // Get the bot password |
473 | $bp = self::newFromUser( $user, $appId ); |
474 | if ( !$bp ) { |
475 | return self::loginHook( $user, $bp, $performer, |
476 | Status::newFatal( 'botpasswords-not-exist', $name, $appId ) ); |
477 | } |
478 | |
479 | // Check restrictions |
480 | $status = $bp->getRestrictions()->check( $request ); |
481 | if ( !$status->isOK() ) { |
482 | return self::loginHook( $user, $bp, $performer, |
483 | Status::newFatal( 'botpasswords-restriction-failed' ) ); |
484 | } |
485 | |
486 | // Check the password |
487 | $passwordObj = $bp->getPassword(); |
488 | if ( $passwordObj instanceof InvalidPassword ) { |
489 | return self::loginHook( $user, $bp, $performer, |
490 | Status::newFatal( 'botpasswords-needs-reset', $name, $appId ) ); |
491 | } |
492 | if ( !$passwordObj->verify( $password ) ) { |
493 | return self::loginHook( $user, $bp, $performer, Status::newFatal( 'wrongpassword' ) ); |
494 | } |
495 | |
496 | // Ok! Create the session. |
497 | if ( $throttle ) { |
498 | $throttle->clear( $user->getName(), $request->getIP() ); |
499 | } |
500 | return self::loginHook( $user, $bp, $performer, |
501 | // @phan-suppress-next-line PhanUndeclaredMethod |
502 | Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) ) ); |
503 | } |
504 | |
505 | /** |
506 | * Call AuthManagerLoginAuthenticateAudit |
507 | * |
508 | * To facilitate logging all authentications, even ones not via |
509 | * AuthManager, call the AuthManagerLoginAuthenticateAudit hook. |
510 | * |
511 | * @param User|string $user User being logged in |
512 | * @param BotPassword|null $bp Bot sub-account, if it can be identified |
513 | * @param User $performer User performing the request |
514 | * @param Status $status Login status |
515 | * @return Status The passed-in status |
516 | */ |
517 | private static function loginHook( $user, $bp, User $performer, Status $status ) { |
518 | $extraData = [ |
519 | 'performer' => $performer |
520 | ]; |
521 | if ( $user instanceof User ) { |
522 | $name = $user->getName(); |
523 | if ( $bp ) { |
524 | $extraData['appId'] = $name . self::getSeparator() . $bp->getAppId(); |
525 | } |
526 | } else { |
527 | $name = $user; |
528 | $user = null; |
529 | } |
530 | |
531 | if ( $status->isGood() ) { |
532 | $response = AuthenticationResponse::newPass( $name ); |
533 | } else { |
534 | $response = AuthenticationResponse::newFail( $status->getMessage() ); |
535 | } |
536 | ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) ) |
537 | ->onAuthManagerLoginAuthenticateAudit( $response, $user, $name, $extraData ); |
538 | |
539 | return $status; |
540 | } |
541 | } |
542 | |
543 | /** @deprecated class alias since 1.41 */ |
544 | class_alias( BotPassword::class, 'BotPassword' ); |