MediaWiki  master
PasswordReset.php
Go to the documentation of this file.
1 <?php
23 namespace MediaWiki\User;
24 
25 use DeferredUpdates;
26 use LogicException;
27 use MapCacheLRU;
35 use Message;
36 use Psr\Log\LoggerAwareInterface;
37 use Psr\Log\LoggerAwareTrait;
38 use Psr\Log\LoggerInterface;
40 use StatusValue;
42 
50 class PasswordReset implements LoggerAwareInterface {
51  use LoggerAwareTrait;
52 
54  private $config;
55 
57  private $authManager;
58 
60  private $hookRunner;
61 
63  private $dbProvider;
64 
66  private $userFactory;
67 
69  private $userNameUtils;
70 
72  private $userOptionsLookup;
73 
79  private $permissionCache;
80 
84  public const CONSTRUCTOR_OPTIONS = [
88  ];
89 
102  public function __construct(
103  ServiceOptions $config,
104  LoggerInterface $logger,
105  AuthManager $authManager,
106  HookContainer $hookContainer,
107  IConnectionProvider $dbProvider,
108  UserFactory $userFactory,
109  UserNameUtils $userNameUtils,
110  UserOptionsLookup $userOptionsLookup
111  ) {
112  $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
113 
114  $this->config = $config;
115  $this->logger = $logger;
116 
117  $this->authManager = $authManager;
118  $this->hookRunner = new HookRunner( $hookContainer );
119  $this->dbProvider = $dbProvider;
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  return $this->permissionCache->getWithSetCallback(
135  $user->getName(),
136  function () use ( $user ) {
137  return $this->computeIsAllowed( $user );
138  }
139  );
140  }
141 
146  private function computeIsAllowed( User $user ): StatusValue {
147  $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes );
148  $status = StatusValue::newGood();
149 
150  if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
151  // Maybe password resets are disabled, or there are no allowable routes
152  $status = StatusValue::newFatal( 'passwordreset-disabled' );
153  } elseif (
154  ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
155  new TemporaryPasswordAuthenticationRequest(), false ) )
156  && !$providerStatus->isGood()
157  ) {
158  // Maybe the external auth plugin won't allow local password changes
159  $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
160  $providerStatus->getMessage() );
161  } elseif ( !$this->config->get( MainConfigNames::EnableEmail ) ) {
162  // Maybe email features have been disabled
163  $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
164  } elseif ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
165  // Maybe not all users have permission to change private data
166  $status = StatusValue::newFatal( 'badaccess' );
167  } elseif ( $this->isBlocked( $user ) ) {
168  // Maybe the user is blocked (check this here rather than relying on the parent
169  // method as we have a more specific error message to use here and we want to
170  // ignore some types of blocks)
171  $status = StatusValue::newFatal( 'blocked-mailpassword' );
172  }
173  return $status;
174  }
175 
189  public function execute(
190  User $performingUser,
191  $username = null,
192  $email = null
193  ) {
194  if ( !$this->isAllowed( $performingUser )->isGood() ) {
195  throw new LogicException(
196  'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
197  );
198  }
199 
200  // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
201  // that the request was good to avoid displaying an error message.
202  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
203  return StatusValue::newGood();
204  }
205 
206  // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
207  // we should send the user's name if they're logged in.
208  $ip = $performingUser->getRequest()->getIP();
209  if ( !$ip ) {
210  return StatusValue::newFatal( 'badipaddress' );
211  }
212 
213  $username ??= '';
214  $email ??= '';
215 
216  $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes )
217  + [ 'username' => false, 'email' => false ];
218  if ( $resetRoutes['username'] && $username ) {
219  $method = 'username';
220  $users = [ $this->userFactory->newFromName( $username ) ];
221  } elseif ( $resetRoutes['email'] && $email ) {
222  if ( !Sanitizer::validateEmail( $email ) ) {
223  // Only email was supplied but not valid: pretend everything's fine.
224  return StatusValue::newGood();
225  }
226  // Only email was provided
227  $method = 'email';
228  $users = $this->getUsersByEmail( $email );
229  $username = null;
230  // Remove users whose preference 'requireemail' is on since username was not submitted
231  if ( $this->config->get( MainConfigNames::AllowRequiringEmailForResets ) ) {
232  $optionsLookup = $this->userOptionsLookup;
233  foreach ( $users as $index => $user ) {
234  if ( $optionsLookup->getBoolOption( $user, 'requireemail' ) ) {
235  unset( $users[$index] );
236  }
237  }
238  }
239  } else {
240  // The user didn't supply any data
241  return StatusValue::newFatal( 'passwordreset-nodata' );
242  }
243 
244  // If the username is not valid, tell the user.
245  if ( $username && !$this->userNameUtils->getCanonical( $username ) ) {
246  return StatusValue::newFatal( 'noname' );
247  }
248 
249  // Check for hooks (captcha etc), and allow them to modify the users list
250  $error = [];
251  $data = [
252  'Username' => $username,
253  // Email gets set to null for backward compatibility
254  'Email' => $method === 'email' ? $email : null,
255  ];
256 
257  // Recreate the $users array with its values so that we reset the numeric keys since
258  // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
259  // hook assumes that index '0' is defined if $users is not empty.
260  $users = array_values( $users );
261 
262  if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
264  }
265 
266  // Get the first element in $users by using `reset` function just in case $users is changed
267  // in 'SpecialPasswordResetOnSubmit' hook.
268  $firstUser = reset( $users );
269 
270  $requireEmail = $this->config->get( MainConfigNames::AllowRequiringEmailForResets )
271  && $method === 'username'
272  && $firstUser
273  && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' );
274  if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
275  // Email is required, and not supplied or not valid: pretend everything's fine.
276  return StatusValue::newGood();
277  }
278 
279  if ( !$users ) {
280  if ( $method === 'email' ) {
281  // Don't reveal whether or not an email address is in use
282  return StatusValue::newGood();
283  } else {
284  return StatusValue::newFatal( 'noname' );
285  }
286  }
287 
288  // If the user doesn't exist, or if the user doesn't have an email address,
289  // don't disclose the information. We want to pretend everything is ok per T238961.
290  // Note that all the users will have the same email address (or none),
291  // so there's no need to check more than the first.
292  if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
293  return StatusValue::newGood();
294  }
295 
296  // Email is required but the email doesn't match: pretend everything's fine.
297  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
298  return StatusValue::newGood();
299  }
300 
301  $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
302 
303  $result = StatusValue::newGood();
304  $reqs = [];
305  foreach ( $users as $user ) {
307  $req->username = $user->getName();
308  $req->mailpassword = true;
309  $req->caller = $performingUser->getName();
310 
311  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
312  // If status is good and the value is 'throttled-mailpassword', we want to pretend
313  // that the request was good to avoid displaying an error message and disclose
314  // if a reset password was previously sent.
315  if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
316  return StatusValue::newGood();
317  }
318 
319  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
320  $reqs[] = $req;
321  } elseif ( $result->isGood() ) {
322  // only record the first error, to avoid exposing the number of users having the
323  // same email address
324  if ( $status->getValue() === 'ignored' ) {
325  $status = StatusValue::newFatal( 'passwordreset-ignored' );
326  }
327  $result->merge( $status );
328  }
329  }
330 
331  $logContext = [
332  'requestingIp' => $ip,
333  'requestingUser' => $performingUser->getName(),
334  'targetUsername' => $username,
335  'targetEmail' => $email,
336  ];
337 
338  if ( !$result->isGood() ) {
339  $this->logger->info(
340  "{requestingUser} attempted password reset of {actualUser} but failed",
341  $logContext + [ 'errors' => $result->getErrors() ]
342  );
343  return $result;
344  }
345 
347  new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
348  DeferredUpdates::POSTSEND
349  );
350 
351  return StatusValue::newGood();
352  }
353 
361  private function isBlocked( User $user ) {
362  $block = $user->getBlock();
363  return $block && $block->appliesToPasswordReset();
364  }
365 
372  protected function getUsersByEmail( $email ) {
373  $res = User::newQueryBuilder( $this->dbProvider->getReplicaDatabase() )
374  ->where( [ 'user_email' => $email ] )
375  ->caller( __METHOD__ )
376  ->fetchResultSet();
377 
378  $users = [];
379  foreach ( $res as $row ) {
380  $users[] = $this->userFactory->newFromRow( $row );
381  }
382  return $users;
383  }
384 
385 }
386 
391 class_alias( PasswordReset::class, 'PasswordReset' );
Defer callable updates to run later in the PHP process.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
Store key-value entries in a size-limited in-memory LRU cache.
Definition: MapCacheLRU.php:34
This serves as the entry point to the authentication system.
This represents the intention to set a temporary password for the user.
static newRandom()
Return an instance with a new, random password.
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:568
A class containing constants representing the names of configuration variables.
const EnableEmail
Name constant for the EnableEmail setting, for use with Config::get()
const PasswordResetRoutes
Name constant for the PasswordResetRoutes setting, for use with Config::get()
const AllowRequiringEmailForResets
Name constant for the AllowRequiringEmailForResets setting, for use with Config::get()
HTML sanitizer for MediaWiki.
Definition: Sanitizer.php:46
static validateEmail( $addr)
Does a string look like an e-mail address?
Definition: Sanitizer.php:1898
Helper class for the password reset functionality shared by the web UI and the API.
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
__construct(ServiceOptions $config, LoggerInterface $logger, AuthManager $authManager, HookContainer $hookContainer, IConnectionProvider $dbProvider, UserFactory $userFactory, UserNameUtils $userNameUtils, UserOptionsLookup $userOptionsLookup)
This class is managed by MediaWikiServices, don't instantiate directly.
isAllowed(User $user)
Check if a given user has permission to use this functionality.
Creates User objects.
Definition: UserFactory.php:41
UserNameUtils service.
Provides access to user options.
internal since 1.36
Definition: User.php:98
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition: User.php:1633
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:1507
isAllowed(string $permission, PermissionStatus $status=null)
Checks whether this authority has the given permission in general.
Definition: User.php:2363
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:2412
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition: User.php:1462
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:1666
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:144
static newFromSpecifier( $value)
Transform a MessageSpecifier or a primitive value used interchangeably with specifiers (a message key...
Definition: Message.php:427
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:46
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
Provide primary and replica IDatabase connections.
Utility class for bot passwords.
Definition: ActorCache.php:21