Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 161
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 / 161
0.00% covered (danger)
0.00%
0 / 12
1560
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 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 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 FormatJson;
26use IDBAccessObject;
27use MediaWiki\Config\ServiceOptions;
28use MediaWiki\MainConfigNames;
29use MediaWiki\User\CentralId\CentralIdLookup;
30use MWCryptRand;
31use MWRestrictions;
32use Password;
33use PasswordFactory;
34use StatusValue;
35use Wikimedia\Rdbms\IConnectionProvider;
36use Wikimedia\Rdbms\IDatabase;
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        if ( $password === null ) {
226            $password = PasswordFactory::newInvalidPassword();
227        }
228
229        $dbw = $this->getPrimaryDatabase();
230        $dbw->newInsertQueryBuilder()
231            ->insertInto( 'bot_passwords' )
232            ->ignore()
233            ->row( [
234                'bp_user' => $botPassword->getUserCentralId(),
235                'bp_app_id' => $botPassword->getAppId(),
236                'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
237                'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
238                'bp_grants' => FormatJson::encode( $botPassword->getGrants() ),
239                'bp_password' => $password->toString(),
240            ] )
241            ->caller( __METHOD__ )->execute();
242
243        $ok = (bool)$dbw->affectedRows();
244        if ( $ok ) {
245            $token = $dbw->newSelectQueryBuilder()
246                ->select( 'bp_token' )
247                ->from( 'bot_passwords' )
248                ->where( [ 'bp_user' => $botPassword->getUserCentralId(), 'bp_app_id' => $botPassword->getAppId(), ] )
249                ->caller( __METHOD__ )->fetchField();
250            return StatusValue::newGood( $token );
251        }
252        return StatusValue::newFatal( 'botpasswords-insert-failed', $botPassword->getAppId() );
253    }
254
255    /**
256     * Update an existing BotPassword in the database
257     *
258     * @internal
259     *
260     * @param BotPassword $botPassword
261     * @param Password|null $password Use null for an invalid password
262     * @return StatusValue if everything worked, the value of the StatusValue is the new token
263     */
264    public function updateBotPassword(
265        BotPassword $botPassword,
266        Password $password = null
267    ): StatusValue {
268        $res = $this->validateBotPassword( $botPassword );
269        if ( !$res->isGood() ) {
270            return $res;
271        }
272
273        $conds = [
274            'bp_user' => $botPassword->getUserCentralId(),
275            'bp_app_id' => $botPassword->getAppId(),
276        ];
277        $fields = [
278            'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
279            'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
280            'bp_grants' => FormatJson::encode( $botPassword->getGrants() ),
281        ];
282        if ( $password !== null ) {
283            $fields['bp_password'] = $password->toString();
284        }
285
286        $dbw = $this->getPrimaryDatabase();
287        $dbw->newUpdateQueryBuilder()
288            ->update( 'bot_passwords' )
289            ->set( $fields )
290            ->where( $conds )
291            ->caller( __METHOD__ )->execute();
292
293        $ok = (bool)$dbw->affectedRows();
294        if ( $ok ) {
295            $token = $dbw->newSelectQueryBuilder()
296                ->select( 'bp_token' )
297                ->from( 'bot_passwords' )
298                ->where( $conds )
299                ->caller( __METHOD__ )->fetchField();
300            return StatusValue::newGood( $token );
301        }
302        return StatusValue::newFatal( 'botpasswords-update-failed', $botPassword->getAppId() );
303    }
304
305    /**
306     * Check if a BotPassword is valid to save in the database (either inserting a new
307     * one or updating an existing one) based on the size of the restrictions and grants
308     *
309     * @param BotPassword $botPassword
310     * @return StatusValue
311     */
312    private function validateBotPassword( BotPassword $botPassword ): StatusValue {
313        $res = StatusValue::newGood();
314
315        $restrictions = $botPassword->getRestrictions()->toJson();
316        if ( strlen( $restrictions ) > BotPassword::RESTRICTIONS_MAXLENGTH ) {
317            $res->fatal( 'botpasswords-toolong-restrictions' );
318        }
319
320        $grants = FormatJson::encode( $botPassword->getGrants() );
321        if ( strlen( $grants ) > BotPassword::GRANTS_MAXLENGTH ) {
322            $res->fatal( 'botpasswords-toolong-grants' );
323        }
324
325        return $res;
326    }
327
328    /**
329     * Delete an existing BotPassword in the database
330     *
331     * @param BotPassword $botPassword
332     * @return bool
333     */
334    public function deleteBotPassword( BotPassword $botPassword ): bool {
335        $dbw = $this->getPrimaryDatabase();
336        $dbw->newDeleteQueryBuilder()
337            ->deleteFrom( 'bot_passwords' )
338            ->where( [ 'bp_user' => $botPassword->getUserCentralId() ] )
339            ->andWhere( [ 'bp_app_id' => $botPassword->getAppId() ] )
340            ->caller( __METHOD__ )->execute();
341
342        return (bool)$dbw->affectedRows();
343    }
344
345    /**
346     * Invalidate all passwords for a user, by name
347     * @param string $username
348     * @return bool Whether any passwords were invalidated
349     */
350    public function invalidateUserPasswords( string $username ): bool {
351        if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
352            return false;
353        }
354
355        $centralId = $this->centralIdLookup->centralIdFromName(
356            $username,
357            CentralIdLookup::AUDIENCE_RAW,
358            IDBAccessObject::READ_LATEST
359        );
360        if ( !$centralId ) {
361            return false;
362        }
363
364        $dbw = $this->getPrimaryDatabase();
365        $dbw->newUpdateQueryBuilder()
366            ->update( 'bot_passwords' )
367            ->set( [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ] )
368            ->where( [ 'bp_user' => $centralId ] )
369            ->caller( __METHOD__ )->execute();
370        return (bool)$dbw->affectedRows();
371    }
372
373    /**
374     * Remove all passwords for a user, by name
375     * @param string $username
376     * @return bool Whether any passwords were removed
377     */
378    public function removeUserPasswords( string $username ): bool {
379        if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
380            return false;
381        }
382
383        $centralId = $this->centralIdLookup->centralIdFromName(
384            $username,
385            CentralIdLookup::AUDIENCE_RAW,
386            IDBAccessObject::READ_LATEST
387        );
388        if ( !$centralId ) {
389            return false;
390        }
391
392        $dbw = $this->getPrimaryDatabase();
393        $dbw->newDeleteQueryBuilder()
394            ->deleteFrom( 'bot_passwords' )
395            ->where( [ 'bp_user' => $centralId ] )
396            ->caller( __METHOD__ )->execute();
397        return (bool)$dbw->affectedRows();
398    }
399
400}