MediaWiki  master
SpecialBotPasswords.php
Go to the documentation of this file.
1 <?php
24 namespace MediaWiki\Specials;
25 
26 use BotPassword;
27 use CentralIdLookup;
28 use ErrorPageError;
29 use FormSpecialPage;
30 use HTMLForm;
32 use InvalidPassword;
38 use PasswordError;
39 use PasswordFactory;
40 use Psr\Log\LoggerInterface;
41 use Status;
42 use User;
43 
50 
52  private $userId = 0;
53 
55  private $botPassword = null;
56 
58  private $operation = null;
59 
61  private $password = null;
62 
64  private $logger;
65 
67  private $passwordFactory;
68 
70  private $centralIdLookup;
71 
73  private $grantsInfo;
74 
76  private $grantsLocalization;
77 
85  public function __construct(
86  PasswordFactory $passwordFactory,
87  AuthManager $authManager,
88  CentralIdLookup $centralIdLookup,
89  GrantsInfo $grantsInfo,
90  GrantsLocalization $grantsLocalization
91  ) {
92  parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
93  $this->logger = LoggerFactory::getInstance( 'authentication' );
94  $this->passwordFactory = $passwordFactory;
95  $this->centralIdLookup = $centralIdLookup;
96  $this->setAuthManager( $authManager );
97  $this->grantsInfo = $grantsInfo;
98  $this->grantsLocalization = $grantsLocalization;
99  }
100 
104  public function isListed() {
105  return $this->getConfig()->get( MainConfigNames::EnableBotPasswords );
106  }
107 
108  protected function getLoginSecurityLevel() {
109  return $this->getName();
110  }
111 
116  public function execute( $par ) {
117  $this->getOutput()->disallowUserJs();
118  $this->requireNamedUser();
119  $this->addHelpLink( 'Manual:Bot_passwords' );
120 
121  if ( $par !== null ) {
122  $par = trim( $par );
123  if ( $par === '' ) {
124  $par = null;
125  } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
126  throw new ErrorPageError(
127  'botpasswords', 'botpasswords-bad-appid', [ htmlspecialchars( $par ) ]
128  );
129  }
130  }
131 
132  parent::execute( $par );
133  }
134 
135  protected function checkExecutePermissions( User $user ) {
136  parent::checkExecutePermissions( $user );
137 
138  if ( !$this->getConfig()->get( MainConfigNames::EnableBotPasswords ) ) {
139  throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
140  }
141 
142  $this->userId = $this->centralIdLookup->centralIdFromLocalUser( $this->getUser() );
143  if ( !$this->userId ) {
144  throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
145  }
146  }
147 
148  protected function getFormFields() {
149  $fields = [];
150 
151  if ( $this->par !== null ) {
152  $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
153  if ( !$this->botPassword ) {
154  $this->botPassword = BotPassword::newUnsaved( [
155  'centralId' => $this->userId,
156  'appId' => $this->par,
157  ] );
158  }
159 
160  $sep = BotPassword::getSeparator();
161  $fields[] = [
162  'type' => 'info',
163  'label-message' => 'username',
164  'default' => $this->getUser()->getName() . $sep . $this->par
165  ];
166 
167  if ( $this->botPassword->isSaved() ) {
168  $fields['resetPassword'] = [
169  'type' => 'check',
170  'label-message' => 'botpasswords-label-resetpassword',
171  ];
172  if ( $this->botPassword->isInvalid() ) {
173  $fields['resetPassword']['default'] = true;
174  }
175  }
176 
177  $lang = $this->getLanguage();
178  $showGrants = $this->grantsInfo->getValidGrants();
179  $grantLinks = array_map( [ $this->grantsLocalization, 'getGrantsLink' ], $showGrants );
180 
181  $fields['grants'] = [
182  'type' => 'checkmatrix',
183  'label-message' => 'botpasswords-label-grants',
184  'help-message' => 'botpasswords-help-grants',
185  'columns' => [
186  $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
187  ],
188  'rows' => array_combine(
189  $grantLinks,
190  $showGrants
191  ),
192  'default' => array_map(
193  static function ( $g ) {
194  return "grant-$g";
195  },
196  $this->botPassword->getGrants()
197  ),
198  'tooltips' => array_combine(
199  $grantLinks,
200  array_map(
201  static function ( $rights ) use ( $lang ) {
202  return $lang->semicolonList( array_map( [ User::class, 'getRightDescription' ], $rights ) );
203  },
204  array_intersect_key( $this->grantsInfo->getRightsByGrant(),
205  array_fill_keys( $showGrants, true ) )
206  )
207  ),
208  'force-options-on' => array_map(
209  static function ( $g ) {
210  return "grant-$g";
211  },
212  $this->grantsInfo->getHiddenGrants()
213  ),
214  ];
215 
216  $fields['restrictions'] = [
217  'class' => HTMLRestrictionsField::class,
218  'required' => true,
219  'default' => $this->botPassword->getRestrictions(),
220  ];
221 
222  } else {
223  $linkRenderer = $this->getLinkRenderer();
224 
226  $res = $dbr->select(
227  'bot_passwords',
228  [ 'bp_app_id', 'bp_password' ],
229  [ 'bp_user' => $this->userId ],
230  __METHOD__
231  );
232  foreach ( $res as $row ) {
233  try {
234  $password = $this->passwordFactory->newFromCiphertext( $row->bp_password );
235  $passwordInvalid = $password instanceof InvalidPassword;
236  unset( $password );
237  } catch ( PasswordError $ex ) {
238  $passwordInvalid = true;
239  }
240 
241  $text = $linkRenderer->makeKnownLink(
242  $this->getPageTitle( $row->bp_app_id ),
243  $row->bp_app_id
244  );
245  if ( $passwordInvalid ) {
246  $text .= $this->msg( 'word-separator' )->escaped()
247  . $this->msg( 'botpasswords-label-needsreset' )->parse();
248  }
249 
250  $fields[] = [
251  'section' => 'existing',
252  'type' => 'info',
253  'raw' => true,
254  'default' => $text,
255  ];
256  }
257 
258  $fields['appId'] = [
259  'section' => 'createnew',
260  'type' => 'textwithbutton',
261  'label-message' => 'botpasswords-label-appid',
262  'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
263  'buttonflags' => [ 'progressive', 'primary' ],
264  'required' => true,
266  'maxlength' => BotPassword::APPID_MAXLENGTH,
267  'validation-callback' => static function ( $v ) {
268  $v = trim( $v );
269  return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
270  },
271  ];
272 
273  $fields[] = [
274  'type' => 'hidden',
275  'default' => 'new',
276  'name' => 'op',
277  ];
278  }
279 
280  return $fields;
281  }
282 
283  protected function alterForm( HTMLForm $form ) {
284  $form->setId( 'mw-botpasswords-form' );
285  $form->setTableId( 'mw-botpasswords-table' );
286  $form->suppressDefaultSubmit();
287 
288  if ( $this->par !== null ) {
289  if ( $this->botPassword->isSaved() ) {
290  $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
291  $form->addButton( [
292  'name' => 'op',
293  'value' => 'update',
294  'label-message' => 'botpasswords-label-update',
295  'flags' => [ 'primary', 'progressive' ],
296  ] );
297  $form->addButton( [
298  'name' => 'op',
299  'value' => 'delete',
300  'label-message' => 'botpasswords-label-delete',
301  'flags' => [ 'destructive' ],
302  ] );
303  } else {
304  $form->setWrapperLegendMsg( 'botpasswords-createnew' );
305  $form->addButton( [
306  'name' => 'op',
307  'value' => 'create',
308  'label-message' => 'botpasswords-label-create',
309  'flags' => [ 'primary', 'progressive' ],
310  ] );
311  }
312 
313  $form->addButton( [
314  'name' => 'op',
315  'value' => 'cancel',
316  'label-message' => 'botpasswords-label-cancel'
317  ] );
318  }
319  }
320 
321  public function onSubmit( array $data ) {
322  $op = $this->getRequest()->getVal( 'op', '' );
323 
324  switch ( $op ) {
325  case 'new':
326  $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
327  return false;
328 
329  case 'create':
330  $this->operation = 'insert';
331  return $this->save( $data );
332 
333  case 'update':
334  $this->operation = 'update';
335  return $this->save( $data );
336 
337  case 'delete':
338  $this->operation = 'delete';
339  $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
340  if ( $bp ) {
341  $bp->delete();
342  $this->logger->info(
343  "Bot password {op} for {user}@{app_id}",
344  [
345  'app_id' => $this->par,
346  'user' => $this->getUser()->getName(),
347  'centralId' => $this->userId,
348  'op' => 'delete',
349  'client_ip' => $this->getRequest()->getIP()
350  ]
351  );
352  }
353  return Status::newGood();
354 
355  case 'cancel':
356  $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
357  return false;
358  }
359 
360  return false;
361  }
362 
363  private function save( array $data ) {
364  $bp = BotPassword::newUnsaved( [
365  'centralId' => $this->userId,
366  'appId' => $this->par,
367  'restrictions' => $data['restrictions'],
368  'grants' => array_merge(
369  $this->grantsInfo->getHiddenGrants(),
370  // @phan-suppress-next-next-line PhanTypeMismatchArgumentInternal See phan issue #3163,
371  // it's probably failing to infer the type of $data['grants']
372  preg_replace( '/^grant-/', '', $data['grants'] )
373  )
374  ] );
375 
376  if ( $bp === null ) {
377  // Messages: botpasswords-insert-failed, botpasswords-update-failed
378  return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
379  }
380 
381  if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
382  $this->password = BotPassword::generatePassword( $this->getConfig() );
383  $password = $this->passwordFactory->newFromPlaintext( $this->password );
384  } else {
385  $password = null;
386  }
387 
388  $res = $bp->save( $this->operation, $password );
389 
390  $success = $res->isGood();
391 
392  $this->logger->info(
393  'Bot password {op} for {user}@{app_id} ' . ( $success ? 'succeeded' : 'failed' ),
394  [
395  'op' => $this->operation,
396  'user' => $this->getUser()->getName(),
397  'app_id' => $this->par,
398  'centralId' => $this->userId,
399  'restrictions' => $data['restrictions'],
400  'grants' => $bp->getGrants(),
401  'client_ip' => $this->getRequest()->getIP(),
402  'success' => $success,
403  ]
404  );
405 
406  return $res;
407  }
408 
409  public function onSuccess() {
410  $out = $this->getOutput();
411 
412  $username = $this->getUser()->getName();
413  switch ( $this->operation ) {
414  case 'insert':
415  $out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() );
416  $out->addWikiMsg( 'botpasswords-created-body', $this->par, $username );
417  break;
418 
419  case 'update':
420  $out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() );
421  $out->addWikiMsg( 'botpasswords-updated-body', $this->par, $username );
422  break;
423 
424  case 'delete':
425  $out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() );
426  $out->addWikiMsg( 'botpasswords-deleted-body', $this->par, $username );
427  $this->password = null;
428  break;
429  }
430 
431  if ( $this->password !== null ) {
432  $sep = BotPassword::getSeparator();
433  $out->addWikiMsg(
434  'botpasswords-newpassword',
435  htmlspecialchars( $username . $sep . $this->par ),
436  htmlspecialchars( $this->password ),
437  htmlspecialchars( $username ),
438  htmlspecialchars( $this->par . $sep . $this->password )
439  );
440  $this->password = null;
441  }
442 
443  $out->addReturnTo( $this->getPageTitle() );
444  }
445 
446  protected function getGroupName() {
447  return 'login';
448  }
449 
450  protected function getDisplayFormat() {
451  return 'ooui';
452  }
453 }
454 
458 class_alias( SpecialBotPasswords::class, 'SpecialBotPasswords' );
$success
Utility class for bot passwords.
Definition: BotPassword.php:35
const APPID_MAXLENGTH
Definition: BotPassword.php:37
static generatePassword( $config)
Returns a (raw, unhashed) random password string.
static getDB( $db)
Get a database connection for the bot passwords database.
static newFromCentralId( $centralId, $appId, $flags=self::READ_NORMAL)
Load a BotPassword from the database.
static newUnsaved(array $data, $flags=self::READ_NORMAL)
Create an unsaved BotPassword.
static getSeparator()
Get the separator for combined user name + app ID.
The CentralIdLookup service allows for connecting local users with cluster-wide IDs.
An error page which can definitely be safely rendered using the OutputPage.
Special page which uses an HTMLForm to handle processing.
string null $par
The sub-page of the special page.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition: HTMLForm.php:155
setTableId( $id)
Set the id of the <table> or outermost <div> element.
Definition: HTMLForm.php:1776
setWrapperLegendMsg( $msg)
Prompt the whole form to be wrapped in a "<fieldset>", with this message as its "<legend>" element.
Definition: HTMLForm.php:1842
setId( $id)
Definition: HTMLForm.php:1787
addButton( $data)
Add a button to the form.
Definition: HTMLForm.php:1208
suppressDefaultSubmit( $suppressSubmit=true)
Stop a default submit button being shown for this form.
Definition: HTMLForm.php:1721
Class for updating an MWRestrictions value (which is, currently, basically just an IP address list).
Represents an invalid password hash.
This serves as the entry point to the authentication system.
PSR-3 logger instance factory.
static getInstance( $channel)
Get a named logger instance from the currently configured logger factory.
A class containing constants representing the names of configuration variables.
const EnableBotPasswords
Name constant for the EnableBotPasswords setting, for use with Config::get()
Users can authorize applications to use their account via OAuth.
Definition: GrantsInfo.php:33
This separate service is needed because the ::getGrantsLink method requires a LinkRenderer and if we ...
Let users manage bot passwords.
checkExecutePermissions(User $user)
Called from execute() to check if the given user can perform this action.
getDisplayFormat()
Get display format for the form.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
__construct(PasswordFactory $passwordFactory, AuthManager $authManager, CentralIdLookup $centralIdLookup, GrantsInfo $grantsInfo, GrantsLocalization $grantsLocalization)
getLoginSecurityLevel()
Tells if the special page does something security-sensitive and needs extra defense against a stolen ...
onSuccess()
Do something exciting on successful processing of the form, most likely to show a confirmation messag...
alterForm(HTMLForm $form)
Play with the HTMLForm if you need to more substantially.
onSubmit(array $data)
Process the form on submission.
getFormFields()
Get an HTMLForm descriptor array.
Show an error when any operation involving passwords fails to run.
Factory class for creating and checking Password objects.
getName()
Get the name of this Special Page.
getOutput()
Get the OutputPage being used for this instance.
getUser()
Shortcut to get the User executing this instance.
requireNamedUser( $reasonMsg='exception-nologin-text', $titleMsg='exception-nologin')
If the user is not logged in or is a temporary user, throws UserNotLoggedIn.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getConfig()
Shortcut to get main config object.
getRequest()
Get the WebRequest being used for this instance.
setAuthManager(AuthManager $authManager)
Set the injected AuthManager from the special page constructor.
getPageTitle( $subpage=false)
Get a self-referential title object.
getLanguage()
Shortcut to get user's language.
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:46
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:71
const DB_REPLICA
Definition: defines.php:26
if(!isset( $args[0])) $lang