MediaWiki  master
PasswordReset.php
Go to the documentation of this file.
1 <?php
32 use Psr\Log\LoggerAwareInterface;
33 use Psr\Log\LoggerAwareTrait;
34 use Psr\Log\LoggerInterface;
36 
44 class PasswordReset implements LoggerAwareInterface {
45  use LoggerAwareTrait;
46 
48  private $config;
49 
51  private $authManager;
52 
54  private $hookRunner;
55 
57  private $loadBalancer;
58 
61 
63  private $userFactory;
64 
66  private $userNameUtils;
67 
70 
77 
81  public const CONSTRUCTOR_OPTIONS = [
82  'AllowRequiringEmailForResets',
83  'EnableEmail',
84  'PasswordResetRoutes',
85  ];
86 
100  public function __construct(
102  LoggerInterface $logger,
104  HookContainer $hookContainer,
110  ) {
111  $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
112 
113  $this->config = $config;
114  $this->logger = $logger;
115 
116  $this->authManager = $authManager;
117  $this->hookRunner = new HookRunner( $hookContainer );
118  $this->loadBalancer = $loadBalancer;
119  $this->permissionManager = $permissionManager;
120  $this->userFactory = $userFactory;
121  $this->userNameUtils = $userNameUtils;
122  $this->userOptionsLookup = $userOptionsLookup;
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,
186  $username = null,
187  $email = null
188  ) {
189  if ( !$this->isAllowed( $performingUser )->isGood() ) {
190  throw new LogicException(
191  'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
192  );
193  }
194 
195  // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
196  // that the request was good to avoid displaying an error message.
197  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
198  return StatusValue::newGood();
199  }
200 
201  // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
202  // we should send the user's name if they're logged in.
203  $ip = $performingUser->getRequest()->getIP();
204  if ( !$ip ) {
205  return StatusValue::newFatal( 'badipaddress' );
206  }
207 
208  $username = $username ?? '';
209  $email = $email ?? '';
210 
211  $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
212  + [ 'username' => false, 'email' => false ];
213  if ( $resetRoutes['username'] && $username ) {
214  $method = 'username';
215  $users = [ $this->userFactory->newFromName( $username ) ];
216  } elseif ( $resetRoutes['email'] && $email ) {
217  if ( !Sanitizer::validateEmail( $email ) ) {
218  // Only email was supplied but not valid: pretend everything's fine.
219  return StatusValue::newGood();
220  }
221  // Only email was provided
222  $method = 'email';
223  $users = $this->getUsersByEmail( $email );
224  $username = null;
225  // Remove users whose preference 'requireemail' is on since username was not submitted
226  if ( $this->config->get( 'AllowRequiringEmailForResets' ) ) {
227  $optionsLookup = $this->userOptionsLookup;
228  foreach ( $users as $index => $user ) {
229  if ( $optionsLookup->getBoolOption( $user, 'requireemail' ) ) {
230  unset( $users[$index] );
231  }
232  }
233  }
234  } else {
235  // The user didn't supply any data
236  return StatusValue::newFatal( 'passwordreset-nodata' );
237  }
238 
239  // If the username is not valid, tell the user.
240  if ( $username && !$this->userNameUtils->getCanonical( $username ) ) {
241  return StatusValue::newFatal( 'noname' );
242  }
243 
244  // Check for hooks (captcha etc), and allow them to modify the users list
245  $error = [];
246  $data = [
247  'Username' => $username,
248  // Email gets set to null for backward compatibility
249  'Email' => $method === 'email' ? $email : null,
250  ];
251 
252  // Recreate the $users array with its values so that we reset the numeric keys since
253  // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
254  // hook assumes that index '0' is defined if $users is not empty.
255  $users = array_values( $users );
256 
257  if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
259  }
260 
261  // Get the first element in $users by using `reset` function just in case $users is changed
262  // in 'SpecialPasswordResetOnSubmit' hook.
263  $firstUser = reset( $users );
264 
265  $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
266  && $method === 'username'
267  && $firstUser
268  && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' );
269  if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
270  // Email is required, and not supplied or not valid: pretend everything's fine.
271  return StatusValue::newGood();
272  }
273 
274  if ( !$users ) {
275  if ( $method === 'email' ) {
276  // Don't reveal whether or not an email address is in use
277  return StatusValue::newGood();
278  } else {
279  return StatusValue::newFatal( 'noname' );
280  }
281  }
282 
283  // If the user doesn't exist, or if the user doesn't have an email address,
284  // don't disclose the information. We want to pretend everything is ok per T238961.
285  // Note that all the users will have the same email address (or none),
286  // so there's no need to check more than the first.
287  if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
288  return StatusValue::newGood();
289  }
290 
291  // Email is required but the email doesn't match: pretend everything's fine.
292  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
293  return StatusValue::newGood();
294  }
295 
296  $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
297 
298  $result = StatusValue::newGood();
299  $reqs = [];
300  foreach ( $users as $user ) {
301  $req = TemporaryPasswordAuthenticationRequest::newRandom();
302  $req->username = $user->getName();
303  $req->mailpassword = true;
304  $req->caller = $performingUser->getName();
305 
306  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
307  // If status is good and the value is 'throttled-mailpassword', we want to pretend
308  // that the request was good to avoid displaying an error message and disclose
309  // if a reset password was previously sent.
310  if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
311  return StatusValue::newGood();
312  }
313 
314  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
315  $reqs[] = $req;
316  } elseif ( $result->isGood() ) {
317  // only record the first error, to avoid exposing the number of users having the
318  // same email address
319  if ( $status->getValue() === 'ignored' ) {
320  $status = StatusValue::newFatal( 'passwordreset-ignored' );
321  }
322  $result->merge( $status );
323  }
324  }
325 
326  $logContext = [
327  'requestingIp' => $ip,
328  'requestingUser' => $performingUser->getName(),
329  'targetUsername' => $username,
330  'targetEmail' => $email,
331  ];
332 
333  if ( !$result->isGood() ) {
334  $this->logger->info(
335  "{requestingUser} attempted password reset of {actualUser} but failed",
336  $logContext + [ 'errors' => $result->getErrors() ]
337  );
338  return $result;
339  }
340 
342  new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
343  DeferredUpdates::POSTSEND
344  );
345 
346  return StatusValue::newGood();
347  }
348 
356  private function isBlocked( User $user ) {
357  $block = $user->getBlock() ?: $user->getGlobalBlock();
358  if ( !$block ) {
359  return false;
360  }
361  return $block->appliesToPasswordReset();
362  }
363 
371  protected function getUsersByEmail( $email ) {
372  $userQuery = User::getQueryInfo();
373  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
374  $userQuery['tables'],
375  $userQuery['fields'],
376  [ 'user_email' => $email ],
377  __METHOD__,
378  [],
379  $userQuery['joins']
380  );
381 
382  if ( !$res ) {
383  // Some sort of database error, probably unreachable
384  throw new MWException( 'Unknown database error in ' . __METHOD__ );
385  }
386 
387  $users = [];
388  foreach ( $res as $row ) {
389  $users[] = $this->userFactory->newFromRow( $row );
390  }
391  return $users;
392  }
393 
394 }
PasswordReset\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: PasswordReset.php:81
PasswordReset\$userNameUtils
UserNameUtils $userNameUtils
Definition: PasswordReset.php:66
Message\newFromSpecifier
static newFromSpecifier( $value)
Transform a MessageSpecifier or a primitive value used interchangeably with specifiers (a message key...
Definition: Message.php:424
StatusValue\newFatal
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
PasswordReset\$hookRunner
HookRunner $hookRunner
Definition: PasswordReset.php:54
PasswordReset\getUsersByEmail
getUsersByEmail( $email)
Definition: PasswordReset.php:371
PasswordReset\__construct
__construct(ServiceOptions $config, LoggerInterface $logger, AuthManager $authManager, HookContainer $hookContainer, ILoadBalancer $loadBalancer, PermissionManager $permissionManager, UserFactory $userFactory, UserNameUtils $userNameUtils, UserOptionsLookup $userOptionsLookup)
This class is managed by MediaWikiServices, don't instantiate directly.
Definition: PasswordReset.php:100
DeferredUpdates\addUpdate
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
Definition: DeferredUpdates.php:119
Sanitizer\validateEmail
static validateEmail( $addr)
Does a string look like an e-mail address?
Definition: Sanitizer.php:1708
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:1692
$res
$res
Definition: testCompression.php:57
User\getRequest
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3162
PasswordReset\$permissionCache
MapCacheLRU $permissionCache
In-process cache for isAllowed lookups, by username.
Definition: PasswordReset.php:76
MWException
MediaWiki exception.
Definition: MWException.php:29
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:27
PasswordReset\$loadBalancer
ILoadBalancer $loadBalancer
Definition: PasswordReset.php:57
SendPasswordResetEmailUpdate
Sends emails to all accounts associated with that email to reset the password.
Definition: SendPasswordResetEmailUpdate.php:30
PasswordReset\$config
ServiceOptions $config
Definition: PasswordReset.php:48
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:37
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
User\getId
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition: User.php:2067
User\getBlock
getBlock( $fromReplica=true, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:1936
MediaWiki\Permissions\PermissionManager
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Definition: PermissionManager.php:51
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:2008
User\getQueryInfo
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition: User.php:4413
MediaWiki\User\UserOptionsLookup
Provides access to user options.
Definition: UserOptionsLookup.php:29
MediaWiki\Auth\AuthManager
This serves as the entry point to the authentication system.
Definition: AuthManager.php:93
MediaWiki\User\UserNameUtils
UserNameUtils service.
Definition: UserNameUtils.php:42
MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
This represents the intention to set a temporary password for the user.
Definition: TemporaryPasswordAuthenticationRequest.php:32
PasswordReset\$userOptionsLookup
UserOptionsLookup $userOptionsLookup
Definition: PasswordReset.php:69
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:575
PasswordReset\$permissionManager
PermissionManager $permissionManager
Definition: PasswordReset.php:60
PasswordReset\isBlocked
isBlocked(User $user)
Check whether the user is blocked.
Definition: PasswordReset.php:356
PasswordReset\$authManager
AuthManager $authManager
Definition: PasswordReset.php:51
PasswordReset\$userFactory
UserFactory $userFactory
Definition: PasswordReset.php:63
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:66
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:44
User\getName
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:2108
MediaWiki\User\UserFactory
Creates User objects.
Definition: UserFactory.php:41
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81
MediaWiki\Config\ServiceOptions\assertRequiredOptions
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
Definition: ServiceOptions.php:66