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