MediaWiki  master
PasswordReset.php
Go to the documentation of this file.
1 <?php
31 use Psr\Log\LoggerAwareInterface;
32 use Psr\Log\LoggerAwareTrait;
33 use Psr\Log\LoggerInterface;
35 
43 class PasswordReset implements LoggerAwareInterface {
44  use LoggerAwareTrait;
45 
47  protected $config;
48 
50  protected $authManager;
51 
53  protected $permissionManager;
54 
56  protected $loadBalancer;
57 
59  private $hookContainer;
60 
62  private $hookRunner;
63 
70 
74  public const CONSTRUCTOR_OPTIONS = [
75  'AllowRequiringEmailForResets',
76  'EnableEmail',
77  'PasswordResetRoutes',
78  ];
79 
90  public function __construct(
91  $config,
95  LoggerInterface $logger = null,
97  ) {
98  $this->config = $config;
99  $this->authManager = $authManager;
100  $this->permissionManager = $permissionManager;
101 
102  if ( !$loadBalancer ) {
103  wfDeprecatedMsg( 'Not passing LoadBalancer to ' . __METHOD__ .
104  ' was deprecated in MediaWiki 1.34', '1.34' );
105  $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
106  }
107  $this->loadBalancer = $loadBalancer;
108 
109  if ( !$logger ) {
110  wfDeprecatedMsg( 'Not passing LoggerInterface to ' . __METHOD__ .
111  ' was deprecated in MediaWiki 1.34', '1.34' );
112  $logger = LoggerFactory::getInstance( 'authentication' );
113  }
114  $this->logger = $logger;
115 
116  if ( !$hookContainer ) {
117  wfDeprecatedMsg( 'Not passing HookContainer to ' . __METHOD__ .
118  ' was deprecated in MediaWiki 1.35', '1.35' );
119  $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
120  }
121  $this->hookContainer = $hookContainer;
122  $this->hookRunner = new HookRunner( $hookContainer );
123 
124  $this->permissionCache = new MapCacheLRU( 1 );
125  }
126 
133  public function isAllowed( User $user ) {
134  $status = $this->permissionCache->get( $user->getName() );
135  if ( !$status ) {
136  $resetRoutes = $this->config->get( 'PasswordResetRoutes' );
137  $status = StatusValue::newGood();
138 
139  if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
140  // Maybe password resets are disabled, or there are no allowable routes
141  $status = StatusValue::newFatal( 'passwordreset-disabled' );
142  } elseif (
143  ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
145  && !$providerStatus->isGood()
146  ) {
147  // Maybe the external auth plugin won't allow local password changes
148  $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
149  $providerStatus->getMessage() );
150  } elseif ( !$this->config->get( 'EnableEmail' ) ) {
151  // Maybe email features have been disabled
152  $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
153  } elseif ( !$this->permissionManager->userHasRight( $user, 'editmyprivateinfo' ) ) {
154  // Maybe not all users have permission to change private data
155  $status = StatusValue::newFatal( 'badaccess' );
156  } elseif ( $this->isBlocked( $user ) ) {
157  // Maybe the user is blocked (check this here rather than relying on the parent
158  // method as we have a more specific error message to use here and we want to
159  // ignore some types of blocks)
160  $status = StatusValue::newFatal( 'blocked-mailpassword' );
161  }
162 
163  $this->permissionCache->set( $user->getName(), $status );
164  }
165 
166  return $status;
167  }
168 
184  public function execute(
185  User $performingUser, $username = null, $email = null
186  ) {
187  if ( !$this->isAllowed( $performingUser )->isGood() ) {
188  throw new LogicException( 'User ' . $performingUser->getName()
189  . ' is not allowed to reset passwords' );
190  }
191 
192  // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
193  // that the request was good to avoid displaying an error message.
194  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
195  return StatusValue::newGood();
196  }
197 
198  // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
199  // we should send the user's name if they're logged in.
200  $ip = $performingUser->getRequest()->getIP();
201  if ( !$ip ) {
202  return StatusValue::newFatal( 'badipaddress' );
203  }
204 
205  $username = $username ?? '';
206  $email = $email ?? '';
207 
208  $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
209  + [ 'username' => false, 'email' => false ];
210  if ( $resetRoutes['username'] && $username ) {
211  $method = 'username';
212  $users = [ $this->lookupUser( $username ) ];
213  } elseif ( $resetRoutes['email'] && $email ) {
214  if ( !Sanitizer::validateEmail( $email ) ) {
215  // Only email was supplied but not valid: pretend everything's fine.
216  return StatusValue::newGood();
217  }
218  // Only email was provided
219  $method = 'email';
220  $users = $this->getUsersByEmail( $email );
221  $username = null;
222  // Remove users whose preference 'requireemail' is on since username was not submitted
223  if ( $this->config->get( 'AllowRequiringEmailForResets' ) ) {
224  foreach ( $users as $index => $user ) {
225  if ( $user->getBoolOption( 'requireemail' ) ) {
226  unset( $users[$index] );
227  }
228  }
229  }
230  } else {
231  // The user didn't supply any data
232  return StatusValue::newFatal( 'passwordreset-nodata' );
233  }
234 
235  // If the username is not valid, tell the user.
236  if ( $username && !User::getCanonicalName( $username ) ) {
237  return StatusValue::newFatal( 'noname' );
238  }
239 
240  // Check for hooks (captcha etc), and allow them to modify the users list
241  $error = [];
242  $data = [
243  'Username' => $username,
244  // Email gets set to null for backward compatibility
245  'Email' => $method === 'email' ? $email : null,
246  ];
247 
248  // Recreate the $users array with its values so that we reset the numeric keys since
249  // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
250  // hook assumes that index '0' is defined if $users is not empty.
251  $users = array_values( $users );
252 
253  if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
255  }
256 
257  // Get the first element in $users by using `reset` function just in case $users is changed
258  // in 'SpecialPasswordResetOnSubmit' hook.
259  $firstUser = reset( $users ) ?? null;
260 
261  $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
262  && $method === 'username'
263  && $firstUser
264  && $firstUser->getBoolOption( 'requireemail' );
265  if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
266  // Email is required, and not supplied or not valid: pretend everything's fine.
267  return StatusValue::newGood();
268  }
269 
270  if ( !$users ) {
271  if ( $method === 'email' ) {
272  // Don't reveal whether or not an email address is in use
273  return StatusValue::newGood();
274  } else {
275  return StatusValue::newFatal( 'noname' );
276  }
277  }
278 
279  // If the user doesn't exist, or if the user doesn't have an email address,
280  // don't disclose the information. We want to pretend everything is ok per T238961.
281  // Note that all the users will have the same email address (or none),
282  // so there's no need to check more than the first.
283  if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
284  return StatusValue::newGood();
285  }
286 
287  // Email is required but the email doesn't match: pretend everything's fine.
288  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
289  return StatusValue::newGood();
290  }
291 
292  $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
293 
294  $result = StatusValue::newGood();
295  $reqs = [];
296  foreach ( $users as $user ) {
297  $req = TemporaryPasswordAuthenticationRequest::newRandom();
298  $req->username = $user->getName();
299  $req->mailpassword = true;
300  $req->caller = $performingUser->getName();
301 
302  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
303  // If status is good and the value is 'throttled-mailpassword', we want to pretend
304  // that the request was good to avoid displaying an error message and disclose
305  // if a reset password was previously sent.
306  if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
307  return StatusValue::newGood();
308  }
309 
310  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
311  $reqs[] = $req;
312  } elseif ( $result->isGood() ) {
313  // only record the first error, to avoid exposing the number of users having the
314  // same email address
315  if ( $status->getValue() === 'ignored' ) {
316  $status = StatusValue::newFatal( 'passwordreset-ignored' );
317  }
318  $result->merge( $status );
319  }
320  }
321 
322  $logContext = [
323  'requestingIp' => $ip,
324  'requestingUser' => $performingUser->getName(),
325  'targetUsername' => $username,
326  'targetEmail' => $email,
327  ];
328 
329  if ( !$result->isGood() ) {
330  $this->logger->info(
331  "{requestingUser} attempted password reset of {actualUser} but failed",
332  $logContext + [ 'errors' => $result->getErrors() ]
333  );
334  return $result;
335  }
336 
338  new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
340  );
341 
342  return StatusValue::newGood();
343  }
344 
352  protected function isBlocked( User $user ) {
353  $block = $user->getBlock() ?: $user->getGlobalBlock();
354  if ( !$block ) {
355  return false;
356  }
357  return $block->appliesToPasswordReset();
358  }
359 
365  protected function getUsersByEmail( $email ) {
366  $userQuery = User::getQueryInfo();
367  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
368  $userQuery['tables'],
369  $userQuery['fields'],
370  [ 'user_email' => $email ],
371  __METHOD__,
372  [],
373  $userQuery['joins']
374  );
375 
376  if ( !$res ) {
377  // Some sort of database error, probably unreachable
378  throw new MWException( 'Unknown database error in ' . __METHOD__ );
379  }
380 
381  $users = [];
382  foreach ( $res as $row ) {
383  $users[] = User::newFromRow( $row );
384  }
385  return $users;
386  }
387 
395  protected function lookupUser( $username ) {
396  return User::newFromName( $username );
397  }
398 }
PasswordReset\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: PasswordReset.php:74
Message\newFromSpecifier
static newFromSpecifier( $value)
Transform a MessageSpecifier or a primitive value used interchangeably with specifiers (a message key...
Definition: Message.php:423
StatusValue\newFatal
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
User\getId
getId()
Get the user's ID.
Definition: User.php:2014
PasswordReset\$hookRunner
HookRunner $hookRunner
Definition: PasswordReset.php:62
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:159
PasswordReset\getUsersByEmail
getUsersByEmail( $email)
Definition: PasswordReset.php:365
PasswordReset\$config
ServiceOptions Config $config
Definition: PasswordReset.php:47
DeferredUpdates\addUpdate
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the deferred update queue for execution at the appropriate time.
Definition: DeferredUpdates.php:106
Sanitizer\validateEmail
static validateEmail( $addr)
Does a string look like an e-mail address?
Definition: Sanitizer.php:1711
User\newFromName
static newFromName( $name, $validate='valid')
Static factory method for creation from username.
Definition: User.php:538
PasswordReset\$hookContainer
HookContainer $hookContainer
Definition: PasswordReset.php:59
User\pingLimiter
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition: User.php:1642
$res
$res
Definition: testCompression.php:57
User\newFromRow
static newFromRow( $row, $data=null)
Create a new user object from a user row.
Definition: User.php:714
User\getRequest
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3096
PasswordReset\$permissionCache
MapCacheLRU $permissionCache
In-process cache for isAllowed lookups, by username.
Definition: PasswordReset.php:69
PasswordReset\__construct
__construct( $config, AuthManager $authManager, PermissionManager $permissionManager, ILoadBalancer $loadBalancer=null, LoggerInterface $logger=null, HookContainer $hookContainer=null)
This class is managed by MediaWikiServices, don't instantiate directly.
Definition: PasswordReset.php:90
Config
Interface for configuration instances.
Definition: Config.php:30
wfDeprecatedMsg
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
Definition: GlobalFunctions.php:1059
MWException
MediaWiki exception.
Definition: MWException.php:29
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:25
MediaWiki\Logger\LoggerFactory
PSR-3 logger instance factory.
Definition: LoggerFactory.php:45
PasswordReset\$loadBalancer
ILoadBalancer $loadBalancer
Definition: PasswordReset.php:56
SendPasswordResetEmailUpdate
Sends emails to all accounts associated with that email to reset the password.
Definition: SendPasswordResetEmailUpdate.php:30
PasswordReset\lookupUser
lookupUser( $username)
User object creation helper for testability.
Definition: PasswordReset.php:395
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:38
DeferredUpdates\POSTSEND
const POSTSEND
Definition: DeferredUpdates.php:85
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
User\getBlock
getBlock( $fromReplica=true)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:1884
MediaWiki\Permissions\PermissionManager
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Definition: PermissionManager.php:49
PasswordReset\isAllowed
isAllowed(User $user)
Check if a given user has permission to use this functionality.
Definition: PasswordReset.php:133
StatusValue\newGood
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:82
User\getGlobalBlock
getGlobalBlock( $ip='')
Check if user is blocked on all wikis.
Definition: User.php:1956
User\getQueryInfo
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition: User.php:4376
MediaWiki\Auth\AuthManager
This serves as the entry point to the authentication system.
Definition: AuthManager.php:88
User\getCanonicalName
static getCanonicalName( $name, $validate='valid')
Given unvalidated user input, return a canonical username, or false if the username is invalid.
Definition: User.php:1127
MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
This represents the intention to set a temporary password for the user.
Definition: TemporaryPasswordAuthenticationRequest.php:32
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:44
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:562
PasswordReset\$permissionManager
PermissionManager $permissionManager
Definition: PasswordReset.php:53
PasswordReset\isBlocked
isBlocked(User $user)
Check whether the user is blocked.
Definition: PasswordReset.php:352
PasswordReset\$authManager
AuthManager $authManager
Definition: PasswordReset.php:50
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:55
PasswordReset\execute
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
Definition: PasswordReset.php:184
PasswordReset
Helper class for the password reset functionality shared by the web UI and the API.
Definition: PasswordReset.php:43
User\getName
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:2043
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81