Add rate limiter to Special:ConfirmEmail
[lhc/web/wiklou.git] / includes / specials / SpecialBotPasswords.php
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
24 use MediaWiki\Logger\LoggerFactory;
25 use MediaWiki\MediaWikiServices;
26
27 /**
28 * Let users manage bot passwords
29 *
30 * @ingroup SpecialPage
31 */
32 class SpecialBotPasswords extends FormSpecialPage {
33
34 /** @var int Central user ID */
35 private $userId = 0;
36
37 /** @var BotPassword|null Bot password being edited, if any */
38 private $botPassword = null;
39
40 /** @var string Operation being performed: create, update, delete */
41 private $operation = null;
42
43 /** @var string New password set, for communication between onSubmit() and onSuccess() */
44 private $password = null;
45
46 /** @var Psr\Log\LoggerInterface */
47 private $logger = null;
48
49 public function __construct() {
50 parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
51 $this->logger = LoggerFactory::getInstance( 'authentication' );
52 }
53
54 /**
55 * @return bool
56 */
57 public function isListed() {
58 return $this->getConfig()->get( 'EnableBotPasswords' );
59 }
60
61 protected function getLoginSecurityLevel() {
62 return $this->getName();
63 }
64
65 /**
66 * Main execution point
67 * @param string|null $par
68 */
69 function execute( $par ) {
70 $this->getOutput()->disallowUserJs();
71 $this->requireLogin();
72 $this->addHelpLink( 'Manual:Bot_passwords' );
73
74 $par = trim( $par );
75 if ( strlen( $par ) === 0 ) {
76 $par = null;
77 } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
78 throw new ErrorPageError( 'botpasswords', 'botpasswords-bad-appid',
79 [ htmlspecialchars( $par ) ] );
80 }
81
82 parent::execute( $par );
83 }
84
85 protected function checkExecutePermissions( User $user ) {
86 parent::checkExecutePermissions( $user );
87
88 if ( !$this->getConfig()->get( 'EnableBotPasswords' ) ) {
89 throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
90 }
91
92 $this->userId = CentralIdLookup::factory()->centralIdFromLocalUser( $this->getUser() );
93 if ( !$this->userId ) {
94 throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
95 }
96 }
97
98 protected function getFormFields() {
99 $fields = [];
100
101 if ( $this->par !== null ) {
102 $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
103 if ( !$this->botPassword ) {
104 $this->botPassword = BotPassword::newUnsaved( [
105 'centralId' => $this->userId,
106 'appId' => $this->par,
107 ] );
108 }
109
110 $sep = BotPassword::getSeparator();
111 $fields[] = [
112 'type' => 'info',
113 'label-message' => 'username',
114 'default' => $this->getUser()->getName() . $sep . $this->par
115 ];
116
117 if ( $this->botPassword->isSaved() ) {
118 $fields['resetPassword'] = [
119 'type' => 'check',
120 'label-message' => 'botpasswords-label-resetpassword',
121 ];
122 if ( $this->botPassword->isInvalid() ) {
123 $fields['resetPassword']['default'] = true;
124 }
125 }
126
127 $lang = $this->getLanguage();
128 $showGrants = MWGrants::getValidGrants();
129 $fields['grants'] = [
130 'type' => 'checkmatrix',
131 'label-message' => 'botpasswords-label-grants',
132 'help-message' => 'botpasswords-help-grants',
133 'columns' => [
134 $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
135 ],
136 'rows' => array_combine(
137 array_map( 'MWGrants::getGrantsLink', $showGrants ),
138 $showGrants
139 ),
140 'default' => array_map(
141 function ( $g ) {
142 return "grant-$g";
143 },
144 $this->botPassword->getGrants()
145 ),
146 'tooltips' => array_combine(
147 array_map( 'MWGrants::getGrantsLink', $showGrants ),
148 array_map(
149 function ( $rights ) use ( $lang ) {
150 return $lang->semicolonList( array_map( 'User::getRightDescription', $rights ) );
151 },
152 array_intersect_key( MWGrants::getRightsByGrant(), array_flip( $showGrants ) )
153 )
154 ),
155 'force-options-on' => array_map(
156 function ( $g ) {
157 return "grant-$g";
158 },
159 MWGrants::getHiddenGrants()
160 ),
161 ];
162
163 $fields['restrictions'] = [
164 'class' => HTMLRestrictionsField::class,
165 'required' => true,
166 'default' => $this->botPassword->getRestrictions(),
167 ];
168
169 } else {
170 $linkRenderer = $this->getLinkRenderer();
171 $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
172
173 $dbr = BotPassword::getDB( DB_REPLICA );
174 $res = $dbr->select(
175 'bot_passwords',
176 [ 'bp_app_id', 'bp_password' ],
177 [ 'bp_user' => $this->userId ],
178 __METHOD__
179 );
180 foreach ( $res as $row ) {
181 try {
182 $password = $passwordFactory->newFromCiphertext( $row->bp_password );
183 $passwordInvalid = $password instanceof InvalidPassword;
184 unset( $password );
185 } catch ( PasswordError $ex ) {
186 $passwordInvalid = true;
187 }
188
189 $text = $linkRenderer->makeKnownLink(
190 $this->getPageTitle( $row->bp_app_id ),
191 $row->bp_app_id
192 );
193 if ( $passwordInvalid ) {
194 $text .= $this->msg( 'word-separator' )->escaped()
195 . $this->msg( 'botpasswords-label-needsreset' )->parse();
196 }
197
198 $fields[] = [
199 'section' => 'existing',
200 'type' => 'info',
201 'raw' => true,
202 'default' => $text,
203 ];
204 }
205
206 $fields['appId'] = [
207 'section' => 'createnew',
208 'type' => 'textwithbutton',
209 'label-message' => 'botpasswords-label-appid',
210 'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
211 'buttonflags' => [ 'progressive', 'primary' ],
212 'required' => true,
213 'size' => BotPassword::APPID_MAXLENGTH,
214 'maxlength' => BotPassword::APPID_MAXLENGTH,
215 'validation-callback' => function ( $v ) {
216 $v = trim( $v );
217 return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
218 },
219 ];
220
221 $fields[] = [
222 'type' => 'hidden',
223 'default' => 'new',
224 'name' => 'op',
225 ];
226 }
227
228 return $fields;
229 }
230
231 protected function alterForm( HTMLForm $form ) {
232 $form->setId( 'mw-botpasswords-form' );
233 $form->setTableId( 'mw-botpasswords-table' );
234 $form->addPreText( $this->msg( 'botpasswords-summary' )->parseAsBlock() );
235 $form->suppressDefaultSubmit();
236
237 if ( $this->par !== null ) {
238 if ( $this->botPassword->isSaved() ) {
239 $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
240 $form->addButton( [
241 'name' => 'op',
242 'value' => 'update',
243 'label-message' => 'botpasswords-label-update',
244 'flags' => [ 'primary', 'progressive' ],
245 ] );
246 $form->addButton( [
247 'name' => 'op',
248 'value' => 'delete',
249 'label-message' => 'botpasswords-label-delete',
250 'flags' => [ 'destructive' ],
251 ] );
252 } else {
253 $form->setWrapperLegendMsg( 'botpasswords-createnew' );
254 $form->addButton( [
255 'name' => 'op',
256 'value' => 'create',
257 'label-message' => 'botpasswords-label-create',
258 'flags' => [ 'primary', 'progressive' ],
259 ] );
260 }
261
262 $form->addButton( [
263 'name' => 'op',
264 'value' => 'cancel',
265 'label-message' => 'botpasswords-label-cancel'
266 ] );
267 }
268 }
269
270 public function onSubmit( array $data ) {
271 $op = $this->getRequest()->getVal( 'op', '' );
272
273 switch ( $op ) {
274 case 'new':
275 $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
276 return false;
277
278 case 'create':
279 $this->operation = 'insert';
280 return $this->save( $data );
281
282 case 'update':
283 $this->operation = 'update';
284 return $this->save( $data );
285
286 case 'delete':
287 $this->operation = 'delete';
288 $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
289 if ( $bp ) {
290 $bp->delete();
291 $this->logger->info(
292 "Bot password {op} for {user}@{app_id}",
293 [
294 'app_id' => $this->par,
295 'user' => $this->getUser()->getName(),
296 'centralId' => $this->userId,
297 'op' => 'delete',
298 'client_ip' => $this->getRequest()->getIP()
299 ]
300 );
301 }
302 return Status::newGood();
303
304 case 'cancel':
305 $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
306 return false;
307 }
308
309 return false;
310 }
311
312 private function save( array $data ) {
313 $bp = BotPassword::newUnsaved( [
314 'centralId' => $this->userId,
315 'appId' => $this->par,
316 'restrictions' => $data['restrictions'],
317 'grants' => array_merge(
318 MWGrants::getHiddenGrants(),
319 preg_replace( '/^grant-/', '', $data['grants'] )
320 )
321 ] );
322
323 if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
324 $this->password = BotPassword::generatePassword( $this->getConfig() );
325 $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
326 $password = $passwordFactory->newFromPlaintext( $this->password );
327 } else {
328 $password = null;
329 }
330
331 if ( $bp->save( $this->operation, $password ) ) {
332 $this->logger->info(
333 "Bot password {op} for {user}@{app_id}",
334 [
335 'op' => $this->operation,
336 'user' => $this->getUser()->getName(),
337 'app_id' => $this->par,
338 'centralId' => $this->userId,
339 'restrictions' => $data['restrictions'],
340 'grants' => $bp->getGrants(),
341 'client_ip' => $this->getRequest()->getIP()
342 ]
343 );
344 return Status::newGood();
345 } else {
346 // Messages: botpasswords-insert-failed, botpasswords-update-failed
347 return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
348 }
349 }
350
351 public function onSuccess() {
352 $out = $this->getOutput();
353
354 $username = $this->getUser()->getName();
355 switch ( $this->operation ) {
356 case 'insert':
357 $out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() );
358 $out->addWikiMsg( 'botpasswords-created-body', $this->par, $username );
359 break;
360
361 case 'update':
362 $out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() );
363 $out->addWikiMsg( 'botpasswords-updated-body', $this->par, $username );
364 break;
365
366 case 'delete':
367 $out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() );
368 $out->addWikiMsg( 'botpasswords-deleted-body', $this->par, $username );
369 $this->password = null;
370 break;
371 }
372
373 if ( $this->password !== null ) {
374 $sep = BotPassword::getSeparator();
375 $out->addWikiMsg(
376 'botpasswords-newpassword',
377 htmlspecialchars( $username . $sep . $this->par ),
378 htmlspecialchars( $this->password ),
379 htmlspecialchars( $username ),
380 htmlspecialchars( $this->par . $sep . $this->password )
381 );
382 $this->password = null;
383 }
384
385 $out->addReturnTo( $this->getPageTitle() );
386 }
387
388 protected function getGroupName() {
389 return 'users';
390 }
391
392 protected function getDisplayFormat() {
393 return 'ooui';
394 }
395 }