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 
60  private $userFactory;
61 
63  private $userNameUtils;
64 
66  private $userOptionsLookup;
67 
73  private $permissionCache;
74 
78  public const CONSTRUCTOR_OPTIONS = [
79  MainConfigNames::AllowRequiringEmailForResets,
80  MainConfigNames::EnableEmail,
81  MainConfigNames::PasswordResetRoutes,
82  ];
83 
96  public function __construct(
97  ServiceOptions $config,
98  LoggerInterface $logger,
99  AuthManager $authManager,
100  HookContainer $hookContainer,
101  ILoadBalancer $loadBalancer,
102  UserFactory $userFactory,
103  UserNameUtils $userNameUtils,
104  UserOptionsLookup $userOptionsLookup
105  ) {
106  $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
107 
108  $this->config = $config;
109  $this->logger = $logger;
110 
111  $this->authManager = $authManager;
112  $this->hookRunner = new HookRunner( $hookContainer );
113  $this->loadBalancer = $loadBalancer;
114  $this->userFactory = $userFactory;
115  $this->userNameUtils = $userNameUtils;
116  $this->userOptionsLookup = $userOptionsLookup;
117 
118  $this->permissionCache = new MapCacheLRU( 1 );
119  }
120 
127  public function isAllowed( User $user ) {
128  return $this->permissionCache->getWithSetCallback(
129  $user->getName(),
130  function () use ( $user ) {
131  return $this->computeIsAllowed( $user );
132  }
133  );
134  }
135 
140  private function computeIsAllowed( User $user ): StatusValue {
141  $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes );
142  $status = StatusValue::newGood();
143 
144  if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
145  // Maybe password resets are disabled, or there are no allowable routes
146  $status = StatusValue::newFatal( 'passwordreset-disabled' );
147  } elseif (
148  ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
150  && !$providerStatus->isGood()
151  ) {
152  // Maybe the external auth plugin won't allow local password changes
153  $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
154  $providerStatus->getMessage() );
155  } elseif ( !$this->config->get( MainConfigNames::EnableEmail ) ) {
156  // Maybe email features have been disabled
157  $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
158  } elseif ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
159  // Maybe not all users have permission to change private data
160  $status = StatusValue::newFatal( 'badaccess' );
161  } elseif ( $this->isBlocked( $user ) ) {
162  // Maybe the user is blocked (check this here rather than relying on the parent
163  // method as we have a more specific error message to use here and we want to
164  // ignore some types of blocks)
165  $status = StatusValue::newFatal( 'blocked-mailpassword' );
166  }
167  return $status;
168  }
169 
185  public function execute(
186  User $performingUser,
187  $username = null,
188  $email = null
189  ) {
190  if ( !$this->isAllowed( $performingUser )->isGood() ) {
191  throw new LogicException(
192  'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
193  );
194  }
195 
196  // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
197  // that the request was good to avoid displaying an error message.
198  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
199  return StatusValue::newGood();
200  }
201 
202  // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
203  // we should send the user's name if they're logged in.
204  $ip = $performingUser->getRequest()->getIP();
205  if ( !$ip ) {
206  return StatusValue::newFatal( 'badipaddress' );
207  }
208 
209  $username = $username ?? '';
210  $email = $email ?? '';
211 
212  $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes )
213  + [ 'username' => false, 'email' => false ];
214  if ( $resetRoutes['username'] && $username ) {
215  $method = 'username';
216  $users = [ $this->userFactory->newFromName( $username ) ];
217  } elseif ( $resetRoutes['email'] && $email ) {
218  if ( !Sanitizer::validateEmail( $email ) ) {
219  // Only email was supplied but not valid: pretend everything's fine.
220  return StatusValue::newGood();
221  }
222  // Only email was provided
223  $method = 'email';
224  $users = $this->getUsersByEmail( $email );
225  $username = null;
226  // Remove users whose preference 'requireemail' is on since username was not submitted
227  if ( $this->config->get( MainConfigNames::AllowRequiringEmailForResets ) ) {
228  $optionsLookup = $this->userOptionsLookup;
229  foreach ( $users as $index => $user ) {
230  if ( $optionsLookup->getBoolOption( $user, 'requireemail' ) ) {
231  unset( $users[$index] );
232  }
233  }
234  }
235  } else {
236  // The user didn't supply any data
237  return StatusValue::newFatal( 'passwordreset-nodata' );
238  }
239 
240  // If the username is not valid, tell the user.
241  if ( $username && !$this->userNameUtils->getCanonical( $username ) ) {
242  return StatusValue::newFatal( 'noname' );
243  }
244 
245  // Check for hooks (captcha etc), and allow them to modify the users list
246  $error = [];
247  $data = [
248  'Username' => $username,
249  // Email gets set to null for backward compatibility
250  'Email' => $method === 'email' ? $email : null,
251  ];
252 
253  // Recreate the $users array with its values so that we reset the numeric keys since
254  // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
255  // hook assumes that index '0' is defined if $users is not empty.
256  $users = array_values( $users );
257 
258  if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
260  }
261 
262  // Get the first element in $users by using `reset` function just in case $users is changed
263  // in 'SpecialPasswordResetOnSubmit' hook.
264  $firstUser = reset( $users );
265 
266  $requireEmail = $this->config->get( MainConfigNames::AllowRequiringEmailForResets )
267  && $method === 'username'
268  && $firstUser
269  && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' );
270  if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
271  // Email is required, and not supplied or not valid: pretend everything's fine.
272  return StatusValue::newGood();
273  }
274 
275  if ( !$users ) {
276  if ( $method === 'email' ) {
277  // Don't reveal whether or not an email address is in use
278  return StatusValue::newGood();
279  } else {
280  return StatusValue::newFatal( 'noname' );
281  }
282  }
283 
284  // If the user doesn't exist, or if the user doesn't have an email address,
285  // don't disclose the information. We want to pretend everything is ok per T238961.
286  // Note that all the users will have the same email address (or none),
287  // so there's no need to check more than the first.
288  if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
289  return StatusValue::newGood();
290  }
291 
292  // Email is required but the email doesn't match: pretend everything's fine.
293  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
294  return StatusValue::newGood();
295  }
296 
297  $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
298 
299  $result = StatusValue::newGood();
300  $reqs = [];
301  foreach ( $users as $user ) {
302  $req = TemporaryPasswordAuthenticationRequest::newRandom();
303  $req->username = $user->getName();
304  $req->mailpassword = true;
305  $req->caller = $performingUser->getName();
306 
307  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
308  // If status is good and the value is 'throttled-mailpassword', we want to pretend
309  // that the request was good to avoid displaying an error message and disclose
310  // if a reset password was previously sent.
311  if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
312  return StatusValue::newGood();
313  }
314 
315  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
316  $reqs[] = $req;
317  } elseif ( $result->isGood() ) {
318  // only record the first error, to avoid exposing the number of users having the
319  // same email address
320  if ( $status->getValue() === 'ignored' ) {
321  $status = StatusValue::newFatal( 'passwordreset-ignored' );
322  }
323  $result->merge( $status );
324  }
325  }
326 
327  $logContext = [
328  'requestingIp' => $ip,
329  'requestingUser' => $performingUser->getName(),
330  'targetUsername' => $username,
331  'targetEmail' => $email,
332  ];
333 
334  if ( !$result->isGood() ) {
335  $this->logger->info(
336  "{requestingUser} attempted password reset of {actualUser} but failed",
337  $logContext + [ 'errors' => $result->getErrors() ]
338  );
339  return $result;
340  }
341 
343  new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
344  DeferredUpdates::POSTSEND
345  );
346 
347  return StatusValue::newGood();
348  }
349 
357  private function isBlocked( User $user ) {
358  $block = $user->getBlock() ?: $user->getGlobalBlock();
359  if ( !$block ) {
360  return false;
361  }
362  return $block->appliesToPasswordReset();
363  }
364 
372  protected function getUsersByEmail( $email ) {
373  $userQuery = User::getQueryInfo();
374  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
375  $userQuery['tables'],
376  $userQuery['fields'],
377  [ 'user_email' => $email ],
378  __METHOD__,
379  [],
380  $userQuery['joins']
381  );
382 
383  if ( !$res ) {
384  // Some sort of database error, probably unreachable
385  throw new MWException( 'Unknown database error in ' . __METHOD__ );
386  }
387 
388  $users = [];
389  foreach ( $res as $row ) {
390  $users[] = $this->userFactory->newFromRow( $row );
391  }
392  return $users;
393  }
394 
395 }
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
MediaWiki exception.
Definition: MWException.php:29
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:36
This serves as the entry point to the authentication system.
This represents the intention to set a temporary password for the user.
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:560
A class containing constants representing the names of configuration variables.
Creates User objects.
Definition: UserFactory.php:38
UserNameUtils service.
Provides access to user options.
static newFromSpecifier( $value)
Transform a MessageSpecifier or a primitive value used interchangeably with specifiers (a message key...
Definition: Message.php:423
Helper class for the password reset functionality shared by the web UI and the API.
isAllowed(User $user)
Check if a given user has permission to use this functionality.
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
__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.
const CONSTRUCTOR_OPTIONS
getUsersByEmail( $email)
static validateEmail( $addr)
Does a string look like an e-mail address?
Definition: Sanitizer.php:1878
Sends emails to all accounts associated with that email to reset the password.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: StatusValue.php:43
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:82
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:70
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:1520
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:2432
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:1702
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition: User.php:3385
getGlobalBlock( $ip='')
Check if user is blocked on all wikis.
Definition: User.php:1610
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition: User.php:1669
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition: User.php:1478
isAllowed(string $permission)
Checks whether this authority has the given permission in general.
Definition: User.php:2383
Create and track the database connections and transactions for a given database cluster.
const DB_REPLICA
Definition: defines.php:26