MediaWiki fundraising/REL1_35
BotPassword.php
Go to the documentation of this file.
1<?php
25
30class BotPassword implements IDBAccessObject {
31
32 public const APPID_MAXLENGTH = 32;
33
37 public const PASSWORD_MINLENGTH = 32;
38
43 public const RESTRICTIONS_MAXLENGTH = 65535;
44
49 public const GRANTS_MAXLENGTH = 65535;
50
52 private $isSaved;
53
55 private $centralId;
56
58 private $appId;
59
61 private $token;
62
65
67 private $grants;
68
70 private $flags = self::READ_NORMAL;
71
77 protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
78 $this->isSaved = $isSaved;
79 $this->flags = $flags;
80
81 $this->centralId = (int)$row->bp_user;
82 $this->appId = $row->bp_app_id;
83 $this->token = $row->bp_token;
84 $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
85 $this->grants = FormatJson::decode( $row->bp_grants );
86 }
87
93 public static function getDB( $db ) {
95
96 $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
98 ? $lbFactory->getExternalLB( $wgBotPasswordsCluster )
99 : $lbFactory->getMainLB( $wgBotPasswordsDatabase );
100 return $lb->getConnectionRef( $db, [], $wgBotPasswordsDatabase );
101 }
102
110 public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
111 $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
112 $user, CentralIdLookup::AUDIENCE_RAW, $flags
113 );
114 return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
115 }
116
124 public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
126
127 if ( !$wgEnableBotPasswords ) {
128 return null;
129 }
130
131 list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
132 $db = self::getDB( $index );
133 $row = $db->selectRow(
134 'bot_passwords',
135 [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ],
136 [ 'bp_user' => $centralId, 'bp_app_id' => $appId ],
137 __METHOD__,
138 $options
139 );
140 return $row ? new self( $row, true, $flags ) : null;
141 }
142
155 public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
156 $row = (object)[
157 'bp_user' => 0,
158 'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
159 'bp_token' => '**unsaved**',
160 'bp_restrictions' => $data['restrictions'] ?? MWRestrictions::newDefault(),
161 'bp_grants' => $data['grants'] ?? [],
162 ];
163
164 if (
165 $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
166 !$row->bp_restrictions instanceof MWRestrictions ||
167 !is_array( $row->bp_grants )
168 ) {
169 return null;
170 }
171
172 $row->bp_restrictions = $row->bp_restrictions->toJson();
173 $row->bp_grants = FormatJson::encode( $row->bp_grants );
174
175 if ( isset( $data['user'] ) ) {
176 if ( !$data['user'] instanceof User ) {
177 return null;
178 }
179 $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
180 $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
181 );
182 } elseif ( isset( $data['username'] ) ) {
183 $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
184 $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
185 );
186 } elseif ( isset( $data['centralId'] ) ) {
187 $row->bp_user = $data['centralId'];
188 }
189 if ( !$row->bp_user ) {
190 return null;
191 }
192
193 return new self( $row, false, $flags );
194 }
195
200 public function isSaved() {
201 return $this->isSaved;
202 }
203
208 public function getUserCentralId() {
209 return $this->centralId;
210 }
211
216 public function getAppId() {
217 return $this->appId;
218 }
219
224 public function getToken() {
225 return $this->token;
226 }
227
232 public function getRestrictions() {
233 return $this->restrictions;
234 }
235
240 public function getGrants() {
241 return $this->grants;
242 }
243
248 public static function getSeparator() {
251 }
252
257 protected function getPassword() {
258 list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
259 $db = self::getDB( $index );
260 $password = $db->selectField(
261 'bot_passwords',
262 'bp_password',
263 [ 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ],
264 __METHOD__,
265 $options
266 );
267 if ( $password === false ) {
268 return PasswordFactory::newInvalidPassword();
269 }
270
271 $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
272 try {
273 return $passwordFactory->newFromCiphertext( $password );
274 } catch ( PasswordError $ex ) {
275 return PasswordFactory::newInvalidPassword();
276 }
277 }
278
284 public function isInvalid() {
285 return $this->getPassword() instanceof InvalidPassword;
286 }
287
295 public function save( $operation, Password $password = null ) {
296 // Ensure operation is valid
297 if ( $operation !== 'insert' && $operation !== 'update' ) {
298 throw new UnexpectedValueException(
299 "Expected 'insert' or 'update'; got '{$operation}'."
300 );
301 }
302
303 $conds = [
304 'bp_user' => $this->centralId,
305 'bp_app_id' => $this->appId,
306 ];
307
308 $res = Status::newGood();
309
310 $restrictions = $this->restrictions->toJson();
311
312 if ( strlen( $restrictions ) > self::RESTRICTIONS_MAXLENGTH ) {
313 $res->fatal( 'botpasswords-toolong-restrictions' );
314 }
315
316 $grants = FormatJson::encode( $this->grants );
317
318 if ( strlen( $grants ) > self::GRANTS_MAXLENGTH ) {
319 $res->fatal( 'botpasswords-toolong-grants' );
320 }
321
322 if ( !$res->isGood() ) {
323 return $res;
324 }
325
326 $fields = [
327 'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
328 'bp_restrictions' => $restrictions,
329 'bp_grants' => $grants,
330 ];
331
332 if ( $password !== null ) {
333 $fields['bp_password'] = $password->toString();
334 } elseif ( $operation === 'insert' ) {
335 $fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
336 }
337
338 $dbw = self::getDB( DB_MASTER );
339
340 if ( $operation === 'insert' ) {
341 $dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, [ 'IGNORE' ] );
342 } else {
343 // Must be update, already checked above
344 $dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
345 }
346 $ok = (bool)$dbw->affectedRows();
347 if ( $ok ) {
348 $this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
349 $this->isSaved = true;
350
351 return $res;
352 }
353
354 // Messages: botpasswords-insert-failed, botpasswords-update-failed
355 return Status::newFatal( "botpasswords-{$operation}-failed", $this->appId );
356 }
357
362 public function delete() {
363 $conds = [
364 'bp_user' => $this->centralId,
365 'bp_app_id' => $this->appId,
366 ];
367 $dbw = self::getDB( DB_MASTER );
368 $dbw->delete( 'bot_passwords', $conds, __METHOD__ );
369 $ok = (bool)$dbw->affectedRows();
370 if ( $ok ) {
371 $this->token = '**unsaved**';
372 $this->isSaved = false;
373 }
374 return $ok;
375 }
376
382 public static function invalidateAllPasswordsForUser( $username ) {
383 $centralId = CentralIdLookup::factory()->centralIdFromName(
384 $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
385 );
386 return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
387 }
388
394 public static function invalidateAllPasswordsForCentralId( $centralId ) {
396
397 if ( !$wgEnableBotPasswords ) {
398 return false;
399 }
400
401 $dbw = self::getDB( DB_MASTER );
402 $dbw->update(
403 'bot_passwords',
404 [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ],
405 [ 'bp_user' => $centralId ],
406 __METHOD__
407 );
408 return (bool)$dbw->affectedRows();
409 }
410
416 public static function removeAllPasswordsForUser( $username ) {
417 $centralId = CentralIdLookup::factory()->centralIdFromName(
418 $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
419 );
420 return $centralId && self::removeAllPasswordsForCentralId( $centralId );
421 }
422
428 public static function removeAllPasswordsForCentralId( $centralId ) {
430
431 if ( !$wgEnableBotPasswords ) {
432 return false;
433 }
434
435 $dbw = self::getDB( DB_MASTER );
436 $dbw->delete(
437 'bot_passwords',
438 [ 'bp_user' => $centralId ],
439 __METHOD__
440 );
441 return (bool)$dbw->affectedRows();
442 }
443
449 public static function generatePassword( $config ) {
450 return PasswordFactory::generateRandomPasswordString(
451 max( self::PASSWORD_MINLENGTH, $config->get( 'MinimalPasswordLength' ) ) );
452 }
453
463 public static function canonicalizeLoginData( $username, $password ) {
464 $sep = self::getSeparator();
465 // the strlen check helps minimize the password information obtainable from timing
466 if ( strlen( $password ) >= self::PASSWORD_MINLENGTH && strpos( $username, $sep ) !== false ) {
467 // the separator is not valid in new usernames but might appear in legacy ones
468 if ( preg_match( '/^[0-9a-w]{' . self::PASSWORD_MINLENGTH . ',}$/', $password ) ) {
469 return [ $username, $password ];
470 }
471 } elseif ( strlen( $password ) > self::PASSWORD_MINLENGTH && strpos( $password, $sep ) !== false ) {
472 $segments = explode( $sep, $password );
473 $password = array_pop( $segments );
474 $appId = implode( $sep, $segments );
475 if ( preg_match( '/^[0-9a-w]{' . self::PASSWORD_MINLENGTH . ',}$/', $password ) ) {
476 return [ $username . $sep . $appId, $password ];
477 }
478 }
479 return false;
480 }
481
489 public static function login( $username, $password, WebRequest $request ) {
491
492 if ( !$wgEnableBotPasswords ) {
493 return Status::newFatal( 'botpasswords-disabled' );
494 }
495
496 $manager = MediaWiki\Session\SessionManager::singleton();
497 $provider = $manager->getProvider( BotPasswordSessionProvider::class );
498 if ( !$provider ) {
499 return Status::newFatal( 'botpasswords-no-provider' );
500 }
501
502 // Split name into name+appId
503 $sep = self::getSeparator();
504 if ( strpos( $username, $sep ) === false ) {
505 return self::loginHook( $username, null, Status::newFatal( 'botpasswords-invalid-name', $sep ) );
506 }
507 list( $name, $appId ) = explode( $sep, $username, 2 );
508
509 // Find the named user
510 $user = User::newFromName( $name );
511 if ( !$user || $user->isAnon() ) {
512 return self::loginHook( $user ?: $name, null, Status::newFatal( 'nosuchuser', $name ) );
513 }
514
515 if ( $user->isLocked() ) {
516 return Status::newFatal( 'botpasswords-locked' );
517 }
518
519 $throttle = null;
520 if ( !empty( $wgPasswordAttemptThrottle ) ) {
522 'type' => 'botpassword',
523 'cache' => ObjectCache::getLocalClusterInstance(),
524 ] );
525 $result = $throttle->increase( $user->getName(), $request->getIP(), __METHOD__ );
526 if ( $result ) {
527 $msg = wfMessage( 'login-throttled' )->durationParams( $result['wait'] );
528 return self::loginHook( $user, null, Status::newFatal( $msg ) );
529 }
530 }
531
532 // Get the bot password
533 $bp = self::newFromUser( $user, $appId );
534 if ( !$bp ) {
535 return self::loginHook( $user, $bp,
536 Status::newFatal( 'botpasswords-not-exist', $name, $appId ) );
537 }
538
539 // Check restrictions
540 $status = $bp->getRestrictions()->check( $request );
541 if ( !$status->isOK() ) {
542 return self::loginHook( $user, $bp, Status::newFatal( 'botpasswords-restriction-failed' ) );
543 }
544
545 // Check the password
546 $passwordObj = $bp->getPassword();
547 if ( $passwordObj instanceof InvalidPassword ) {
548 return self::loginHook( $user, $bp,
549 Status::newFatal( 'botpasswords-needs-reset', $name, $appId ) );
550 }
551 if ( !$passwordObj->verify( $password ) ) {
552 return self::loginHook( $user, $bp, Status::newFatal( 'wrongpassword' ) );
553 }
554
555 // Ok! Create the session.
556 if ( $throttle ) {
557 $throttle->clear( $user->getName(), $request->getIP() );
558 }
559 return self::loginHook( $user, $bp,
560 // @phan-suppress-next-line PhanUndeclaredMethod
561 Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) ) );
562 }
563
575 private static function loginHook( $user, $bp, Status $status ) {
576 $extraData = [];
577 if ( $user instanceof User ) {
578 $name = $user->getName();
579 if ( $bp ) {
580 $extraData['appId'] = $name . self::getSeparator() . $bp->getAppId();
581 }
582 } else {
583 $name = $user;
584 $user = null;
585 }
586
587 if ( $status->isGood() ) {
588 $response = AuthenticationResponse::newPass( $name );
589 } else {
590 $response = AuthenticationResponse::newFail( $status->getMessage() );
591 }
592 Hooks::runner()->onAuthManagerLoginAuthenticateAudit( $response, $user, $name, $extraData );
593
594 return $status;
595 }
596}
getDB()
$wgUserrightsInterwikiDelimiter
Character used as a delimiter when testing for interwiki userrights (In Special:UserRights,...
bool $wgEnableBotPasswords
Whether to enable bot passwords.
$wgPasswordAttemptThrottle
Limit password attempts to X attempts per Y seconds per IP per account.
string bool $wgBotPasswordsDatabase
Database name for the bot_passwords table.
string bool $wgBotPasswordsCluster
Cluster for the bot_passwords table If false, the normal cluster will be used.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Utility class for bot passwords.
static newFromUser(User $user, $appId, $flags=self::READ_NORMAL)
Load a BotPassword from the database.
isInvalid()
Whether the password is currently invalid.
__construct( $row, $isSaved, $flags=self::READ_NORMAL)
const APPID_MAXLENGTH
static invalidateAllPasswordsForUser( $username)
Invalidate all passwords for a user, by name.
static loginHook( $user, $bp, Status $status)
Call AuthManagerLoginAuthenticateAudit.
getRestrictions()
Get the restrictions.
MWRestrictions $restrictions
static generatePassword( $config)
Returns a (raw, unhashed) random password string.
getPassword()
Get the password.
const PASSWORD_MINLENGTH
Minimum length for a bot password.
getUserCentralId()
Get the central user ID.
getGrants()
Get the grants.
string[] $grants
string $token
static getDB( $db)
Get a database connection for the bot passwords database.
getAppId()
Get the app ID.
const RESTRICTIONS_MAXLENGTH
Maximum length of the json representation of restrictions.
static newFromCentralId( $centralId, $appId, $flags=self::READ_NORMAL)
Load a BotPassword from the database.
const GRANTS_MAXLENGTH
Maximum length of the json representation of grants.
static login( $username, $password, WebRequest $request)
Try to log the user in.
static invalidateAllPasswordsForCentralId( $centralId)
Invalidate all passwords for a user, by central ID.
isSaved()
Indicate whether this is known to be saved.
string $appId
static removeAllPasswordsForUser( $username)
Remove all passwords for a user, by name.
static removeAllPasswordsForCentralId( $centralId)
Remove all passwords for a user, by central ID.
static newUnsaved(array $data, $flags=self::READ_NORMAL)
Create an unsaved BotPassword.
getToken()
Get the token.
static getSeparator()
Get the separator for combined user name + app ID.
save( $operation, Password $password=null)
Save the BotPassword to the database.
static canonicalizeLoginData( $username, $password)
There are two ways to login with a bot password: "username@appId", "password" and "username",...
Represents an invalid password hash.
static generateHex( $chars)
Generate a run of cryptographically random data and return it in hexadecimal string format.
A class to check request restrictions expressed as a JSON object.
static newFromJson( $json)
toJson( $pretty=false)
Return the restrictions as a JSON string.
This is a value object to hold authentication response data.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Show an error when any operation involving passwords fails to run.
Represents a password hash for use in authentication.
Definition Password.php:61
isGood()
Returns whether the operation completed and didn't have any error or warnings.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
getMessage( $shortContext=false, $longContext=false, $lang=null)
Get a bullet list of the errors as a Message object.
Definition Status.php:243
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:60
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
getIP()
Work out the IP address based on various globals For trusted proxies, use the XFF client IP (first of...
Interface for database access objects.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
const DB_MASTER
Definition defines.php:29