]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - includes/user/BotPassword.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / includes / user / BotPassword.php
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 use MediaWiki\Session\BotPasswordSessionProvider;
22 use Wikimedia\Rdbms\IMaintainableDatabase;
23
24 /**
25  * Utility class for bot passwords
26  * @since 1.27
27  */
28 class BotPassword implements IDBAccessObject {
29
30         const APPID_MAXLENGTH = 32;
31
32         /** @var bool */
33         private $isSaved;
34
35         /** @var int */
36         private $centralId;
37
38         /** @var string */
39         private $appId;
40
41         /** @var string */
42         private $token;
43
44         /** @var MWRestrictions */
45         private $restrictions;
46
47         /** @var string[] */
48         private $grants;
49
50         /** @var int */
51         private $flags = self::READ_NORMAL;
52
53         /**
54          * @param object $row bot_passwords database row
55          * @param bool $isSaved Whether the bot password was read from the database
56          * @param int $flags IDBAccessObject read flags
57          */
58         protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
59                 $this->isSaved = $isSaved;
60                 $this->flags = $flags;
61
62                 $this->centralId = (int)$row->bp_user;
63                 $this->appId = $row->bp_app_id;
64                 $this->token = $row->bp_token;
65                 $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
66                 $this->grants = FormatJson::decode( $row->bp_grants );
67         }
68
69         /**
70          * Get a database connection for the bot passwords database
71          * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_REPLICA.
72          * @return IMaintainableDatabase
73          */
74         public static function getDB( $db ) {
75                 global $wgBotPasswordsCluster, $wgBotPasswordsDatabase;
76
77                 $lb = $wgBotPasswordsCluster
78                         ? wfGetLBFactory()->getExternalLB( $wgBotPasswordsCluster )
79                         : wfGetLB( $wgBotPasswordsDatabase );
80                 return $lb->getConnectionRef( $db, [], $wgBotPasswordsDatabase );
81         }
82
83         /**
84          * Load a BotPassword from the database
85          * @param User $user
86          * @param string $appId
87          * @param int $flags IDBAccessObject read flags
88          * @return BotPassword|null
89          */
90         public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
91                 $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
92                         $user, CentralIdLookup::AUDIENCE_RAW, $flags
93                 );
94                 return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
95         }
96
97         /**
98          * Load a BotPassword from the database
99          * @param int $centralId from CentralIdLookup
100          * @param string $appId
101          * @param int $flags IDBAccessObject read flags
102          * @return BotPassword|null
103          */
104         public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
105                 global $wgEnableBotPasswords;
106
107                 if ( !$wgEnableBotPasswords ) {
108                         return null;
109                 }
110
111                 list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
112                 $db = self::getDB( $index );
113                 $row = $db->selectRow(
114                         'bot_passwords',
115                         [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ],
116                         [ 'bp_user' => $centralId, 'bp_app_id' => $appId ],
117                         __METHOD__,
118                         $options
119                 );
120                 return $row ? new self( $row, true, $flags ) : null;
121         }
122
123         /**
124          * Create an unsaved BotPassword
125          * @param array $data Data to use to create the bot password. Keys are:
126          *  - user: (User) User object to create the password for. Overrides username and centralId.
127          *  - username: (string) Username to create the password for. Overrides centralId.
128          *  - centralId: (int) User central ID to create the password for.
129          *  - appId: (string) App ID for the password.
130          *  - restrictions: (MWRestrictions, optional) Restrictions.
131          *  - grants: (string[], optional) Grants.
132          * @param int $flags IDBAccessObject read flags
133          * @return BotPassword|null
134          */
135         public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
136                 $row = (object)[
137                         'bp_user' => 0,
138                         'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
139                         'bp_token' => '**unsaved**',
140                         'bp_restrictions' => isset( $data['restrictions'] )
141                                 ? $data['restrictions']
142                                 : MWRestrictions::newDefault(),
143                         'bp_grants' => isset( $data['grants'] ) ? $data['grants'] : [],
144                 ];
145
146                 if (
147                         $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
148                         !$row->bp_restrictions instanceof MWRestrictions ||
149                         !is_array( $row->bp_grants )
150                 ) {
151                         return null;
152                 }
153
154                 $row->bp_restrictions = $row->bp_restrictions->toJson();
155                 $row->bp_grants = FormatJson::encode( $row->bp_grants );
156
157                 if ( isset( $data['user'] ) ) {
158                         if ( !$data['user'] instanceof User ) {
159                                 return null;
160                         }
161                         $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
162                                 $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
163                         );
164                 } elseif ( isset( $data['username'] ) ) {
165                         $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
166                                 $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
167                         );
168                 } elseif ( isset( $data['centralId'] ) ) {
169                         $row->bp_user = $data['centralId'];
170                 }
171                 if ( !$row->bp_user ) {
172                         return null;
173                 }
174
175                 return new self( $row, false, $flags );
176         }
177
178         /**
179          * Indicate whether this is known to be saved
180          * @return bool
181          */
182         public function isSaved() {
183                 return $this->isSaved;
184         }
185
186         /**
187          * Get the central user ID
188          * @return int
189          */
190         public function getUserCentralId() {
191                 return $this->centralId;
192         }
193
194         /**
195          * Get the app ID
196          * @return string
197          */
198         public function getAppId() {
199                 return $this->appId;
200         }
201
202         /**
203          * Get the token
204          * @return string
205          */
206         public function getToken() {
207                 return $this->token;
208         }
209
210         /**
211          * Get the restrictions
212          * @return MWRestrictions
213          */
214         public function getRestrictions() {
215                 return $this->restrictions;
216         }
217
218         /**
219          * Get the grants
220          * @return string[]
221          */
222         public function getGrants() {
223                 return $this->grants;
224         }
225
226         /**
227          * Get the separator for combined user name + app ID
228          * @return string
229          */
230         public static function getSeparator() {
231                 global $wgUserrightsInterwikiDelimiter;
232                 return $wgUserrightsInterwikiDelimiter;
233         }
234
235         /**
236          * Get the password
237          * @return Password
238          */
239         protected function getPassword() {
240                 list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
241                 $db = self::getDB( $index );
242                 $password = $db->selectField(
243                         'bot_passwords',
244                         'bp_password',
245                         [ 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ],
246                         __METHOD__,
247                         $options
248                 );
249                 if ( $password === false ) {
250                         return PasswordFactory::newInvalidPassword();
251                 }
252
253                 $passwordFactory = new \PasswordFactory();
254                 $passwordFactory->init( \RequestContext::getMain()->getConfig() );
255                 try {
256                         return $passwordFactory->newFromCiphertext( $password );
257                 } catch ( PasswordError $ex ) {
258                         return PasswordFactory::newInvalidPassword();
259                 }
260         }
261
262         /**
263          * Whether the password is currently invalid
264          * @since 1.32
265          * @return bool
266          */
267         public function isInvalid() {
268                 return $this->getPassword() instanceof InvalidPassword;
269         }
270
271         /**
272          * Save the BotPassword to the database
273          * @param string $operation 'update' or 'insert'
274          * @param Password|null $password Password to set.
275          * @return bool Success
276          */
277         public function save( $operation, Password $password = null ) {
278                 $conds = [
279                         'bp_user' => $this->centralId,
280                         'bp_app_id' => $this->appId,
281                 ];
282                 $fields = [
283                         'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
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
315         /**
316          * Delete the BotPassword from the database
317          * @return bool Success
318          */
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
334         /**
335          * Invalidate all passwords for a user, by name
336          * @param string $username User name
337          * @return bool Whether any passwords were invalidated
338          */
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
346         /**
347          * Invalidate all passwords for a user, by central ID
348          * @param int $centralId
349          * @return bool Whether any passwords were invalidated
350          */
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
368         /**
369          * Remove all passwords for a user, by name
370          * @param string $username User name
371          * @return bool Whether any passwords were removed
372          */
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
380         /**
381          * Remove all passwords for a user, by central ID
382          * @param int $centralId
383          * @return bool Whether any passwords were removed
384          */
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
401         /**
402          * Returns a (raw, unhashed) random password string.
403          * @param Config $config
404          * @return string
405          */
406         public static function generatePassword( $config ) {
407                 return PasswordFactory::generateRandomPasswordString(
408                         max( 32, $config->get( 'MinimalPasswordLength' ) ) );
409         }
410
411         /**
412          * There are two ways to login with a bot password: "username@appId", "password" and
413          * "username", "appId@password". Transform it so it is always in the first form.
414          * Returns [bot username, bot password, could be normal password?] where the last one is a flag
415          * meaning this could either be a bot password or a normal password, it cannot be decided for
416          * certain (although in such cases it almost always will be a bot password).
417          * If this cannot be a bot password login just return false.
418          * @param string $username
419          * @param string $password
420          * @return array|false
421          */
422         public static function canonicalizeLoginData( $username, $password ) {
423                 $sep = self::getSeparator();
424                 // the strlen check helps minimize the password information obtainable from timing
425                 if ( strlen( $password ) >= 32 && strpos( $username, $sep ) !== false ) {
426                         // the separator is not valid in new usernames but might appear in legacy ones
427                         if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
428                                 return [ $username, $password, true ];
429                         }
430                 } elseif ( strlen( $password ) > 32 && strpos( $password, $sep ) !== false ) {
431                         $segments = explode( $sep, $password );
432                         $password = array_pop( $segments );
433                         $appId = implode( $sep, $segments );
434                         if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
435                                 return [ $username . $sep . $appId, $password, true ];
436                         }
437                 }
438                 return false;
439         }
440
441         /**
442          * Try to log the user in
443          * @param string $username Combined user name and app ID
444          * @param string $password Supplied password
445          * @param WebRequest $request
446          * @return Status On success, the good status's value is the new Session object
447          */
448         public static function login( $username, $password, WebRequest $request ) {
449                 global $wgEnableBotPasswords, $wgPasswordAttemptThrottle;
450
451                 if ( !$wgEnableBotPasswords ) {
452                         return Status::newFatal( 'botpasswords-disabled' );
453                 }
454
455                 $manager = MediaWiki\Session\SessionManager::singleton();
456                 $provider = $manager->getProvider( BotPasswordSessionProvider::class );
457                 if ( !$provider ) {
458                         return Status::newFatal( 'botpasswords-no-provider' );
459                 }
460
461                 // Split name into name+appId
462                 $sep = self::getSeparator();
463                 if ( strpos( $username, $sep ) === false ) {
464                         return Status::newFatal( 'botpasswords-invalid-name', $sep );
465                 }
466                 list( $name, $appId ) = explode( $sep, $username, 2 );
467
468                 // Find the named user
469                 $user = User::newFromName( $name );
470                 if ( !$user || $user->isAnon() ) {
471                         return Status::newFatal( 'nosuchuser', $name );
472                 }
473
474                 if ( $user->isLocked() ) {
475                         return Status::newFatal( 'botpasswords-locked' );
476                 }
477
478                 // Throttle
479                 $throttle = null;
480                 if ( !empty( $wgPasswordAttemptThrottle ) ) {
481                         $throttle = new MediaWiki\Auth\Throttler( $wgPasswordAttemptThrottle, [
482                                 'type' => 'botpassword',
483                                 'cache' => ObjectCache::getLocalClusterInstance(),
484                         ] );
485                         $result = $throttle->increase( $user->getName(), $request->getIP(), __METHOD__ );
486                         if ( $result ) {
487                                 $msg = wfMessage( 'login-throttled' )->durationParams( $result['wait'] );
488                                 return Status::newFatal( $msg );
489                         }
490                 }
491
492                 // Get the bot password
493                 $bp = self::newFromUser( $user, $appId );
494                 if ( !$bp ) {
495                         return Status::newFatal( 'botpasswords-not-exist', $name, $appId );
496                 }
497
498                 // Check restrictions
499                 $status = $bp->getRestrictions()->check( $request );
500                 if ( !$status->isOK() ) {
501                         return Status::newFatal( 'botpasswords-restriction-failed' );
502                 }
503
504                 // Check the password
505                 $passwordObj = $bp->getPassword();
506                 if ( $passwordObj instanceof InvalidPassword ) {
507                         return Status::newFatal( 'botpasswords-needs-reset', $name, $appId );
508                 }
509                 if ( !$passwordObj->equals( $password ) ) {
510                         return Status::newFatal( 'wrongpassword' );
511                 }
512
513                 // Ok! Create the session.
514                 if ( $throttle ) {
515                         $throttle->clear( $user->getName(), $request->getIP() );
516                 }
517                 return Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) );
518         }
519 }