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