Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 263
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialBotPasswords
0.00% covered (danger)
0.00%
0 / 262
0.00% covered (danger)
0.00%
0 / 12
1722
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 isListed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLoginSecurityLevel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 checkExecutePermissions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getFormFields
0.00% covered (danger)
0.00%
0 / 114
0.00% covered (danger)
0.00%
0 / 1
90
 alterForm
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 onSubmit
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 save
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 onSuccess
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDisplayFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Implements Special:BotPasswords
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 * @ingroup SpecialPage
22 */
23
24namespace MediaWiki\Specials;
25
26use ErrorPageError;
27use HTMLRestrictionsField;
28use InvalidPassword;
29use MediaWiki\Auth\AuthManager;
30use MediaWiki\Html\Html;
31use MediaWiki\HTMLForm\HTMLForm;
32use MediaWiki\Logger\LoggerFactory;
33use MediaWiki\MainConfigNames;
34use MediaWiki\Permissions\GrantsInfo;
35use MediaWiki\Permissions\GrantsLocalization;
36use MediaWiki\SpecialPage\FormSpecialPage;
37use MediaWiki\Status\Status;
38use MediaWiki\User\BotPassword;
39use MediaWiki\User\CentralId\CentralIdLookup;
40use MediaWiki\User\User;
41use PasswordError;
42use PasswordFactory;
43use Psr\Log\LoggerInterface;
44
45/**
46 * Let users manage bot passwords
47 *
48 * @ingroup SpecialPage
49 */
50class SpecialBotPasswords extends FormSpecialPage {
51
52    /** @var int Central user ID */
53    private $userId = 0;
54
55    /** @var BotPassword|null Bot password being edited, if any */
56    private $botPassword = null;
57
58    /** @var string|null Operation being performed: create, update, delete */
59    private $operation = null;
60
61    /** @var string|null New password set, for communication between onSubmit() and onSuccess() */
62    private $password = null;
63
64    private LoggerInterface $logger;
65    private PasswordFactory $passwordFactory;
66    private CentralIdLookup $centralIdLookup;
67    private GrantsInfo $grantsInfo;
68    private GrantsLocalization $grantsLocalization;
69
70    /**
71     * @param PasswordFactory $passwordFactory
72     * @param AuthManager $authManager
73     * @param CentralIdLookup $centralIdLookup
74     * @param GrantsInfo $grantsInfo
75     * @param GrantsLocalization $grantsLocalization
76     */
77    public function __construct(
78        PasswordFactory $passwordFactory,
79        AuthManager $authManager,
80        CentralIdLookup $centralIdLookup,
81        GrantsInfo $grantsInfo,
82        GrantsLocalization $grantsLocalization
83    ) {
84        parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
85        $this->logger = LoggerFactory::getInstance( 'authentication' );
86        $this->passwordFactory = $passwordFactory;
87        $this->centralIdLookup = $centralIdLookup;
88        $this->setAuthManager( $authManager );
89        $this->grantsInfo = $grantsInfo;
90        $this->grantsLocalization = $grantsLocalization;
91    }
92
93    /**
94     * @return bool
95     */
96    public function isListed() {
97        return $this->getConfig()->get( MainConfigNames::EnableBotPasswords );
98    }
99
100    protected function getLoginSecurityLevel() {
101        return $this->getName();
102    }
103
104    /**
105     * Main execution point
106     * @param string|null $par
107     */
108    public function execute( $par ) {
109        $this->requireNamedUser();
110        $this->getOutput()->disallowUserJs();
111        $this->getOutput()->addModuleStyles( 'mediawiki.special' );
112        $this->addHelpLink( 'Manual:Bot_passwords' );
113
114        if ( $par !== null ) {
115            $par = trim( $par );
116            if ( $par === '' ) {
117                $par = null;
118            } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
119                throw new ErrorPageError(
120                    'botpasswords', 'botpasswords-bad-appid', [ htmlspecialchars( $par ) ]
121                );
122            }
123        }
124
125        parent::execute( $par );
126    }
127
128    protected function checkExecutePermissions( User $user ) {
129        parent::checkExecutePermissions( $user );
130
131        if ( !$this->getConfig()->get( MainConfigNames::EnableBotPasswords ) ) {
132            throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
133        }
134
135        $this->userId = $this->centralIdLookup->centralIdFromLocalUser( $this->getUser() );
136        if ( !$this->userId ) {
137            throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
138        }
139    }
140
141    protected function getFormFields() {
142        $fields = [];
143
144        if ( $this->par !== null ) {
145            $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
146            if ( !$this->botPassword ) {
147                $this->botPassword = BotPassword::newUnsaved( [
148                    'centralId' => $this->userId,
149                    'appId' => $this->par,
150                ] );
151            }
152
153            $sep = BotPassword::getSeparator();
154            $fields[] = [
155                'type' => 'info',
156                'label-message' => 'username',
157                'default' => $this->getUser()->getName() . $sep . $this->par
158            ];
159
160            if ( $this->botPassword->isSaved() ) {
161                $fields['resetPassword'] = [
162                    'type' => 'check',
163                    'label-message' => 'botpasswords-label-resetpassword',
164                ];
165                if ( $this->botPassword->isInvalid() ) {
166                    $fields['resetPassword']['default'] = true;
167                }
168            }
169
170            $showGrants = $this->grantsInfo->getValidGrants();
171            $grantNames = $this->grantsLocalization->getGrantDescriptionsWithClasses(
172                $showGrants, $this->getLanguage() );
173
174            $fields[] = [
175                'type' => 'info',
176                'default' => '',
177                'help-message' => 'botpasswords-help-grants',
178            ];
179            $fields['grants'] = [
180                'type' => 'checkmatrix',
181                'label-message' => 'botpasswords-label-grants',
182                'columns' => [
183                    $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
184                ],
185                'rows' => array_combine(
186                    $grantNames,
187                    $showGrants
188                ),
189                'default' => array_map(
190                    static function ( $g ) {
191                        return "grant-$g";
192                    },
193                    $this->botPassword->getGrants()
194                ),
195                'tooltips-html' => array_combine(
196                    $grantNames,
197                    array_map(
198                        fn ( $rights ) => Html::rawElement( 'ul', [], implode( '', array_map(
199                            fn ( $right ) => Html::rawElement( 'li', [], $this->msg( "right-$right" )->parse() ),
200                            $rights
201                        ) ) ),
202                        array_intersect_key( $this->grantsInfo->getRightsByGrant(),
203                            array_fill_keys( $showGrants, true ) )
204                    )
205                ),
206                'force-options-on' => array_map(
207                    static function ( $g ) {
208                        return "grant-$g";
209                    },
210                    $this->grantsInfo->getHiddenGrants()
211                ),
212            ];
213
214            $fields['restrictions'] = [
215                'class' => HTMLRestrictionsField::class,
216                'required' => true,
217                'default' => $this->botPassword->getRestrictions(),
218            ];
219
220        } else {
221            $linkRenderer = $this->getLinkRenderer();
222
223            $dbr = BotPassword::getReplicaDatabase();
224            $res = $dbr->newSelectQueryBuilder()
225                ->select( [ 'bp_app_id', 'bp_password' ] )
226                ->from( 'bot_passwords' )
227                ->where( [ 'bp_user' => $this->userId ] )
228                ->caller( __METHOD__ )->fetchResultSet();
229            foreach ( $res as $row ) {
230                try {
231                    $password = $this->passwordFactory->newFromCiphertext( $row->bp_password );
232                    $passwordInvalid = $password instanceof InvalidPassword;
233                    unset( $password );
234                } catch ( PasswordError $ex ) {
235                    $passwordInvalid = true;
236                }
237
238                $text = $linkRenderer->makeKnownLink(
239                    $this->getPageTitle( $row->bp_app_id ),
240                    $row->bp_app_id
241                );
242                if ( $passwordInvalid ) {
243                    $text .= $this->msg( 'word-separator' )->escaped()
244                        . $this->msg( 'botpasswords-label-needsreset' )->parse();
245                }
246
247                $fields[] = [
248                    'section' => 'existing',
249                    'type' => 'info',
250                    'raw' => true,
251                    'default' => $text,
252                ];
253            }
254
255            $fields['appId'] = [
256                'section' => 'createnew',
257                'type' => 'textwithbutton',
258                'label-message' => 'botpasswords-label-appid',
259                'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
260                'buttonflags' => [ 'progressive', 'primary' ],
261                'required' => true,
262                'size' => BotPassword::APPID_MAXLENGTH,
263                'maxlength' => BotPassword::APPID_MAXLENGTH,
264                'validation-callback' => static function ( $v ) {
265                    $v = trim( $v );
266                    return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
267                },
268            ];
269
270            $fields[] = [
271                'type' => 'hidden',
272                'default' => 'new',
273                'name' => 'op',
274            ];
275        }
276
277        return $fields;
278    }
279
280    protected function alterForm( HTMLForm $form ) {
281        $form->setId( 'mw-botpasswords-form' );
282        $form->setTableId( 'mw-botpasswords-table' );
283        $form->suppressDefaultSubmit();
284
285        if ( $this->par !== null ) {
286            if ( $this->botPassword->isSaved() ) {
287                $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
288                $form->addButton( [
289                    'name' => 'op',
290                    'value' => 'update',
291                    'label-message' => 'botpasswords-label-update',
292                    'flags' => [ 'primary', 'progressive' ],
293                ] );
294                $form->addButton( [
295                    'name' => 'op',
296                    'value' => 'delete',
297                    'label-message' => 'botpasswords-label-delete',
298                    'flags' => [ 'destructive' ],
299                ] );
300            } else {
301                $form->setWrapperLegendMsg( 'botpasswords-createnew' );
302                $form->addButton( [
303                    'name' => 'op',
304                    'value' => 'create',
305                    'label-message' => 'botpasswords-label-create',
306                    'flags' => [ 'primary', 'progressive' ],
307                ] );
308            }
309
310            $form->addButton( [
311                'name' => 'op',
312                'value' => 'cancel',
313                'label-message' => 'botpasswords-label-cancel'
314            ] );
315        }
316    }
317
318    public function onSubmit( array $data ) {
319        $op = $this->getRequest()->getVal( 'op', '' );
320
321        switch ( $op ) {
322            case 'new':
323                $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
324                return false;
325
326            case 'create':
327                $this->operation = 'insert';
328                return $this->save( $data );
329
330            case 'update':
331                $this->operation = 'update';
332                return $this->save( $data );
333
334            case 'delete':
335                $this->operation = 'delete';
336                $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
337                if ( $bp ) {
338                    $bp->delete();
339                    $this->logger->info(
340                        "Bot password {op} for {user}@{app_id}",
341                        [
342                            'app_id' => $this->par,
343                            'user' => $this->getUser()->getName(),
344                            'centralId' => $this->userId,
345                            'op' => 'delete',
346                            'client_ip' => $this->getRequest()->getIP()
347                        ]
348                    );
349                }
350                return Status::newGood();
351
352            case 'cancel':
353                $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
354                return false;
355        }
356
357        return false;
358    }
359
360    private function save( array $data ) {
361        $bp = BotPassword::newUnsaved( [
362            'centralId' => $this->userId,
363            'appId' => $this->par,
364            'restrictions' => $data['restrictions'],
365            'grants' => array_merge(
366                $this->grantsInfo->getHiddenGrants(),
367                // @phan-suppress-next-next-line PhanTypeMismatchArgumentInternal See phan issue #3163,
368                // it's probably failing to infer the type of $data['grants']
369                preg_replace( '/^grant-/', '', $data['grants'] )
370            )
371        ] );
372
373        if ( $bp === null ) {
374            // Messages: botpasswords-insert-failed, botpasswords-update-failed
375            return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
376        }
377
378        if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
379            $this->password = BotPassword::generatePassword( $this->getConfig() );
380            $password = $this->passwordFactory->newFromPlaintext( $this->password );
381        } else {
382            $password = null;
383        }
384
385        $res = $bp->save( $this->operation, $password );
386
387        $success = $res->isGood();
388
389        $this->logger->info(
390            'Bot password {op} for {user}@{app_id} ' . ( $success ? 'succeeded' : 'failed' ),
391            [
392                'op' => $this->operation,
393                'user' => $this->getUser()->getName(),
394                'app_id' => $this->par,
395                'centralId' => $this->userId,
396                'restrictions' => $data['restrictions'],
397                'grants' => $bp->getGrants(),
398                'client_ip' => $this->getRequest()->getIP(),
399                'success' => $success,
400            ]
401        );
402
403        return $res;
404    }
405
406    public function onSuccess() {
407        $out = $this->getOutput();
408
409        $username = $this->getUser()->getName();
410        switch ( $this->operation ) {
411            case 'insert':
412                $out->setPageTitleMsg( $this->msg( 'botpasswords-created-title' ) );
413                $out->addWikiMsg( 'botpasswords-created-body', $this->par, $username );
414                break;
415
416            case 'update':
417                $out->setPageTitleMsg( $this->msg( 'botpasswords-updated-title' ) );
418                $out->addWikiMsg( 'botpasswords-updated-body', $this->par, $username );
419                break;
420
421            case 'delete':
422                $out->setPageTitleMsg( $this->msg( 'botpasswords-deleted-title' ) );
423                $out->addWikiMsg( 'botpasswords-deleted-body', $this->par, $username );
424                $this->password = null;
425                break;
426        }
427
428        if ( $this->password !== null ) {
429            $sep = BotPassword::getSeparator();
430            $out->addWikiMsg(
431                'botpasswords-newpassword',
432                htmlspecialchars( $username . $sep . $this->par ),
433                htmlspecialchars( $this->password ),
434                htmlspecialchars( $username ),
435                htmlspecialchars( $this->par . $sep . $this->password )
436            );
437            $this->password = null;
438        }
439
440        $out->addReturnTo( $this->getPageTitle() );
441    }
442
443    protected function getGroupName() {
444        return 'login';
445    }
446
447    protected function getDisplayFormat() {
448        return 'ooui';
449    }
450}
451
452/** @deprecated class alias since 1.41 */
453class_alias( SpecialBotPasswords::class, 'SpecialBotPasswords' );