3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
21 namespace MediaWiki\User
;
23 use MediaWiki\Config\ServiceOptions
;
24 use MediaWiki\Json\FormatJson
;
25 use MediaWiki\MainConfigNames
;
26 use MediaWiki\Password\Password
;
27 use MediaWiki\Password\PasswordFactory
;
28 use MediaWiki\User\CentralId\CentralIdLookup
;
32 use Wikimedia\Rdbms\IConnectionProvider
;
33 use Wikimedia\Rdbms\IDatabase
;
34 use Wikimedia\Rdbms\IDBAccessObject
;
35 use Wikimedia\Rdbms\IReadableDatabase
;
38 * BotPassword interaction with databases
43 class BotPasswordStore
{
46 * @internal For use by ServiceWiring
48 public const CONSTRUCTOR_OPTIONS
= [
49 MainConfigNames
::EnableBotPasswords
,
52 private ServiceOptions
$options;
53 private IConnectionProvider
$dbProvider;
54 private CentralIdLookup
$centralIdLookup;
57 * @param ServiceOptions $options
58 * @param CentralIdLookup $centralIdLookup
59 * @param IConnectionProvider $dbProvider
61 public function __construct(
62 ServiceOptions
$options,
63 CentralIdLookup
$centralIdLookup,
64 IConnectionProvider
$dbProvider
66 $options->assertRequiredOptions( self
::CONSTRUCTOR_OPTIONS
);
67 $this->options
= $options;
68 $this->centralIdLookup
= $centralIdLookup;
69 $this->dbProvider
= $dbProvider;
73 * Get a database connection for the bot passwords database
74 * @return IReadableDatabase
77 public function getReplicaDatabase(): IReadableDatabase
{
78 return $this->dbProvider
->getReplicaDatabase( 'virtual-botpasswords' );
82 * Get a database connection for the bot passwords database
86 public function getPrimaryDatabase(): IDatabase
{
87 return $this->dbProvider
->getPrimaryDatabase( 'virtual-botpasswords' );
91 * Load a BotPassword from the database based on a UserIdentity object
92 * @param UserIdentity $userIdentity
93 * @param string $appId
94 * @param int $flags IDBAccessObject read flags
95 * @return BotPassword|null
97 public function getByUser(
98 UserIdentity
$userIdentity,
100 int $flags = IDBAccessObject
::READ_NORMAL
102 if ( !$this->options
->get( MainConfigNames
::EnableBotPasswords
) ) {
106 $centralId = $this->centralIdLookup
->centralIdFromLocalUser(
108 CentralIdLookup
::AUDIENCE_RAW
,
111 return $centralId ?
$this->getByCentralId( $centralId, $appId, $flags ) : null;
115 * Load a BotPassword from the database
116 * @param int $centralId from CentralIdLookup
117 * @param string $appId
118 * @param int $flags IDBAccessObject read flags
119 * @return BotPassword|null
121 public function getByCentralId(
124 int $flags = IDBAccessObject
::READ_NORMAL
126 if ( !$this->options
->get( MainConfigNames
::EnableBotPasswords
) ) {
130 if ( ( $flags & IDBAccessObject
::READ_LATEST
) == IDBAccessObject
::READ_LATEST
) {
131 $db = $this->dbProvider
->getPrimaryDatabase( 'virtual-botpasswords' );
133 $db = $this->dbProvider
->getReplicaDatabase( 'virtual-botpasswords' );
135 $row = $db->newSelectQueryBuilder()
136 ->select( [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ] )
137 ->from( 'bot_passwords' )
138 ->where( [ 'bp_user' => $centralId, 'bp_app_id' => $appId ] )
140 ->caller( __METHOD__
)->fetchRow();
141 return $row ?
new BotPassword( $row, true, $flags ) : null;
145 * Create an unsaved BotPassword
146 * @param array $data Data to use to create the bot password. Keys are:
147 * - user: (UserIdentity) UserIdentity to create the password for. Overrides username and centralId.
148 * - username: (string) Username to create the password for. Overrides centralId.
149 * - centralId: (int) User central ID to create the password for.
150 * - appId: (string, required) App ID for the password.
151 * - restrictions: (MWRestrictions, optional) Restrictions.
152 * - grants: (string[], optional) Grants.
153 * @param int $flags IDBAccessObject read flags
154 * @return BotPassword|null
156 public function newUnsavedBotPassword(
158 int $flags = IDBAccessObject
::READ_NORMAL
160 if ( isset( $data['user'] ) && ( !$data['user'] instanceof UserIdentity
) ) {
166 'bp_app_id' => trim( $data['appId'] ??
'' ),
167 'bp_token' => '**unsaved**',
168 'bp_restrictions' => $data['restrictions'] ?? MWRestrictions
::newDefault(),
169 'bp_grants' => $data['grants'] ??
[],
173 $row->bp_app_id
=== '' ||
174 strlen( $row->bp_app_id
) > BotPassword
::APPID_MAXLENGTH ||
175 !$row->bp_restrictions
instanceof MWRestrictions ||
176 !is_array( $row->bp_grants
)
181 $row->bp_restrictions
= $row->bp_restrictions
->toJson();
182 $row->bp_grants
= FormatJson
::encode( $row->bp_grants
);
184 if ( isset( $data['user'] ) ) {
185 // Must be a UserIdentity object, already checked above
186 $row->bp_user
= $this->centralIdLookup
->centralIdFromLocalUser(
188 CentralIdLookup
::AUDIENCE_RAW
,
191 } elseif ( isset( $data['username'] ) ) {
192 $row->bp_user
= $this->centralIdLookup
->centralIdFromName(
194 CentralIdLookup
::AUDIENCE_RAW
,
197 } elseif ( isset( $data['centralId'] ) ) {
198 $row->bp_user
= $data['centralId'];
200 if ( !$row->bp_user
) {
204 return new BotPassword( $row, false, $flags );
208 * Save the new BotPassword to the database
212 * @param BotPassword $botPassword
213 * @param Password|null $password Use null for an invalid password
214 * @return StatusValue if everything worked, the value of the StatusValue is the new token
216 public function insertBotPassword(
217 BotPassword
$botPassword,
218 ?Password
$password = null
220 $res = $this->validateBotPassword( $botPassword );
221 if ( !$res->isGood() ) {
225 $password ??
= PasswordFactory
::newInvalidPassword();
227 $dbw = $this->getPrimaryDatabase();
228 $dbw->newInsertQueryBuilder()
229 ->insertInto( 'bot_passwords' )
232 'bp_user' => $botPassword->getUserCentralId(),
233 'bp_app_id' => $botPassword->getAppId(),
234 'bp_token' => MWCryptRand
::generateHex( User
::TOKEN_LENGTH
),
235 'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
236 'bp_grants' => FormatJson
::encode( $botPassword->getGrants() ),
237 'bp_password' => $password->toString(),
239 ->caller( __METHOD__
)->execute();
241 $ok = (bool)$dbw->affectedRows();
243 $token = $dbw->newSelectQueryBuilder()
244 ->select( 'bp_token' )
245 ->from( 'bot_passwords' )
246 ->where( [ 'bp_user' => $botPassword->getUserCentralId(), 'bp_app_id' => $botPassword->getAppId(), ] )
247 ->caller( __METHOD__
)->fetchField();
248 return StatusValue
::newGood( $token );
250 return StatusValue
::newFatal( 'botpasswords-insert-failed', $botPassword->getAppId() );
254 * Update an existing BotPassword in the database
258 * @param BotPassword $botPassword
259 * @param Password|null $password Use null for an invalid password
260 * @return StatusValue if everything worked, the value of the StatusValue is the new token
262 public function updateBotPassword(
263 BotPassword
$botPassword,
264 ?Password
$password = null
266 $res = $this->validateBotPassword( $botPassword );
267 if ( !$res->isGood() ) {
272 'bp_user' => $botPassword->getUserCentralId(),
273 'bp_app_id' => $botPassword->getAppId(),
276 'bp_token' => MWCryptRand
::generateHex( User
::TOKEN_LENGTH
),
277 'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
278 'bp_grants' => FormatJson
::encode( $botPassword->getGrants() ),
280 if ( $password !== null ) {
281 $fields['bp_password'] = $password->toString();
284 $dbw = $this->getPrimaryDatabase();
285 $dbw->newUpdateQueryBuilder()
286 ->update( 'bot_passwords' )
289 ->caller( __METHOD__
)->execute();
291 $ok = (bool)$dbw->affectedRows();
293 $token = $dbw->newSelectQueryBuilder()
294 ->select( 'bp_token' )
295 ->from( 'bot_passwords' )
297 ->caller( __METHOD__
)->fetchField();
298 return StatusValue
::newGood( $token );
300 return StatusValue
::newFatal( 'botpasswords-update-failed', $botPassword->getAppId() );
304 * Check if a BotPassword is valid to save in the database (either inserting a new
305 * one or updating an existing one) based on the size of the restrictions and grants
307 * @param BotPassword $botPassword
308 * @return StatusValue
310 private function validateBotPassword( BotPassword
$botPassword ): StatusValue
{
311 $res = StatusValue
::newGood();
313 $restrictions = $botPassword->getRestrictions()->toJson();
314 if ( strlen( $restrictions ) > BotPassword
::RESTRICTIONS_MAXLENGTH
) {
315 $res->fatal( 'botpasswords-toolong-restrictions' );
318 $grants = FormatJson
::encode( $botPassword->getGrants() );
319 if ( strlen( $grants ) > BotPassword
::GRANTS_MAXLENGTH
) {
320 $res->fatal( 'botpasswords-toolong-grants' );
327 * Delete an existing BotPassword in the database
329 * @param BotPassword $botPassword
332 public function deleteBotPassword( BotPassword
$botPassword ): bool {
333 $dbw = $this->getPrimaryDatabase();
334 $dbw->newDeleteQueryBuilder()
335 ->deleteFrom( 'bot_passwords' )
336 ->where( [ 'bp_user' => $botPassword->getUserCentralId() ] )
337 ->andWhere( [ 'bp_app_id' => $botPassword->getAppId() ] )
338 ->caller( __METHOD__
)->execute();
340 return (bool)$dbw->affectedRows();
344 * Invalidate all passwords for a user, by name
345 * @param string $username
346 * @return bool Whether any passwords were invalidated
348 public function invalidateUserPasswords( string $username ): bool {
349 if ( !$this->options
->get( MainConfigNames
::EnableBotPasswords
) ) {
353 $centralId = $this->centralIdLookup
->centralIdFromName(
355 CentralIdLookup
::AUDIENCE_RAW
,
356 IDBAccessObject
::READ_LATEST
362 $dbw = $this->getPrimaryDatabase();
363 $dbw->newUpdateQueryBuilder()
364 ->update( 'bot_passwords' )
365 ->set( [ 'bp_password' => PasswordFactory
::newInvalidPassword()->toString() ] )
366 ->where( [ 'bp_user' => $centralId ] )
367 ->caller( __METHOD__
)->execute();
368 return (bool)$dbw->affectedRows();
372 * Remove all passwords for a user, by name
373 * @param string $username
374 * @return bool Whether any passwords were removed
376 public function removeUserPasswords( string $username ): bool {
377 if ( !$this->options
->get( MainConfigNames
::EnableBotPasswords
) ) {
381 $centralId = $this->centralIdLookup
->centralIdFromName(
383 CentralIdLookup
::AUDIENCE_RAW
,
384 IDBAccessObject
::READ_LATEST
390 $dbw = $this->getPrimaryDatabase();
391 $dbw->newDeleteQueryBuilder()
392 ->deleteFrom( 'bot_passwords' )
393 ->where( [ 'bp_user' => $centralId ] )
394 ->caller( __METHOD__
)->execute();
395 return (bool)$dbw->affectedRows();