Merge ".mailmap: Correct two contributor names"
[mediawiki.git] / includes / user / BotPasswordStore.php
blob8e48667d2e6ea58cf8c7ef9b1c41c05bfeb1852f
1 <?php
2 /**
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
18 * @file
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;
29 use MWCryptRand;
30 use MWRestrictions;
31 use StatusValue;
32 use Wikimedia\Rdbms\IConnectionProvider;
33 use Wikimedia\Rdbms\IDatabase;
34 use Wikimedia\Rdbms\IDBAccessObject;
35 use Wikimedia\Rdbms\IReadableDatabase;
37 /**
38 * BotPassword interaction with databases
40 * @author DannyS712
41 * @since 1.37
43 class BotPasswordStore {
45 /**
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;
56 /**
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
65 ) {
66 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
67 $this->options = $options;
68 $this->centralIdLookup = $centralIdLookup;
69 $this->dbProvider = $dbProvider;
72 /**
73 * Get a database connection for the bot passwords database
74 * @return IReadableDatabase
75 * @internal
77 public function getReplicaDatabase(): IReadableDatabase {
78 return $this->dbProvider->getReplicaDatabase( 'virtual-botpasswords' );
81 /**
82 * Get a database connection for the bot passwords database
83 * @return IDatabase
84 * @internal
86 public function getPrimaryDatabase(): IDatabase {
87 return $this->dbProvider->getPrimaryDatabase( 'virtual-botpasswords' );
90 /**
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,
99 string $appId,
100 int $flags = IDBAccessObject::READ_NORMAL
101 ): ?BotPassword {
102 if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
103 return null;
106 $centralId = $this->centralIdLookup->centralIdFromLocalUser(
107 $userIdentity,
108 CentralIdLookup::AUDIENCE_RAW,
109 $flags
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(
122 int $centralId,
123 string $appId,
124 int $flags = IDBAccessObject::READ_NORMAL
125 ): ?BotPassword {
126 if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
127 return null;
130 if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
131 $db = $this->dbProvider->getPrimaryDatabase( 'virtual-botpasswords' );
132 } else {
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 ] )
139 ->recency( $flags )
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(
157 array $data,
158 int $flags = IDBAccessObject::READ_NORMAL
159 ): ?BotPassword {
160 if ( isset( $data['user'] ) && ( !$data['user'] instanceof UserIdentity ) ) {
161 return null;
164 $row = (object)[
165 'bp_user' => 0,
166 'bp_app_id' => trim( $data['appId'] ?? '' ),
167 'bp_token' => '**unsaved**',
168 'bp_restrictions' => $data['restrictions'] ?? MWRestrictions::newDefault(),
169 'bp_grants' => $data['grants'] ?? [],
172 if (
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 )
178 return null;
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(
187 $data['user'],
188 CentralIdLookup::AUDIENCE_RAW,
189 $flags
191 } elseif ( isset( $data['username'] ) ) {
192 $row->bp_user = $this->centralIdLookup->centralIdFromName(
193 $data['username'],
194 CentralIdLookup::AUDIENCE_RAW,
195 $flags
197 } elseif ( isset( $data['centralId'] ) ) {
198 $row->bp_user = $data['centralId'];
200 if ( !$row->bp_user ) {
201 return null;
204 return new BotPassword( $row, false, $flags );
208 * Save the new BotPassword to the database
210 * @internal
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
219 ): StatusValue {
220 $res = $this->validateBotPassword( $botPassword );
221 if ( !$res->isGood() ) {
222 return $res;
225 $password ??= PasswordFactory::newInvalidPassword();
227 $dbw = $this->getPrimaryDatabase();
228 $dbw->newInsertQueryBuilder()
229 ->insertInto( 'bot_passwords' )
230 ->ignore()
231 ->row( [
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();
242 if ( $ok ) {
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
256 * @internal
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
265 ): StatusValue {
266 $res = $this->validateBotPassword( $botPassword );
267 if ( !$res->isGood() ) {
268 return $res;
271 $conds = [
272 'bp_user' => $botPassword->getUserCentralId(),
273 'bp_app_id' => $botPassword->getAppId(),
275 $fields = [
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' )
287 ->set( $fields )
288 ->where( $conds )
289 ->caller( __METHOD__ )->execute();
291 $ok = (bool)$dbw->affectedRows();
292 if ( $ok ) {
293 $token = $dbw->newSelectQueryBuilder()
294 ->select( 'bp_token' )
295 ->from( 'bot_passwords' )
296 ->where( $conds )
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' );
323 return $res;
327 * Delete an existing BotPassword in the database
329 * @param BotPassword $botPassword
330 * @return bool
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 ) ) {
350 return false;
353 $centralId = $this->centralIdLookup->centralIdFromName(
354 $username,
355 CentralIdLookup::AUDIENCE_RAW,
356 IDBAccessObject::READ_LATEST
358 if ( !$centralId ) {
359 return false;
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 ) ) {
378 return false;
381 $centralId = $this->centralIdLookup->centralIdFromName(
382 $username,
383 CentralIdLookup::AUDIENCE_RAW,
384 IDBAccessObject::READ_LATEST
386 if ( !$centralId ) {
387 return false;
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();