Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 160 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
BotPasswordStore | |
0.00% |
0 / 160 |
|
0.00% |
0 / 12 |
1482 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getReplicaDatabase | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPrimaryDatabase | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getByUser | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getByCentralId | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
newUnsavedBotPassword | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
132 | |||
insertBotPassword | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
12 | |||
updateBotPassword | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
20 | |||
validateBotPassword | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
deleteBotPassword | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
invalidateUserPasswords | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
removeUserPasswords | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | /** |
3 | * BotPassword interaction with databases |
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 | * @file |
21 | */ |
22 | |
23 | namespace MediaWiki\User; |
24 | |
25 | use MediaWiki\Config\ServiceOptions; |
26 | use MediaWiki\Json\FormatJson; |
27 | use MediaWiki\MainConfigNames; |
28 | use MediaWiki\Password\Password; |
29 | use MediaWiki\Password\PasswordFactory; |
30 | use MediaWiki\User\CentralId\CentralIdLookup; |
31 | use MWCryptRand; |
32 | use MWRestrictions; |
33 | use StatusValue; |
34 | use Wikimedia\Rdbms\IConnectionProvider; |
35 | use Wikimedia\Rdbms\IDatabase; |
36 | use Wikimedia\Rdbms\IDBAccessObject; |
37 | use Wikimedia\Rdbms\IReadableDatabase; |
38 | |
39 | /** |
40 | * @author DannyS712 |
41 | * @since 1.37 |
42 | */ |
43 | class BotPasswordStore { |
44 | |
45 | /** |
46 | * @internal For use by ServiceWiring |
47 | */ |
48 | public const CONSTRUCTOR_OPTIONS = [ |
49 | MainConfigNames::EnableBotPasswords, |
50 | ]; |
51 | |
52 | private ServiceOptions $options; |
53 | private IConnectionProvider $dbProvider; |
54 | private CentralIdLookup $centralIdLookup; |
55 | |
56 | /** |
57 | * @param ServiceOptions $options |
58 | * @param CentralIdLookup $centralIdLookup |
59 | * @param IConnectionProvider $dbProvider |
60 | */ |
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; |
70 | } |
71 | |
72 | /** |
73 | * Get a database connection for the bot passwords database |
74 | * @return IReadableDatabase |
75 | * @internal |
76 | */ |
77 | public function getReplicaDatabase(): IReadableDatabase { |
78 | return $this->dbProvider->getReplicaDatabase( 'virtual-botpasswords' ); |
79 | } |
80 | |
81 | /** |
82 | * Get a database connection for the bot passwords database |
83 | * @return IDatabase |
84 | * @internal |
85 | */ |
86 | public function getPrimaryDatabase(): IDatabase { |
87 | return $this->dbProvider->getPrimaryDatabase( 'virtual-botpasswords' ); |
88 | } |
89 | |
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 |
96 | */ |
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; |
104 | } |
105 | |
106 | $centralId = $this->centralIdLookup->centralIdFromLocalUser( |
107 | $userIdentity, |
108 | CentralIdLookup::AUDIENCE_RAW, |
109 | $flags |
110 | ); |
111 | return $centralId ? $this->getByCentralId( $centralId, $appId, $flags ) : null; |
112 | } |
113 | |
114 | /** |
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 |
120 | */ |
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; |
128 | } |
129 | |
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' ); |
134 | } |
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; |
142 | } |
143 | |
144 | /** |
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 |
155 | */ |
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; |
162 | } |
163 | |
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'] ?? [], |
170 | ]; |
171 | |
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 ) |
177 | ) { |
178 | return null; |
179 | } |
180 | |
181 | $row->bp_restrictions = $row->bp_restrictions->toJson(); |
182 | $row->bp_grants = FormatJson::encode( $row->bp_grants ); |
183 | |
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 |
190 | ); |
191 | } elseif ( isset( $data['username'] ) ) { |
192 | $row->bp_user = $this->centralIdLookup->centralIdFromName( |
193 | $data['username'], |
194 | CentralIdLookup::AUDIENCE_RAW, |
195 | $flags |
196 | ); |
197 | } elseif ( isset( $data['centralId'] ) ) { |
198 | $row->bp_user = $data['centralId']; |
199 | } |
200 | if ( !$row->bp_user ) { |
201 | return null; |
202 | } |
203 | |
204 | return new BotPassword( $row, false, $flags ); |
205 | } |
206 | |
207 | /** |
208 | * Save the new BotPassword to the database |
209 | * |
210 | * @internal |
211 | * |
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 |
215 | */ |
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; |
223 | } |
224 | |
225 | $password ??= PasswordFactory::newInvalidPassword(); |
226 | |
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(), |
238 | ] ) |
239 | ->caller( __METHOD__ )->execute(); |
240 | |
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 ); |
249 | } |
250 | return StatusValue::newFatal( 'botpasswords-insert-failed', $botPassword->getAppId() ); |
251 | } |
252 | |
253 | /** |
254 | * Update an existing BotPassword in the database |
255 | * |
256 | * @internal |
257 | * |
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 |
261 | */ |
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; |
269 | } |
270 | |
271 | $conds = [ |
272 | 'bp_user' => $botPassword->getUserCentralId(), |
273 | 'bp_app_id' => $botPassword->getAppId(), |
274 | ]; |
275 | $fields = [ |
276 | 'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ), |
277 | 'bp_restrictions' => $botPassword->getRestrictions()->toJson(), |
278 | 'bp_grants' => FormatJson::encode( $botPassword->getGrants() ), |
279 | ]; |
280 | if ( $password !== null ) { |
281 | $fields['bp_password'] = $password->toString(); |
282 | } |
283 | |
284 | $dbw = $this->getPrimaryDatabase(); |
285 | $dbw->newUpdateQueryBuilder() |
286 | ->update( 'bot_passwords' ) |
287 | ->set( $fields ) |
288 | ->where( $conds ) |
289 | ->caller( __METHOD__ )->execute(); |
290 | |
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 ); |
299 | } |
300 | return StatusValue::newFatal( 'botpasswords-update-failed', $botPassword->getAppId() ); |
301 | } |
302 | |
303 | /** |
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 |
306 | * |
307 | * @param BotPassword $botPassword |
308 | * @return StatusValue |
309 | */ |
310 | private function validateBotPassword( BotPassword $botPassword ): StatusValue { |
311 | $res = StatusValue::newGood(); |
312 | |
313 | $restrictions = $botPassword->getRestrictions()->toJson(); |
314 | if ( strlen( $restrictions ) > BotPassword::RESTRICTIONS_MAXLENGTH ) { |
315 | $res->fatal( 'botpasswords-toolong-restrictions' ); |
316 | } |
317 | |
318 | $grants = FormatJson::encode( $botPassword->getGrants() ); |
319 | if ( strlen( $grants ) > BotPassword::GRANTS_MAXLENGTH ) { |
320 | $res->fatal( 'botpasswords-toolong-grants' ); |
321 | } |
322 | |
323 | return $res; |
324 | } |
325 | |
326 | /** |
327 | * Delete an existing BotPassword in the database |
328 | * |
329 | * @param BotPassword $botPassword |
330 | * @return bool |
331 | */ |
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(); |
339 | |
340 | return (bool)$dbw->affectedRows(); |
341 | } |
342 | |
343 | /** |
344 | * Invalidate all passwords for a user, by name |
345 | * @param string $username |
346 | * @return bool Whether any passwords were invalidated |
347 | */ |
348 | public function invalidateUserPasswords( string $username ): bool { |
349 | if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) { |
350 | return false; |
351 | } |
352 | |
353 | $centralId = $this->centralIdLookup->centralIdFromName( |
354 | $username, |
355 | CentralIdLookup::AUDIENCE_RAW, |
356 | IDBAccessObject::READ_LATEST |
357 | ); |
358 | if ( !$centralId ) { |
359 | return false; |
360 | } |
361 | |
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(); |
369 | } |
370 | |
371 | /** |
372 | * Remove all passwords for a user, by name |
373 | * @param string $username |
374 | * @return bool Whether any passwords were removed |
375 | */ |
376 | public function removeUserPasswords( string $username ): bool { |
377 | if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) { |
378 | return false; |
379 | } |
380 | |
381 | $centralId = $this->centralIdLookup->centralIdFromName( |
382 | $username, |
383 | CentralIdLookup::AUDIENCE_RAW, |
384 | IDBAccessObject::READ_LATEST |
385 | ); |
386 | if ( !$centralId ) { |
387 | return false; |
388 | } |
389 | |
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(); |
396 | } |
397 | |
398 | } |