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