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  private $config;
48 
50  private $authManager;
51 
53  private $hookRunner;
54 
56  private $loadBalancer;
57 
59  private $userFactory;
60 
62  private $userNameUtils;
63 
66 
73 
77  public const CONSTRUCTOR_OPTIONS = [
78  'AllowRequiringEmailForResets',
79  'EnableEmail',
80  'PasswordResetRoutes',
81  ];
82 
95  public function __construct(
97  LoggerInterface $logger,
99  HookContainer $hookContainer,
104  ) {
105  $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
106 
107  $this->config = $config;
108  $this->logger = $logger;
109 
110  $this->authManager = $authManager;
111  $this->hookRunner = new HookRunner( $hookContainer );
112  $this->loadBalancer = $loadBalancer;
113  $this->userFactory = $userFactory;
114  $this->userNameUtils = $userNameUtils;
115  $this->userOptionsLookup = $userOptionsLookup;
116 
117  $this->permissionCache = new MapCacheLRU( 1 );
118  }
119 
126  public function isAllowed( User $user ) {
127  $status = $this->permissionCache->get( $user->getName() );
128  if ( !$status ) {
129  $resetRoutes = $this->config->get( 'PasswordResetRoutes' );
130  $status = StatusValue::newGood();
131 
132  if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
133  // Maybe password resets are disabled, or there are no allowable routes
134  $status = StatusValue::newFatal( 'passwordreset-disabled' );
135  } elseif (
136  ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
138  && !$providerStatus->isGood()
139  ) {
140  // Maybe the external auth plugin won't allow local password changes
141  $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
142  $providerStatus->getMessage() );
143  } elseif ( !$this->config->get( 'EnableEmail' ) ) {
144  // Maybe email features have been disabled
145  $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
146  } elseif ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
147  // Maybe not all users have permission to change private data
148  $status = StatusValue::newFatal( 'badaccess' );
149  } elseif ( $this->isBlocked( $user ) ) {
150  // Maybe the user is blocked (check this here rather than relying on the parent
151  // method as we have a more specific error message to use here and we want to
152  // ignore some types of blocks)
153  $status = StatusValue::newFatal( 'blocked-mailpassword' );
154  }
155 
156  $this->permissionCache->set( $user->getName(), $status );
157  }
158 
159  return $status;
160  }
161 
177  public function execute(
178  User $performingUser,
179  $username = null,
180  $email = null
181  ) {
182  if ( !$this->isAllowed( $performingUser )->isGood() ) {
183  throw new LogicException(
184  'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
185  );
186  }
187 
188  // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
189  // that the request was good to avoid displaying an error message.
190  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
191  return StatusValue::newGood();
192  }
193 
194  // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
195  // we should send the user's name if they're logged in.
196  $ip = $performingUser->getRequest()->getIP();
197  if ( !$ip ) {
198  return StatusValue::newFatal( 'badipaddress' );
199  }
200 
201  $username = $username ?? '';
202  $email = $email ?? '';
203 
204  $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
205  + [ 'username' => false, 'email' => false ];
206  if ( $resetRoutes['username'] && $username ) {
207  $method = 'username';
208  $users = [ $this->userFactory->newFromName( $username ) ];
209  } elseif ( $resetRoutes['email'] && $email ) {
210  if ( !Sanitizer::validateEmail( $email ) ) {
211  // Only email was supplied but not valid: pretend everything's fine.
212  return StatusValue::newGood();
213  }
214  // Only email was provided
215  $method = 'email';
216  $users = $this->getUsersByEmail( $email );
217  $username = null;
218  // Remove users whose preference 'requireemail' is on since username was not submitted
219  if ( $this->config->get( 'AllowRequiringEmailForResets' ) ) {
220  $optionsLookup = $this->userOptionsLookup;
221  foreach ( $users as $index => $user ) {
222  if ( $optionsLookup->getBoolOption( $user, 'requireemail' ) ) {
223  unset( $users[$index] );
224  }
225  }
226  }
227  } else {
228  // The user didn't supply any data
229  return StatusValue::newFatal( 'passwordreset-nodata' );
230  }
231 
232  // If the username is not valid, tell the user.
233  if ( $username && !$this->userNameUtils->getCanonical( $username ) ) {
234  return StatusValue::newFatal( 'noname' );
235  }
236 
237  // Check for hooks (captcha etc), and allow them to modify the users list
238  $error = [];
239  $data = [
240  'Username' => $username,
241  // Email gets set to null for backward compatibility
242  'Email' => $method === 'email' ? $email : null,
243  ];
244 
245  // Recreate the $users array with its values so that we reset the numeric keys since
246  // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
247  // hook assumes that index '0' is defined if $users is not empty.
248  $users = array_values( $users );
249 
250  if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
252  }
253 
254  // Get the first element in $users by using `reset` function just in case $users is changed
255  // in 'SpecialPasswordResetOnSubmit' hook.
256  $firstUser = reset( $users );
257 
258  $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
259  && $method === 'username'
260  && $firstUser
261  && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' );
262  if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
263  // Email is required, and not supplied or not valid: pretend everything's fine.
264  return StatusValue::newGood();
265  }
266 
267  if ( !$users ) {
268  if ( $method === 'email' ) {
269  // Don't reveal whether or not an email address is in use
270  return StatusValue::newGood();
271  } else {
272  return StatusValue::newFatal( 'noname' );
273  }
274  }
275 
276  // If the user doesn't exist, or if the user doesn't have an email address,
277  // don't disclose the information. We want to pretend everything is ok per T238961.
278  // Note that all the users will have the same email address (or none),
279  // so there's no need to check more than the first.
280  if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
281  return StatusValue::newGood();
282  }
283 
284  // Email is required but the email doesn't match: pretend everything's fine.
285  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
286  return StatusValue::newGood();
287  }
288 
289  $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
290 
291  $result = StatusValue::newGood();
292  $reqs = [];
293  foreach ( $users as $user ) {
294  $req = TemporaryPasswordAuthenticationRequest::newRandom();
295  $req->username = $user->getName();
296  $req->mailpassword = true;
297  $req->caller = $performingUser->getName();
298 
299  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
300  // If status is good and the value is 'throttled-mailpassword', we want to pretend
301  // that the request was good to avoid displaying an error message and disclose
302  // if a reset password was previously sent.
303  if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
304  return StatusValue::newGood();
305  }
306 
307  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
308  $reqs[] = $req;
309  } elseif ( $result->isGood() ) {
310  // only record the first error, to avoid exposing the number of users having the
311  // same email address
312  if ( $status->getValue() === 'ignored' ) {
313  $status = StatusValue::newFatal( 'passwordreset-ignored' );
314  }
315  $result->merge( $status );
316  }
317  }
318 
319  $logContext = [
320  'requestingIp' => $ip,
321  'requestingUser' => $performingUser->getName(),
322  'targetUsername' => $username,
323  'targetEmail' => $email,
324  ];
325 
326  if ( !$result->isGood() ) {
327  $this->logger->info(
328  "{requestingUser} attempted password reset of {actualUser} but failed",
329  $logContext + [ 'errors' => $result->getErrors() ]
330  );
331  return $result;
332  }
333 
335  new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
336  DeferredUpdates::POSTSEND
337  );
338 
339  return StatusValue::newGood();
340  }
341 
349  private function isBlocked( User $user ) {
350  $block = $user->getBlock() ?: $user->getGlobalBlock();
351  if ( !$block ) {
352  return false;
353  }
354  return $block->appliesToPasswordReset();
355  }
356 
364  protected function getUsersByEmail( $email ) {
365  $userQuery = User::getQueryInfo();
366  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
367  $userQuery['tables'],
368  $userQuery['fields'],
369  [ 'user_email' => $email ],
370  __METHOD__,
371  [],
372  $userQuery['joins']
373  );
374 
375  if ( !$res ) {
376  // Some sort of database error, probably unreachable
377  throw new MWException( 'Unknown database error in ' . __METHOD__ );
378  }
379 
380  $users = [];
381  foreach ( $res as $row ) {
382  $users[] = $this->userFactory->newFromRow( $row );
383  }
384  return $users;
385  }
386 
387 }
PasswordReset\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: PasswordReset.php:77
PasswordReset\$userNameUtils
UserNameUtils $userNameUtils
Definition: PasswordReset.php:62
Message\newFromSpecifier
static newFromSpecifier( $value)
Transform a MessageSpecifier or a primitive value used interchangeably with specifiers (a message key...
Definition: Message.php:398
StatusValue\newFatal
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
PasswordReset\$hookRunner
HookRunner $hookRunner
Definition: PasswordReset.php:53
PasswordReset\getUsersByEmail
getUsersByEmail( $email)
Definition: PasswordReset.php:364
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:1714
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:1688
$res
$res
Definition: testCompression.php:57
User\getBlock
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:1940
User\getRequest
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3074
PasswordReset\$permissionCache
MapCacheLRU $permissionCache
In-process cache for isAllowed lookups, by username.
Definition: PasswordReset.php:72
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:56
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:47
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:36
PasswordReset\__construct
__construct(ServiceOptions $config, LoggerInterface $logger, AuthManager $authManager, HookContainer $hookContainer, ILoadBalancer $loadBalancer, UserFactory $userFactory, UserNameUtils $userNameUtils, UserOptionsLookup $userOptionsLookup)
This class is managed by MediaWikiServices, don't instantiate directly.
Definition: PasswordReset.php:95
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
User\getId
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition: User.php:2082
PasswordReset\isAllowed
isAllowed(User $user)
Check if a given user has permission to use this functionality.
Definition: PasswordReset.php:126
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:2023
User\getQueryInfo
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition: User.php:4181
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:102
User\isAllowed
isAllowed(string $permission)
Checks whether this authority has the given permission in general.
Definition: User.php:3032
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:65
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:558
PasswordReset\isBlocked
isBlocked(User $user)
Check whether the user is blocked.
Definition: PasswordReset.php:349
PasswordReset\$authManager
AuthManager $authManager
Definition: PasswordReset.php:50
PasswordReset\$userFactory
UserFactory $userFactory
Definition: PasswordReset.php:59
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:68
PasswordReset\execute
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
Definition: PasswordReset.php:177
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:2115
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:71