BotPassword::getGrants()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
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
23
/**
24
 * Utility class for bot passwords
25
 * @since 1.27
26
 */
27
class BotPassword implements IDBAccessObject {
28
29
	const APPID_MAXLENGTH = 32;
30
31
	/** @var bool */
32
	private $isSaved;
33
34
	/** @var int */
35
	private $centralId;
36
37
	/** @var string */
38
	private $appId;
39
40
	/** @var string */
41
	private $token;
42
43
	/** @var MWRestrictions */
44
	private $restrictions;
45
46
	/** @var string[] */
47
	private $grants;
48
49
	/** @var int */
50
	private $flags = self::READ_NORMAL;
51
52
	/**
53
	 * @param object $row bot_passwords database row
54
	 * @param bool $isSaved Whether the bot password was read from the database
55
	 * @param int $flags IDBAccessObject read flags
56
	 */
57
	protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
58
		$this->isSaved = $isSaved;
59
		$this->flags = $flags;
60
61
		$this->centralId = (int)$row->bp_user;
62
		$this->appId = $row->bp_app_id;
63
		$this->token = $row->bp_token;
64
		$this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
65
		$this->grants = FormatJson::decode( $row->bp_grants );
0 ignored issues
show
Documentation Bug introduced by
It seems like \FormatJson::decode($row->bp_grants) of type * is incompatible with the declared type array<integer,string> of property $grants.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
66
	}
67
68
	/**
69
	 * Get a database connection for the bot passwords database
70
	 * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_REPLICA.
71
	 * @return Database
72
	 */
73
	public static function getDB( $db ) {
74
		global $wgBotPasswordsCluster, $wgBotPasswordsDatabase;
75
76
		$lb = $wgBotPasswordsCluster
77
			? wfGetLBFactory()->getExternalLB( $wgBotPasswordsCluster )
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLBFactory() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
78
			: wfGetLB( $wgBotPasswordsDatabase );
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLB() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancer() or MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
79
		return $lb->getConnectionRef( $db, [], $wgBotPasswordsDatabase );
80
	}
81
82
	/**
83
	 * Load a BotPassword from the database
84
	 * @param User $user
85
	 * @param string $appId
86
	 * @param int $flags IDBAccessObject read flags
87
	 * @return BotPassword|null
88
	 */
89
	public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
90
		$centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
91
			$user, CentralIdLookup::AUDIENCE_RAW, $flags
92
		);
93
		return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
94
	}
95
96
	/**
97
	 * Load a BotPassword from the database
98
	 * @param int $centralId from CentralIdLookup
99
	 * @param string $appId
100
	 * @param int $flags IDBAccessObject read flags
101
	 * @return BotPassword|null
102
	 */
103
	public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
104
		global $wgEnableBotPasswords;
105
106
		if ( !$wgEnableBotPasswords ) {
107
			return null;
108
		}
109
110
		list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
111
		$db = self::getDB( $index );
112
		$row = $db->selectRow(
113
			'bot_passwords',
114
			[ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ],
115
			[ 'bp_user' => $centralId, 'bp_app_id' => $appId ],
116
			__METHOD__,
117
			$options
118
		);
119
		return $row ? new self( $row, true, $flags ) : null;
0 ignored issues
show
Bug introduced by
It seems like $row defined by $db->selectRow('bot_pass..., __METHOD__, $options) on line 112 can also be of type boolean; however, BotPassword::__construct() does only seem to accept object, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
120
	}
121
122
	/**
123
	 * Create an unsaved BotPassword
124
	 * @param array $data Data to use to create the bot password. Keys are:
125
	 *  - user: (User) User object to create the password for. Overrides username and centralId.
126
	 *  - username: (string) Username to create the password for. Overrides centralId.
127
	 *  - centralId: (int) User central ID to create the password for.
128
	 *  - appId: (string) App ID for the password.
129
	 *  - restrictions: (MWRestrictions, optional) Restrictions.
130
	 *  - grants: (string[], optional) Grants.
131
	 * @param int $flags IDBAccessObject read flags
132
	 * @return BotPassword|null
133
	 */
134
	public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
135
		$row = (object)[
136
			'bp_user' => 0,
137
			'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
138
			'bp_token' => '**unsaved**',
139
			'bp_restrictions' => isset( $data['restrictions'] )
140
				? $data['restrictions']
141
				: MWRestrictions::newDefault(),
142
			'bp_grants' => isset( $data['grants'] ) ? $data['grants'] : [],
143
		];
144
145
		if (
146
			$row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
147
			!$row->bp_restrictions instanceof MWRestrictions ||
148
			!is_array( $row->bp_grants )
149
		) {
150
			return null;
151
		}
152
153
		$row->bp_restrictions = $row->bp_restrictions->toJson();
154
		$row->bp_grants = FormatJson::encode( $row->bp_grants );
155
156
		if ( isset( $data['user'] ) ) {
157
			if ( !$data['user'] instanceof User ) {
158
				return null;
159
			}
160
			$row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
161
				$data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
162
			);
