Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 160
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
BotPasswordStore
0.00% covered (danger)
0.00%
0 / 160
0.00% covered (danger)
0.00%
0 / 12
1482
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getReplicaDatabase
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrimaryDatabase
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getByUser
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getByCentralId
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 newUnsavedBotPassword
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
132
 insertBotPassword
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 updateBotPassword
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 validateBotPassword
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 deleteBotPassword
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateUserPasswords
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 removeUserPasswords
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
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
23namespace MediaWiki\User;
24
25use MediaWiki\Config\ServiceOptions;
26use MediaWiki\Json\FormatJson;
27use MediaWiki\MainConfigNames;
28use MediaWiki\Password\Password;
29use MediaWiki\Password\PasswordFactory;
30use MediaWiki\User\CentralId\CentralIdLookup;
31use MWCryptRand;
32use MWRestrictions;
33use StatusValue;
34use Wikimedia\Rdbms\IConnectionProvider;
35use Wikimedia\Rdbms\IDatabase;
36use Wikimedia\Rdbms\IDBAccessObject;
37use Wikimedia\Rdbms\IReadableDatabase;
38
39/**
40 * @author DannyS712
41 * @since 1.37
42 */
43class 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}