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