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