Merge "docs: Fix typo"
[mediawiki.git] / includes / specials / SpecialBotPasswords.php
blob96f0ef35ecf320d86c12f03db4fd45b21a811a4f
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 namespace MediaWiki\Specials;
23 use ErrorPageError;
24 use MediaWiki\Auth\AuthManager;
25 use MediaWiki\Html\Html;
26 use MediaWiki\HTMLForm\Field\HTMLRestrictionsField;
27 use MediaWiki\HTMLForm\HTMLForm;
28 use MediaWiki\Logger\LoggerFactory;
29 use MediaWiki\MainConfigNames;
30 use MediaWiki\Password\InvalidPassword;
31 use MediaWiki\Password\PasswordError;
32 use MediaWiki\Password\PasswordFactory;
33 use MediaWiki\Permissions\GrantsInfo;
34 use MediaWiki\Permissions\GrantsLocalization;
35 use MediaWiki\SpecialPage\FormSpecialPage;
36 use MediaWiki\Status\Status;
37 use MediaWiki\User\BotPassword;
38 use MediaWiki\User\CentralId\CentralIdLookup;
39 use MediaWiki\User\User;
40 use Psr\Log\LoggerInterface;
42 /**
43 * Let users manage bot passwords
45 * @ingroup SpecialPage
47 class SpecialBotPasswords extends FormSpecialPage {
49 /** @var int Central user ID */
50 private $userId = 0;
52 /** @var BotPassword|null Bot password being edited, if any */
53 private $botPassword = null;
55 /** @var string|null Operation being performed: create, update, delete */
56 private $operation = null;
58 /** @var string|null New password set, for communication between onSubmit() and onSuccess() */
59 private $password = null;
61 private LoggerInterface $logger;
62 private PasswordFactory $passwordFactory;
63 private CentralIdLookup $centralIdLookup;
64 private GrantsInfo $grantsInfo;
65 private GrantsLocalization $grantsLocalization;
67 /**
68 * @param PasswordFactory $passwordFactory
69 * @param AuthManager $authManager
70 * @param CentralIdLookup $centralIdLookup
71 * @param GrantsInfo $grantsInfo
72 * @param GrantsLocalization $grantsLocalization
74 public function __construct(
75 PasswordFactory $passwordFactory,
76 AuthManager $authManager,
77 CentralIdLookup $centralIdLookup,
78 GrantsInfo $grantsInfo,
79 GrantsLocalization $grantsLocalization
80 ) {
81 parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
82 $this->logger = LoggerFactory::getInstance( 'authentication' );
83 $this->passwordFactory = $passwordFactory;
84 $this->centralIdLookup = $centralIdLookup;
85 $this->setAuthManager( $authManager );
86 $this->grantsInfo = $grantsInfo;
87 $this->grantsLocalization = $grantsLocalization;
90 /**
91 * @return bool
93 public function isListed() {
94 return $this->getConfig()->get( MainConfigNames::EnableBotPasswords );
97 protected function getLoginSecurityLevel() {
98 return $this->getName();
102 * Main execution point
103 * @param string|null $par
105 public function execute( $par ) {
106 $this->requireNamedUser();
107 $this->getOutput()->disallowUserJs();
108 $this->getOutput()->addModuleStyles( 'mediawiki.special' );
109 $this->addHelpLink( 'Manual:Bot_passwords' );
111 if ( $par !== null ) {
112 $par = trim( $par );
113 if ( $par === '' ) {
114 $par = null;
115 } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
116 throw new ErrorPageError(
117 'botpasswords', 'botpasswords-bad-appid', [ htmlspecialchars( $par ) ]
122 parent::execute( $par );
125 protected function checkExecutePermissions( User $user ) {
126 parent::checkExecutePermissions( $user );
128 if ( !$this->getConfig()->get( MainConfigNames::EnableBotPasswords ) ) {
129 throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
132 $this->userId = $this->centralIdLookup->centralIdFromLocalUser( $this->getUser() );
133 if ( !$this->userId ) {
134 throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
138 protected function getFormFields() {
139 $fields = [];
141 if ( $this->par !== null ) {
142 $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
143 if ( !$this->botPassword ) {
144 $this->botPassword = BotPassword::newUnsaved( [
145 'centralId' => $this->userId,
146 'appId' => $this->par,
147 ] );
150 $sep = BotPassword::getSeparator();
151 $fields[] = [
152 'type' => 'info',
153 'label-message' => 'username',
154 'default' => $this->getUser()->getName() . $sep . $this->par
157 if ( $this->botPassword->isSaved() ) {
158 $fields['resetPassword'] = [
159 'type' => 'check',
160 'label-message' => 'botpasswords-label-resetpassword',
162 if ( $this->botPassword->isInvalid() ) {
163 $fields['resetPassword']['default'] = true;
167 $showGrants = $this->grantsInfo->getValidGrants();
168 $grantNames = $this->grantsLocalization->getGrantDescriptionsWithClasses(
169 $showGrants, $this->getLanguage() );
171 $fields[] = [
172 'type' => 'info',
173 'default' => '',
174 'help-message' => 'botpasswords-help-grants',
176 $fields['grants'] = [
177 'type' => 'checkmatrix',
178 'label-message' => 'botpasswords-label-grants',
179 'columns' => [
180 $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
182 'rows' => array_combine(
183 $grantNames,
184 $showGrants
186 'default' => array_map(
187 static function ( $g ) {
188 return "grant-$g";
190 $this->botPassword->getGrants()
192 'tooltips-html' => array_combine(
193 $grantNames,
194 array_map(
195 fn ( $rights ) => Html::rawElement( 'ul', [], implode( '', array_map(
196 fn ( $right ) => Html::rawElement( 'li', [], $this->msg( "right-$right" )->parse() ),
197 $rights
198 ) ) ),
199 array_intersect_key( $this->grantsInfo->getRightsByGrant(),
200 array_fill_keys( $showGrants, true ) )
203 'force-options-on' => array_map(
204 static function ( $g ) {
205 return "grant-$g";
207 $this->grantsInfo->getHiddenGrants()
211 $fields['restrictions'] = [
212 'class' => HTMLRestrictionsField::class,
213 'required' => true,
214 'default' => $this->botPassword->getRestrictions(),
217 } else {
218 $linkRenderer = $this->getLinkRenderer();
220 $dbr = BotPassword::getReplicaDatabase();
221 $res = $dbr->newSelectQueryBuilder()
222 ->select( [ 'bp_app_id', 'bp_password' ] )
223 ->from( 'bot_passwords' )
224 ->where( [ 'bp_user' => $this->userId ] )
225 ->caller( __METHOD__ )->fetchResultSet();
226 foreach ( $res as $row ) {
227 try {
228 $password = $this->passwordFactory->newFromCiphertext( $row->bp_password );
229 $passwordInvalid = $password instanceof InvalidPassword;
230 unset( $password );
231 } catch ( PasswordError $ex ) {
232 $passwordInvalid = true;
235 $text = $linkRenderer->makeKnownLink(
236 $this->getPageTitle( $row->bp_app_id ),
237 $row->bp_app_id
239 if ( $passwordInvalid ) {
240 $text .= $this->msg( 'word-separator' )->escaped()
241 . $this->msg( 'botpasswords-label-needsreset' )->parse();
244 $fields[] = [
245 'section' => 'existing',
246 'type' => 'info',
247 'raw' => true,
248 'default' => $text,
252 $fields['appId'] = [
253 'section' => 'createnew',
254 'type' => 'textwithbutton',
255 'label-message' => 'botpasswords-label-appid',
256 'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
257 'buttonflags' => [ 'progressive', 'primary' ],
258 'required' => true,
259 'size' => BotPassword::APPID_MAXLENGTH,
260 'maxlength' => BotPassword::APPID_MAXLENGTH,
261 'validation-callback' => static function ( $v ) {
262 $v = trim( $v );
263 return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
267 $fields[] = [
268 'type' => 'hidden',
269 'default' => 'new',
270 'name' => 'op',
274 return $fields;
277 protected function alterForm( HTMLForm $form ) {
278 $form->setId( 'mw-botpasswords-form' );
279 $form->setTableId( 'mw-botpasswords-table' );
280 $form->suppressDefaultSubmit();
282 if ( $this->par !== null ) {
283 if ( $this->botPassword->isSaved() ) {
284 $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
285 $form->addButton( [
286 'name' => 'op',
287 'value' => 'update',
288 'label-message' => 'botpasswords-label-update',
289 'flags' => [ 'primary', 'progressive' ],
290 ] );
291 $form->addButton( [
292 'name' => 'op',
293 'value' => 'delete',
294 'label-message' => 'botpasswords-label-delete',
295 'flags' => [ 'destructive' ],
296 ] );
297 } else {
298 $form->setWrapperLegendMsg( 'botpasswords-createnew' );
299 $form->addButton( [
300 'name' => 'op',
301 'value' => 'create',
302 'label-message' => 'botpasswords-label-create',
303 'flags' => [ 'primary', 'progressive' ],
304 ] );
307 $form->addButton( [
308 'name' => 'op',
309 'value' => 'cancel',
310 'label-message' => 'botpasswords-label-cancel'
311 ] );
315 public function onSubmit( array $data ) {
316 $op = $this->getRequest()->getVal( 'op', '' );
318 switch ( $op ) {
319 case 'new':
320 $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
321 return false;
323 case 'create':
324 $this->operation = 'insert';
325 return $this->save( $data );
327 case 'update':
328 $this->operation = 'update';
329 return $this->save( $data );
331 case 'delete':
332 $this->operation = 'delete';
333 $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
334 if ( $bp ) {
335 $bp->delete();
336 $this->logger->info(
337 "Bot password {op} for {user}@{app_id}",
339 'app_id' => $this->par,
340 'user' => $this->getUser()->getName(),
341 'centralId' => $this->userId,
342 'op' => 'delete',
343 'client_ip' => $this->getRequest()->getIP()
347 return Status::newGood();
349 case 'cancel':
350 $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
351 return false;
354 return false;
357 private function save( array $data ) {
358 $bp = BotPassword::newUnsaved( [
359 'centralId' => $this->userId,
360 'appId' => $this->par,
361 'restrictions' => $data['restrictions'],
362 'grants' => array_merge(
363 $this->grantsInfo->getHiddenGrants(),
364 // @phan-suppress-next-next-line PhanTypeMismatchArgumentInternal See phan issue #3163,
365 // it's probably failing to infer the type of $data['grants']
366 preg_replace( '/^grant-/', '', $data['grants'] )
368 ] );
370 if ( $bp === null ) {
371 // Messages: botpasswords-insert-failed, botpasswords-update-failed
372 return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
375 if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
376 $this->password = BotPassword::generatePassword( $this->getConfig() );
377 $password = $this->passwordFactory->newFromPlaintext( $this->password );
378 } else {
379 $password = null;
382 $res = $bp->save( $this->operation, $password );
384 $success = $res->isGood();
386 $this->logger->info(
387 'Bot password {op} for {user}@{app_id} ' . ( $success ? 'succeeded' : 'failed' ),
389 'op' => $this->operation,
390 'user' => $this->getUser()->getName(),
391 'app_id' => $this->par,
392 'centralId' => $this->userId,
393 'restrictions' => $data['restrictions'],
394 'grants' => $bp->getGrants(),
395 'client_ip' => $this->getRequest()->getIP(),
396 'success' => $success,
400 return $res;
403 public function onSuccess() {
404 $out = $this->getOutput();
406 $username = $this->getUser()->getName();
407 switch ( $this->operation ) {
408 case 'insert':
409 $out->setPageTitleMsg( $this->msg( 'botpasswords-created-title' ) );
410 $out->addWikiMsg( 'botpasswords-created-body', $this->par, $username );
411 break;
413 case 'update':
414 $out->setPageTitleMsg( $this->msg( 'botpasswords-updated-title' ) );
415 $out->addWikiMsg( 'botpasswords-updated-body', $this->par, $username );
416 break;
418 case 'delete':
419 $out->setPageTitleMsg( $this->msg( 'botpasswords-deleted-title' ) );
420 $out->addWikiMsg( 'botpasswords-deleted-body', $this->par, $username );
421 $this->password = null;
422 break;
425 if ( $this->password !== null ) {
426 $sep = BotPassword::getSeparator();
427 $out->addWikiMsg(
428 'botpasswords-newpassword',
429 htmlspecialchars( $username . $sep . $this->par ),
430 htmlspecialchars( $this->password ),
431 htmlspecialchars( $username ),
432 htmlspecialchars( $this->par . $sep . $this->password )
434 $this->password = null;
437 $out->addReturnTo( $this->getPageTitle() );
440 protected function getGroupName() {
441 return 'login';
444 protected function getDisplayFormat() {
445 return 'ooui';
449 /** @deprecated class alias since 1.41 */
450 class_alias( SpecialBotPasswords::class, 'SpecialBotPasswords' );