163
		} elseif ( isset( $data['username'] ) ) {
164
			$row->bp_user = CentralIdLookup::factory()->centralIdFromName(
165
				$data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
166
			);
167
		} elseif ( isset( $data['centralId'] ) ) {
168
			$row->bp_user = $data['centralId'];
169
		}
170
		if ( !$row->bp_user ) {
171
			return null;
172
		}
173
174
		return new self( $row, false, $flags );
175
	}
176
177
	/**
178
	 * Indicate whether this is known to be saved
179
	 * @return bool
180
	 */
181
	public function isSaved() {
182
		return $this->isSaved;
183
	}
184
185
	/**
186
	 * Get the central user ID
187
	 * @return int
188
	 */
189
	public function getUserCentralId() {
190
		return $this->centralId;
191
	}
192
193
	/**
194
	 * Get the app ID
195
	 * @return string
196
	 */
197
	public function getAppId() {
198
		return $this->appId;
199
	}
200
201
	/**
202
	 * Get the token
203
	 * @return string
204
	 */
205
	public function getToken() {
206
		return $this->token;
207
	}
208
209
	/**
210
	 * Get the restrictions
211
	 * @return MWRestrictions
212
	 */
213
	public function getRestrictions() {
214
		return $this->restrictions;
215
	}
216
217
	/**
218
	 * Get the grants
219
	 * @return string[]
220
	 */
221
	public function getGrants() {
222
		return $this->grants;
223
	}
224
225
	/**
226
	 * Get the separator for combined user name + app ID
227
	 * @return string
228
	 */
229
	public static function getSeparator() {
230
		global $wgUserrightsInterwikiDelimiter;
231
		return $wgUserrightsInterwikiDelimiter;
232
	}
233
234
	/**
235
	 * Get the password
236
	 * @return Password
237
	 */
238
	protected function getPassword() {
239
		list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
240
		$db = self::getDB( $index );
241
		$password = $db->selectField(
242
			'bot_passwords',
243
			'bp_password',
244
			[ 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ],
245
			__METHOD__,
246
			$options
247
		);
248
		if ( $password === false ) {
249
			return PasswordFactory::newInvalidPassword();
250
		}
251
252
		$passwordFactory = new \PasswordFactory();
253
		$passwordFactory->init( \RequestContext::getMain()->getConfig() );
254
		try {
255
			return $passwordFactory->newFromCiphertext( $password );
256
		} catch ( PasswordError $ex ) {
257
			return PasswordFactory::newInvalidPassword();
258
		}
259
	}
260
261
	/**
262
	 * Save the BotPassword to the database
263
	 * @param string $operation 'update' or 'insert'
264
	 * @param Password|null $password Password to set.
265
	 * @return bool Success
266
	 */
267
	public function save( $operation, Password $password = null ) {
268
		$conds = [
269
			'bp_user' => $this->centralId,
270
			'bp_app_id' => $this->appId,
271
		];
272
		$fields = [
273
			'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
274
			'bp_restrictions' => $this->restrictions->toJson(),
275
			'bp_grants' => FormatJson::encode( $this->grants ),
276
		];
277
278
		if ( $password !== null ) {
279
			$fields['bp_password'] = $password->toString();
280
		} elseif ( $operation === 'insert' ) {
281
			$fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
282
		}
283
284
		$dbw = self::getDB( DB_MASTER );
285
		switch ( $operation ) {
286
			case 'insert':
287
				$dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, [ 'IGNORE' ] );
288
				break;
289
290
			case 'update':
291
				$dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
292
				break;
293
294
			default:
295
				return false;
296
		}
297
		$ok = (bool)$dbw->affectedRows();
298
		if ( $ok ) {
299
			$this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
300
			$this->isSaved = true;
301
		}
302
		return $ok;
303
	}
304
305
	/**
306
	 * Delete the BotPassword from the database
307
	 * @return bool Success
308
	 */
309
	public function delete() {
310
		$conds = [
311
			'bp_user' => $this->centralId,
312
			'bp_app_id' => $this->appId,
313
		];
314
		$dbw = self::getDB( DB_MASTER );
315
		$dbw->delete( 'bot_passwords', $conds, __METHOD__ );
316
		$ok = (bool)$dbw->affectedRows();
317
		if ( $ok ) {
318
			$this->token = '**unsaved**';
319
			$this->isSaved = false;
320
		}
321
		return $ok;
322
	}
323
324
	/**
325
	 * Invalidate all passwords for a user, by name
326
	 * @param string $username User name
327
	 * @return bool Whether any passwords were invalidated
328
	 */
329
	public static function invalidateAllPasswordsForUser( $username ) {
330
		$centralId = CentralIdLookup::factory()->centralIdFromName(
331
			$username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
332
		);
333
		return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
334
	}
335
336
	/**
337
	 * Invalidate all passwords for a user, by central ID
338
	 * @param int $centralId
339
	 * @return bool Whether any passwords were invalidated
340
	 */
