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