MediaWiki  master
BotPassword.php
Go to the documentation of this file.
1 <?php
25 
30 class BotPassword implements IDBAccessObject {
31 
32  const APPID_MAXLENGTH = 32;
33 
35  private $isSaved;
36 
38  private $centralId;
39 
41  private $appId;
42 
44  private $token;
45 
47  private $restrictions;
48 
50  private $grants;
51 
53  private $flags = self::READ_NORMAL;
54 
60  protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
61  $this->isSaved = $isSaved;
62  $this->flags = $flags;
63 
64  $this->centralId = (int)$row->bp_user;
65  $this->appId = $row->bp_app_id;
66  $this->token = $row->bp_token;
67  $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
68  $this->grants = FormatJson::decode( $row->bp_grants );
69  }
70 
76  public static function getDB( $db ) {
78 
79  $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
80  $lb = $wgBotPasswordsCluster
81  ? $lbFactory->getExternalLB( $wgBotPasswordsCluster )
82  : $lbFactory->getMainLB( $wgBotPasswordsDatabase );
83  return $lb->getConnectionRef( $db, [], $wgBotPasswordsDatabase );
84  }
85 
93  public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
94  $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
96  );
97  return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
98  }
99 
107  public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
108  global $wgEnableBotPasswords;
109 
110  if ( !$wgEnableBotPasswords ) {
111  return null;
112  }
113 
114  list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
115  $db = self::getDB( $index );
116  $row = $db->selectRow(
117  'bot_passwords',
118  [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ],
119  [ 'bp_user' => $centralId, 'bp_app_id' => $appId ],
120  __METHOD__,
121  $options
122  );
123  return $row ? new self( $row, true, $flags ) : null;
124  }
125 
138  public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
139  $row = (object)[
140  'bp_user' => 0,
141  'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
142  'bp_token' => '**unsaved**',
143  'bp_restrictions' => $data['restrictions'] ?? MWRestrictions::newDefault(),
144  'bp_grants' => $data['grants'] ?? [],
145  ];
146 
147  if (
148  $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
149  !$row->bp_restrictions instanceof MWRestrictions ||
150  !is_array( $row->bp_grants )
151  ) {
152  return null;
153  }
154 
155  $row->bp_restrictions = $row->bp_restrictions->toJson();
156  $row->bp_grants = FormatJson::encode( $row->bp_grants );
157 
158  if ( isset( $data['user'] ) ) {
159  if ( !$data['user'] instanceof User ) {
160  return null;
161  }
162  $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
163  $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
164  );
165  } elseif ( isset( $data['username'] ) ) {
166  $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
167  $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
168  );
169  } elseif ( isset( $data['centralId'] ) ) {
170  $row->bp_user = $data['centralId'];
171  }
172  if ( !$row->bp_user ) {
173  return null;
174  }
175 
176  return new self( $row, false, $flags );
177  }
178 
183  public function isSaved() {
184  return $this->isSaved;
185  }
186 
191  public function getUserCentralId() {
192  return $this->centralId;
193  }
194 
199  public function getAppId() {
200  return $this->appId;
201  }
202 
207  public function getToken() {
208  return $this->token;
209  }
210 
215  public function getRestrictions() {
216  return $this->restrictions;
217  }
218 
223  public function getGrants() {
224  return $this->grants;
225  }
226 
231  public static function getSeparator() {
234  }
235 
240  protected function getPassword() {
241  list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
242  $db = self::getDB( $index );
243  $password = $db->selectField(
244  'bot_passwords',
245  'bp_password',
246  [ 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ],
247  __METHOD__,
248  $options
249  );
250  if ( $password === false ) {
252  }
253 
254  $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
255  try {
256  return $passwordFactory->newFromCiphertext( $password );
257  } catch ( PasswordError $ex ) {
259  }
260  }
261 
267  public function isInvalid() {
268  return $this->getPassword() instanceof InvalidPassword;
269  }
270 
277  public function save( $operation, Password $password = null ) {
278  $conds = [
279  'bp_user' => $this->centralId,
280  'bp_app_id' => $this->appId,
281  ];
282  $fields = [
284  'bp_restrictions' => $this->restrictions->toJson(),
285  'bp_grants' => FormatJson::encode( $this->grants ),
286  ];
287 
288  if ( $password !== null ) {
289  $fields['bp_password'] = $password->toString();
290  } elseif ( $operation === 'insert' ) {
291  $fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
292  }
293 
294  $dbw = self::getDB( DB_MASTER );
295  switch ( $operation ) {
296  case 'insert':
297  $dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, [ 'IGNORE' ] );
298  break;
299 
300  case 'update':
301  $dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
302  break;
303 
304  default:
305  return false;
306  }
307  $ok = (bool)$dbw->affectedRows();
308  if ( $ok ) {
309  $this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
310  $this->isSaved = true;
311  }
312  return $ok;
313  }
314 
319  public function delete() {
320  $conds = [
321  'bp_user' => $this->centralId,
322  'bp_app_id' => $this->appId,
323  ];
324  $dbw = self::getDB( DB_MASTER );
325  $dbw->delete( 'bot_passwords', $conds, __METHOD__ );
326  $ok = (bool)$dbw->affectedRows();
327  if ( $ok ) {
328  $this->token = '**unsaved**';
329  $this->isSaved = false;
330  }
331  return $ok;
332  }
333 
339  public static function invalidateAllPasswordsForUser( $username ) {
340  $centralId = CentralIdLookup::factory()->centralIdFromName(
341  $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
342  );
343  return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
344  }
345 
351  public static function invalidateAllPasswordsForCentralId( $centralId ) {
352  global $wgEnableBotPasswords;
353 
354  if ( !$wgEnableBotPasswords ) {
355  return false;
356  }
357 
358  $dbw = self::getDB( DB_MASTER );
359  $dbw->update(
360  'bot_passwords',
361  [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ],
362  [ 'bp_user' => $centralId ],
363  __METHOD__
364  );
365  return (bool)$dbw->affectedRows();
366  }
367 
373  public static function removeAllPasswordsForUser( $username ) {
374  $centralId = CentralIdLookup::factory()->centralIdFromName(
375  $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
376  );
377  return $centralId && self::removeAllPasswordsForCentralId( $centralId );
378  }
379 
385  public static function removeAllPasswordsForCentralId( $centralId ) {
386  global $wgEnableBotPasswords;
387 
388  if ( !$wgEnableBotPasswords ) {
389  return false;
390  }
391 
392  $dbw = self::getDB( DB_MASTER );
393  $dbw->delete(
394  'bot_passwords',
395  [ 'bp_user' => $centralId ],
396  __METHOD__
397  );
398  return (bool)$dbw->affectedRows();
399  }
400 
406  public static function generatePassword( $config ) {
408  max( 32, $config->get( 'MinimalPasswordLength' ) ) );
409  }
410 
420  public static function canonicalizeLoginData( $username, $password ) {
421  $sep = self::getSeparator();
422  // the strlen check helps minimize the password information obtainable from timing
423  if ( strlen( $password ) >= 32 && strpos( $username, $sep ) !== false ) {
424  // the separator is not valid in new usernames but might appear in legacy ones
425  if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
426  return [ $username, $password ];
427  }
428  } elseif ( strlen( $password ) > 32 && strpos( $password, $sep ) !== false ) {
429  $segments = explode( $sep, $password );
430  $password = array_pop( $segments );
431  $appId = implode( $sep, $segments );
432  if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
433  return [ $username . $sep . $appId, $password ];
434  }
435  }
436  return false;
437  }
438 
446  public static function login( $username, $password, WebRequest $request ) {
448 
449  if ( !$wgEnableBotPasswords ) {
450  return Status::newFatal( 'botpasswords-disabled' );
451  }
452 
454  $provider = $manager->getProvider( BotPasswordSessionProvider::class );
455  if ( !$provider ) {
456  return Status::newFatal( 'botpasswords-no-provider' );
457  }
458 
459  // Split name into name+appId
460  $sep = self::getSeparator();
461  if ( strpos( $username, $sep ) === false ) {
462  return self::loginHook( $username, null, Status::newFatal( 'botpasswords-invalid-name', $sep ) );
463  }
464  list( $name, $appId ) = explode( $sep, $username, 2 );
465 
466  // Find the named user
467  $user = User::newFromName( $name );
468  if ( !$user || $user->isAnon() ) {
469  return self::loginHook( $user ?: $name, null, Status::newFatal( 'nosuchuser', $name ) );
470  }
471 
472  if ( $user->isLocked() ) {
473  return Status::newFatal( 'botpasswords-locked' );
474  }
475 
476  $throttle = null;
477  if ( !empty( $wgPasswordAttemptThrottle ) ) {
478  $throttle = new MediaWiki\Auth\Throttler( $wgPasswordAttemptThrottle, [
479  'type' => 'botpassword',
481  ] );
482  $result = $throttle->increase( $user->getName(), $request->getIP(), __METHOD__ );
483  if ( $result ) {
484  $msg = wfMessage( 'login-throttled' )->durationParams( $result['wait'] );
485  return self::loginHook( $user, null, Status::newFatal( $msg ) );
486  }
487  }
488 
489  // Get the bot password
490  $bp = self::newFromUser( $user, $appId );
491  if ( !$bp ) {
492  return self::loginHook( $user, $bp,
493  Status::newFatal( 'botpasswords-not-exist', $name, $appId ) );
494  }
495 
496  // Check restrictions
497  $status = $bp->getRestrictions()->check( $request );
498  if ( !$status->isOK() ) {
499  return self::loginHook( $user, $bp, Status::newFatal( 'botpasswords-restriction-failed' ) );
500  }
501 
502  // Check the password
503  $passwordObj = $bp->getPassword();
504  if ( $passwordObj instanceof InvalidPassword ) {
505  return self::loginHook( $user, $bp,
506  Status::newFatal( 'botpasswords-needs-reset', $name, $appId ) );
507  }
508  if ( !$passwordObj->verify( $password ) ) {
509  return self::loginHook( $user, $bp, Status::newFatal( 'wrongpassword' ) );
510  }
511 
512  // Ok! Create the session.
513  if ( $throttle ) {
514  $throttle->clear( $user->getName(), $request->getIP() );
515  }
516  return self::loginHook( $user, $bp,
517  // @phan-suppress-next-line PhanUndeclaredMethod
518  Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) ) );
519  }
520 
532  private static function loginHook( $user, $bp, Status $status ) {
533  $extraData = [];
534  if ( $user instanceof User ) {
535  $name = $user->getName();
536  if ( $bp ) {
537  $extraData['appId'] = $name . self::getSeparator() . $bp->getAppId();
538  }
539  } else {
540  $name = $user;
541  $user = null;
542  }
543 
544  if ( $status->isGood() ) {
545  $response = AuthenticationResponse::newPass( $name );
546  } else {
547  $response = AuthenticationResponse::newFail( $status->getMessage() );
548  }
549  Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $response, $user, $name, $extraData ] );
550 
551  return $status;
552  }
553 }
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:69
$response
$wgPasswordAttemptThrottle
Limit password attempts to X attempts per Y seconds per IP per account.
static getSeparator()
Get the separator for combined user name + app ID.
static generatePassword( $config)
Returns a (raw, unhashed) random password string.
getIP()
Work out the IP address based on various globals For trusted proxies, use the XFF client IP (first of...
static generateRandomPasswordString( $minLength=10)
Generate a random string suitable for a password.
static invalidateAllPasswordsForCentralId( $centralId)
Invalidate all passwords for a user, by central ID.
getUserCentralId()
Get the central user ID.
const TOKEN_LENGTH
Number of characters required for the user_token field.
Definition: User.php:56
isInvalid()
Whether the password is currently invalid.
const APPID_MAXLENGTH
Definition: BotPassword.php:32
static canonicalizeLoginData( $username, $password)
There are two ways to login with a bot password: "username@appId", "password" and "username"...
getToken()
Get the token.
Represents an invalid password hash.
isGood()
Returns whether the operation completed and didn&#39;t have any error or warnings.
static getLocalClusterInstance()
Get the main cluster-local cache object.
static encode( $value, $pretty=false, $escaping=0)
Returns the JSON representation of a value.
Definition: FormatJson.php:115
save( $operation, Password $password=null)
Save the BotPassword to the database.
array bool $wgEnableBotPasswords
Whether to enable bot passwords.
string [] $grants
Definition: BotPassword.php:50
const DB_MASTER
Definition: defines.php:26
static getDBOptions( $bitfield)
Get an appropriate DB index, options, and fallback DB index for a query.
static newUnsaved(array $data, $flags=self::READ_NORMAL)
Create an unsaved BotPassword.
isSaved()
Indicate whether this is known to be saved.
getMessage( $shortContext=false, $longContext=false, $lang=null)
Get a bullet list of the errors as a Message object.
Definition: Status.php:232
__construct( $row, $isSaved, $flags=self::READ_NORMAL)
Definition: BotPassword.php:60
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
getAppId()
Get the app ID.
static getDB( $db)
Get a database connection for the bot passwords database.
Definition: BotPassword.php:76
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:81
string $token
Definition: BotPassword.php:44
static decode( $value, $assoc=false)
Decodes a JSON string.
Definition: FormatJson.php:174
getPassword()
Get the password.
A class to check request restrictions expressed as a JSON object.
static generateHex( $chars)
Generate a run of cryptographically random data and return it in hexadecimal string format...
Definition: MWCryptRand.php:36
string $appId
Definition: BotPassword.php:41
static newFromJson( $json)
static login( $username, $password, WebRequest $request)
Try to log the user in.
static singleton()
Get the global SessionManager.
static newInvalidPassword()
Create an InvalidPassword.
static loginHook( $user, $bp, Status $status)
Call AuthManagerLoginAuthenticateAudit.
static newFromUser(User $user, $appId, $flags=self::READ_NORMAL)
Load a BotPassword from the database.
Definition: BotPassword.php:93
string bool $wgBotPasswordsCluster
Cluster for the bot_passwords table If false, the normal cluster will be used.
getDB()
static factory( $providerId=null)
Fetch a CentralIdLookup.
Show an error when any operation involving passwords fails to run.
static invalidateAllPasswordsForUser( $username)
Invalidate all passwords for a user, by name.
static newFromCentralId( $centralId, $appId, $flags=self::READ_NORMAL)
Load a BotPassword from the database.
static newDefault()
static removeAllPasswordsForCentralId( $centralId)
Remove all passwords for a user, by central ID.
$wgUserrightsInterwikiDelimiter
Character used as a delimiter when testing for interwiki userrights (In Special:UserRights, it is possible to modify users on different databases if the delimiter is used, e.g.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
getGrants()
Get the grants.
static newFromName( $name, $validate='valid')
Static factory method for creation from username.
Definition: User.php:515
string bool $wgBotPasswordsDatabase
Database name for the bot_passwords table.
MWRestrictions $restrictions
Definition: BotPassword.php:47
getRestrictions()
Get the restrictions.
static removeAllPasswordsForUser( $username)
Remove all passwords for a user, by name.
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200