341
	public static function invalidateAllPasswordsForCentralId( $centralId ) {
342
		global $wgEnableBotPasswords;
343
344
		if ( !$wgEnableBotPasswords ) {
345
			return false;
346
		}
347
348
		$dbw = self::getDB( DB_MASTER );
349
		$dbw->update(
350
			'bot_passwords',
351
			[ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ],
352
			[ 'bp_user' => $centralId ],
353
			__METHOD__
354
		);
355
		return (bool)$dbw->affectedRows();
356
	}
357
358
	/**
359
	 * Remove all passwords for a user, by name
360
	 * @param string $username User name
361
	 * @return bool Whether any passwords were removed
362
	 */
363
	public static function removeAllPasswordsForUser( $username ) {
364
		$centralId = CentralIdLookup::factory()->centralIdFromName(
365
			$username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
366
		);
367
		return $centralId && self::removeAllPasswordsForCentralId( $centralId );
368
	}
369
370
	/**
371
	 * Remove all passwords for a user, by central ID
372
	 * @param int $centralId
373
	 * @return bool Whether any passwords were removed
374
	 */
375
	public static function removeAllPasswordsForCentralId( $centralId ) {
376
		global $wgEnableBotPasswords;
377
378
		if ( !$wgEnableBotPasswords ) {
379
			return false;
380
		}
381
382
		$dbw = self::getDB( DB_MASTER );
383
		$dbw->delete(
384
			'bot_passwords',
385
			[ 'bp_user' => $centralId ],
386
			__METHOD__
387
		);
388
		return (bool)$dbw->affectedRows();
389
	}
390
391
	/**
392
	 * Returns a (raw, unhashed) random password string.
393
	 * @param Config $config
394
	 * @return string
395
	 */
396
	public static function generatePassword( $config ) {
397
		return PasswordFactory::generateRandomPasswordString(
398
			max( 32, $config->get( 'MinimalPasswordLength' ) ) );
399
	}
400
401
	/**
402
	 * There are two ways to login with a bot password: "username@appId", "password" and
403
	 * "username", "appId@password". Transform it so it is always in the first form.
404
	 * Returns [bot username, bot password, could be normal password?] where the last one is a flag
405
	 * meaning this could either be a bot password or a normal password, it cannot be decided for
406
	 * certain (although in such cases it almost always will be a bot password).
407
	 * If this cannot be a bot password login just return false.
408
	 * @param string $username
409
	 * @param string $password
410
	 * @return array|false
411
	 */
412
	public static function canonicalizeLoginData( $username, $password ) {
413
		$sep = BotPassword::getSeparator();
414
		// the strlen check helps minimize the password information obtainable from timing
415
		if ( strlen( $password ) >= 32 && strpos( $username, $sep ) !== false ) {
416
			// the separator is not valid in new usernames but might appear in legacy ones
417
			if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
418
				return [ $username, $password, true ];
419
			}
420
		} elseif ( strlen( $password ) > 32 && strpos( $password, $sep ) !== false ) {
421
			$segments = explode( $sep, $password );
422
			$password = array_pop( $segments );
423
			$appId = implode( $sep, $segments );
424
			if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
425
				return [ $username . $sep . $appId, $password, true ];
426
			}
427
		}
428
		return false;
429
	}
430
431
	/**
432
	 * Try to log the user in
433
	 * @param string $username Combined user name and app ID
434
	 * @param string $password Supplied password
435
	 * @param WebRequest $request
436
	 * @return Status On success, the good status's value is the new Session object
437
	 */
438
	public static function login( $username, $password, WebRequest $request ) {
439
		global $wgEnableBotPasswords;
440
441
		if ( !$wgEnableBotPasswords ) {
442
			return Status::newFatal( 'botpasswords-disabled' );
443
		}
444
445
		$manager = MediaWiki\Session\SessionManager::singleton();
446
		$provider = $manager->getProvider( BotPasswordSessionProvider::class );
447
		if ( !$provider ) {
448
			return Status::newFatal( 'botpasswords-no-provider' );
449
		}
450
451
		// Split name into name+appId
452
		$sep = self::getSeparator();
453
		if ( strpos( $username, $sep ) === false ) {
454
			return Status::newFatal( 'botpasswords-invalid-name', $sep );
455
		}
456
		list( $name, $appId ) = explode( $sep, $username, 2 );
457
458
		// Find the named user
459
		$user = User::newFromName( $name );
460
		if ( !$user || $user->isAnon() ) {
461
			return Status::newFatal( 'nosuchuser', $name );
462
		}
463
464
		// Get the bot password
465
		$bp = self::newFromUser( $user, $appId );
466
		if ( !$bp ) {
467
			return Status::newFatal( 'botpasswords-not-exist', $name, $appId );
468
		}
469
470
		// Check restrictions
471
		$status = $bp->getRestrictions()->check( $request );
472
		if ( !$status->isOK() ) {
473
			return Status::newFatal( 'botpasswords-restriction-failed' );
474
		}
475
476
		// Check the password
477
		if ( !$bp->getPassword()->equals( $password ) ) {
478
			return Status::newFatal( 'wrongpassword' );
479
		}
480
481
		// Ok! Create the session.
482
		return Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) );
483
	}
484
}
485