Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 157
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 / 157
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 / 1
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 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\Json\FormatJson;
11use MediaWiki\MainConfigNames;
12use MediaWiki\Password\Password;
13use MediaWiki\Password\PasswordFactory;
14use MediaWiki\User\CentralId\CentralIdLookup;
15use MediaWiki\Utils\MWCryptRand;
16use MediaWiki\Utils\MWRestrictions;
17use StatusValue;
18use Wikimedia\Rdbms\IConnectionProvider;
19use Wikimedia\Rdbms\IDatabase;
20use Wikimedia\Rdbms\IDBAccessObject;
21use Wikimedia\Rdbms\IReadableDatabase;
22
23/**
24 * BotPassword interaction with databases
25 *
26 * @author DannyS712
27 * @since 1.37
28 */
29class BotPasswordStore {
30
31    /**
32     * @internal For use by ServiceWiring
33     */
34    public const CONSTRUCTOR_OPTIONS = [
35        MainConfigNames::EnableBotPasswords,
36    ];
37
38    public function __construct(
39        private readonly ServiceOptions $options,
40        private readonly CentralIdLookup $centralIdLookup,
41        private readonly IConnectionProvider $dbProvider,
42    ) {
43        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
44    }
45
46    /**
47     * Get a database connection for the bot passwords database
48     * @internal
49     */
50    public function getReplicaDatabase(): IReadableDatabase {
51        return $this->dbProvider->getReplicaDatabase( 'virtual-botpasswords' );
52    }
53
54    /**
55     * Get a database connection for the bot passwords database
56     * @internal
57     */
58    public function getPrimaryDatabase(): IDatabase {
59        return $this->dbProvider->getPrimaryDatabase( 'virtual-botpasswords' );
60    }
61
62    /**
63     * Load a BotPassword from the database based on a UserIdentity object
64     * @param UserIdentity $userIdentity
65     * @param string $appId
66     * @param int $flags IDBAccessObject read flags
67     * @return BotPassword|null
68     */
69    public function getByUser(
70        UserIdentity $userIdentity,
71        string $appId,
72        int $flags = IDBAccessObject::READ_NORMAL
73    ): ?BotPassword {
74        if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
75            return null;
76        }
77
78        $centralId = $this->centralIdLookup->centralIdFromLocalUser(
79            $userIdentity,
80            CentralIdLookup::AUDIENCE_RAW,
81            $flags
82        );
83        return $centralId ? $this->getByCentralId( $centralId, $appId, $flags ) : null;
84    }
85
86    /**
87     * Load a BotPassword from the database
88     * @param int $centralId from CentralIdLookup
89     * @param string $appId
90     * @param int $flags IDBAccessObject read flags
91     * @return BotPassword|null
92     */
93    public function getByCentralId(
94        int $centralId,
95        string $appId,
96        int $flags = IDBAccessObject::READ_NORMAL
97    ): ?BotPassword {
98        if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
99            return null;
100        }
101
102        if ( ( $flags & IDBAccessObject::READ_LATEST ) === IDBAccessObject::READ_LATEST ) {
103            $db = $this->dbProvider->getPrimaryDatabase( 'virtual-botpasswords' );
104        } else {
105            $db = $this->dbProvider->getReplicaDatabase( 'virtual-botpasswords' );
106        }
107        $row = $db->newSelectQueryBuilder()
108            ->select( [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ] )
109            ->from( 'bot_passwords' )
110            ->where( [ 'bp_user' => $centralId, 'bp_app_id' => $appId ] )
111            ->recency( $flags )
112            ->caller( __METHOD__ )->fetchRow();
113        return $row ? new BotPassword( $row, true, $flags ) : null;
114    }
115
116    /**
117     * Create an unsaved BotPassword
118     * @param array $data Data to use to create the bot password. Keys are:
119     *  - user: (UserIdentity) UserIdentity to create the password for. Overrides username and centralId.
120     *  - username: (string) Username to create the password for. Overrides centralId.
121     *  - centralId: (int) User central ID to create the password for.
122     *  - appId: (string, required) App ID for the password.
123     *  - restrictions: (MWRestrictions, optional) Restrictions.
124     *  - grants: (string[], optional) Grants.
125     * @param int $flags IDBAccessObject read flags
126     * @return BotPassword|null
127     */
128    public function newUnsavedBotPassword(
129        array $data,
130        int $flags = IDBAccessObject::READ_NORMAL
131    ): ?BotPassword {
132        if ( isset( $data['user'] ) && ( !$data['user'] instanceof UserIdentity ) ) {
133            return null;
134        }
135
136        $row = (object)[
137            'bp_user' => 0,
138            'bp_app_id' => trim( $data['appId'] ?? '' ),
139            'bp_token' => '**unsaved**',
140            'bp_restrictions' => $data['restrictions'] ?? MWRestrictions::newDefault(),
141            'bp_grants' => $data['grants'] ?? [],
142        ];
143
144        if (
145            $row->bp_app_id === '' ||
146            strlen( $row->bp_app_id ) > BotPassword::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            // Must be a UserIdentity object, already checked above
158            $row->bp_user = $this->centralIdLookup->centralIdFromLocalUser(
159                $data['user'],
160                CentralIdLookup::AUDIENCE_RAW,
161                $flags
162            );
163        } elseif ( isset( $data['username'] ) ) {
164            $row->bp_user = $this->centralIdLookup->centralIdFromName(
165                $data['username'],
166                CentralIdLookup::AUDIENCE_RAW,
167                $flags
168            );
169        } elseif ( isset( $data['centralId'] ) ) {
170            $row->bp_user = $data['centralId'];
171        }
172        if ( !$row->bp_user ) {
173            return null;
174        }
175
176        return new BotPassword( $row, false, $flags );
177    }
178
179    /**
180     * Save the new BotPassword to the database
181     *
182     * @internal
183     *
184     * @param BotPassword $botPassword
185     * @param Password|null $password Use null for an invalid password
186     * @return StatusValue if everything worked, the value of the StatusValue is the new token
187     */
188    public function insertBotPassword(
189        BotPassword $botPassword,
190        ?Password $password = null
191    ): StatusValue {
192        $res = $this->validateBotPassword( $botPassword );
193        if ( !$res->isGood() ) {
194            return $res;
195        }
196
197        $password ??= PasswordFactory::newInvalidPassword();
198
199        $dbw = $this->getPrimaryDatabase();
200        $dbw->newInsertQueryBuilder()
201            ->insertInto( 'bot_passwords' )
202            ->ignore()
203            ->row( [
204                'bp_user' => $botPassword->getUserCentralId(),
205                'bp_app_id' => $botPassword->getAppId(),
206                'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
207                'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
208                'bp_grants' => FormatJson::encode( $botPassword->getGrants() ),
209                'bp_password' => $password->toString(),
210            ] )
211            ->caller( __METHOD__ )->execute();
212
213        $ok = (bool)$dbw->affectedRows();
214        if ( $ok ) {
215            $token = $dbw->newSelectQueryBuilder()
216                ->select( 'bp_token' )
217                ->from( 'bot_passwords' )
218                ->where( [ 'bp_user' => $botPassword->getUserCentralId(), 'bp_app_id' => $botPassword->getAppId(), ] )
219                ->caller( __METHOD__ )->fetchField();
220            return StatusValue::newGood( $token );
221        }
222        return StatusValue::newFatal( 'botpasswords-insert-failed', $botPassword->getAppId() );
223    }
224
225    /**
226     * Update an existing BotPassword in the database
227     *
228     * @internal
229     *
230     * @param BotPassword $botPassword
231     * @param Password|null $password Use null for an invalid password
232     * @return StatusValue if everything worked, the value of the StatusValue is the new token
233     */
234    public function updateBotPassword(
235        BotPassword $botPassword,
236        ?Password $password = null
237    ): StatusValue {
238        $res = $this->validateBotPassword( $botPassword );
239        if ( !$res->isGood() ) {
240            return $res;
241        }
242
243        $conds = [
244            'bp_user' => $botPassword->getUserCentralId(),
245            'bp_app_id' => $botPassword->getAppId(),
246        ];
247        $fields = [
248            'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
249            'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
250            'bp_grants' => FormatJson::encode( $botPassword->getGrants() ),
251        ];
252        if ( $password !== null ) {
253            $fields['bp_password'] = $password->toString();
254        }
255
256        $dbw = $this->getPrimaryDatabase();
257        $dbw->newUpdateQueryBuilder()
258            ->update( 'bot_passwords' )
259            ->set( $fields )
260            ->where( $conds )
261            ->caller( __METHOD__ )->execute();
262
263        $ok = (bool)$dbw->affectedRows();
264        if ( $ok ) {
265            $token = $dbw->newSelectQueryBuilder()
266                ->select( 'bp_token' )
267                ->from( 'bot_passwords' )
268                ->where( $conds )
269                ->caller( __METHOD__ )->fetchField();
270            return StatusValue::newGood( $token );
271        }
272        return StatusValue::newFatal( 'botpasswords-update-failed', $botPassword->getAppId() );
273    }
274
275    /**
276     * Check if a BotPassword is valid to save in the database (either inserting a new
277     * one or updating an existing one) based on the size of the restrictions and grants
278     */
279    private function validateBotPassword( BotPassword $botPassword ): StatusValue {
280        $res = StatusValue::newGood();
281
282        $restrictions = $botPassword->getRestrictions()->toJson();
283        if ( strlen( $restrictions ) > BotPassword::RESTRICTIONS_MAXLENGTH ) {
284            $res->fatal( 'botpasswords-toolong-restrictions' );
285        }
286
287        $grants = FormatJson::encode( $botPassword->getGrants() );
288        if ( strlen( $grants ) > BotPassword::GRANTS_MAXLENGTH ) {
289            $res->fatal( 'botpasswords-toolong-grants' );
290        }
291
292        return $res;
293    }
294
295    /**
296     * Delete an existing BotPassword in the database
297     */
298    public function deleteBotPassword( BotPassword $botPassword ): bool {
299        $dbw = $this->getPrimaryDatabase();
300        $dbw->newDeleteQueryBuilder()
301            ->deleteFrom( 'bot_passwords' )
302            ->where( [ 'bp_user' => $botPassword->getUserCentralId() ] )
303            ->andWhere( [ 'bp_app_id' => $botPassword->getAppId() ] )
304            ->caller( __METHOD__ )->execute();
305
306        return (bool)$dbw->affectedRows();
307    }
308
309    /**
310     * Invalidate all passwords for a user, by name
311     * @param string $username
312     * @return bool Whether any passwords were invalidated
313     */
314    public function invalidateUserPasswords( string $username ): bool {
315        if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
316            return false;
317        }
318
319        $centralId = $this->centralIdLookup->centralIdFromName(
320            $username,
321            CentralIdLookup::AUDIENCE_RAW,
322            IDBAccessObject::READ_LATEST
323        );
324        if ( !$centralId ) {
325            return false;
326        }
327
328        $dbw = $this->getPrimaryDatabase();
329        $dbw->newUpdateQueryBuilder()
330            ->update( 'bot_passwords' )
331            ->set( [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ] )
332            ->where( [ 'bp_user' => $centralId ] )
333            ->caller( __METHOD__ )->execute();
334        return (bool)$dbw->affectedRows();
335    }
336
337    /**
338     * Remove all passwords for a user, by name
339     * @param string $username
340     * @return bool Whether any passwords were removed
341     */
342    public function removeUserPasswords( string $username ): bool {
343        if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
344            return false;
345        }
346
347        $centralId = $this->centralIdLookup->centralIdFromName(
348            $username,
349            CentralIdLookup::AUDIENCE_RAW,
350            IDBAccessObject::READ_LATEST
351        );
352        if ( !$centralId ) {
353            return false;
354        }
355
356        $dbw = $this->getPrimaryDatabase();
357        $dbw->newDeleteQueryBuilder()
358            ->deleteFrom( 'bot_passwords' )
359            ->where( [ 'bp_user' => $centralId ] )
360            ->caller( __METHOD__ )->execute();
361        return (bool)$dbw->affectedRows();
362    }
363
364}