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 
71  public const CONSTRUCTOR_OPTIONS = [
72  'AllowRequiringEmailForResets',
73  'EnableEmail',
74  'PasswordResetRoutes',
75  ];
76 
87  public function __construct(
88  $config,
92  LoggerInterface $logger = null,
94  ) {
95  $this->config = $config;
96  $this->authManager = $authManager;
97  $this->permissionManager = $permissionManager;
98 
99  if ( !$loadBalancer ) {
100  wfDeprecated( 'Not passing LoadBalancer to ' . __METHOD__, '1.34' );
101  $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
102  }
103  $this->loadBalancer = $loadBalancer;
104 
105  if ( !$logger ) {
106  wfDeprecated( 'Not passing LoggerInterface to ' . __METHOD__, '1.34' );
107  $logger = LoggerFactory::getInstance( 'authentication' );
108  }
109  $this->logger = $logger;
110 
111  if ( !$hookContainer ) {
112  $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
113  }
114  $this->hookContainer = $hookContainer;
115  $this->hookRunner = new HookRunner( $hookContainer );
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 ( !$this->permissionManager->userHasRight( $user, '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, $username = null, $email = null
179  ) {
180  if ( !$this->isAllowed( $performingUser )->isGood() ) {
181  throw new LogicException( 'User ' . $performingUser->getName()
182  . ' is not allowed to reset passwords' );
183  }
184 
185  // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
186  // that the request was good to avoid displaying an error message.
187  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
188  return StatusValue::newGood();
189  }
190 
191  // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
192  // we should send the user's name if they're logged in.
193  $ip = $performingUser->getRequest()->getIP();
194  if ( !$ip ) {
195  return StatusValue::newFatal( 'badipaddress' );
196  }
197 
198  $username = $username ?? '';
199  $email = $email ?? '';
200 
201  $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
202  + [ 'username' => false, 'email' => false ];
203  if ( $resetRoutes['username'] && $username ) {
204  $method = 'username';
205  $users = [ $this->lookupUser( $username ) ];
206  } elseif ( $resetRoutes['email'] && $email ) {
207  if ( !Sanitizer::validateEmail( $email ) ) {
208  // Only email was supplied but not valid: pretend everything's fine.
209  return StatusValue::newGood();
210  }
211  // Only email was provided
212  $method = 'email';
213  $users = $this->getUsersByEmail( $email );
214  $username = null;
215  // Remove users whose preference 'requireemail' is on since username was not submitted
216  if ( $this->config->get( 'AllowRequiringEmailForResets' ) ) {
217  foreach ( $users as $index => $user ) {
218  if ( $user->getBoolOption( 'requireemail' ) ) {
219  unset( $users[$index] );
220  }
221  }
222  }
223  } else {
224  // The user didn't supply any data
225  return StatusValue::newFatal( 'passwordreset-nodata' );
226  }
227 
228  // If the username is not valid, tell the user.
229  if ( $username && !User::getCanonicalName( $username ) ) {
230  return StatusValue::newFatal( 'noname' );
231  }
232 
233  // Check for hooks (captcha etc), and allow them to modify the users list
234  $error = [];
235  $data = [
236  'Username' => $username,
237  // Email gets set to null for backward compatibility
238  'Email' => $method === 'email' ? $email : null,
239  ];
240 
241  // Recreate the $users array with its values so that we reset the numeric keys since
242  // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
243  // hook assumes that index '0' is defined if $users is not empty.
244  $users = array_values( $users );
245 
246  if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
248  }
249 
250  // Get the first element in $users by using `reset` function just in case $users is changed
251  // in 'SpecialPasswordResetOnSubmit' hook.
252  $firstUser = reset( $users ) ?? null;
253 
254  $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
255  && $method === 'username'
256  && $firstUser
257  && $firstUser->getBoolOption( 'requireemail' );
258  if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
259  // Email is required, and not supplied or not valid: pretend everything's fine.
260  return StatusValue::newGood();
261  }
262 
263  if ( !$users ) {
264  if ( $method === 'email' ) {
265  // Don't reveal whether or not an email address is in use
266  return StatusValue::newGood();
267  } else {
268  return StatusValue::newFatal( 'noname' );
269  }
270  }
271 
272  // If the user doesn't exist, or if the user doesn't have an email address,
273  // don't disclose the information. We want to pretend everything is ok per T238961.
274  // Note that all the users will have the same email address (or none),
275  // so there's no need to check more than the first.
276  if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
277  return StatusValue::newGood();
278  }
279 
280  // Email is required but the email doesn't match: pretend everything's fine.
281  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
282  return StatusValue::newGood();
283  }
284 
285  $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
286 
287  $result = StatusValue::newGood();
288  $reqs = [];
289  foreach ( $users as $user ) {
290  $req = TemporaryPasswordAuthenticationRequest::newRandom();
291  $req->username = $user->getName();
292  $req->mailpassword = true;
293  $req->caller = $performingUser->getName();
294 
295  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
296  // If status is good and the value is 'throttled-mailpassword', we want to pretend
297  // that the request was good to avoid displaying an error message and disclose
298  // if a reset password was previously sent.
299  if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
300  return StatusValue::newGood();
301  }
302 
303  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
304  $reqs[] = $req;
305  } elseif ( $result->isGood() ) {
306  // only record the first error, to avoid exposing the number of users having the
307  // same email address
308  if ( $status->getValue() === 'ignored' ) {
309  $status = StatusValue::newFatal( 'passwordreset-ignored' );
310  }
311  $result->merge( $status );
312  }
313  }
314 
315  $logContext = [
316  'requestingIp' => $ip,
317  'requestingUser' => $performingUser->getName(),
318  'targetUsername' => $username,
319  'targetEmail' => $email,
320  ];
321 
322  if ( !$result->isGood() ) {
323  $this->logger->info(
324  "{requestingUser} attempted password reset of {actualUser} but failed",
325  $logContext + [ 'errors' => $result->getErrors() ]
326  );
327  return $result;
328  }
329 
331  new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
333  );
334 
335  return StatusValue::newGood();
336  }
337 
345  protected function isBlocked( User $user ) {
346  $block = $user->getBlock() ?: $user->getGlobalBlock();
347  if ( !$block ) {
348  return false;
349  }
350  return $block->appliesToPasswordReset();
351  }
352 
358  protected function getUsersByEmail( $email ) {
359  $userQuery = User::getQueryInfo();
360  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
361  $userQuery['tables'],
362  $userQuery['fields'],
363  [ 'user_email' => $email ],
364  __METHOD__,
365  [],
366  $userQuery['joins']
367  );
368 
369  if ( !$res ) {
370  // Some sort of database error, probably unreachable
371  throw new MWException( 'Unknown database error in ' . __METHOD__ );
372  }
373 
374  $users = [];
375  foreach ( $res as $row ) {
376  $users[] = User::newFromRow( $row );
377  }
378  return $users;
379  }
380 
388  protected function lookupUser( $username ) {
389  return User::newFromName( $username );
390  }
391 }
PasswordReset\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: PasswordReset.php:71
Message\newFromSpecifier
static newFromSpecifier( $value)
Transform a MessageSpecifier or a primitive value used interchangeably with specifiers (a message key...
Definition: Message.php:434
StatusValue\newFatal
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:69
User\getId
getId()
Get the user's ID.
Definition: User.php:2079
PasswordReset\$hookRunner
HookRunner $hookRunner
Definition: PasswordReset.php:62
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:149
PasswordReset\getUsersByEmail
getUsersByEmail( $email)
Definition: PasswordReset.php:358
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:1980
User\newFromName
static newFromName( $name, $validate='valid')
Static factory method for creation from username.
Definition: User.php:534
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:1794
$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:715
User\getRequest
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3255
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:87
Config
Interface for configuration instances.
Definition: Config.php:28
MWException
MediaWiki exception.
Definition: MWException.php:26
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:25
wfDeprecated
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that $function is deprecated.
Definition: GlobalFunctions.php:1030
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:388
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:1949
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:126
StatusValue\newGood
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:81
User\getGlobalBlock
getGlobalBlock( $ip='')
Check if user is blocked on all wikis.
Definition: User.php:2021
User\getQueryInfo
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition: User.php:4613
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:1126
MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
This represents the intention to set a temporary password for the user.
Definition: TemporaryPasswordAuthenticationRequest.php:31
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:563
PasswordReset\$permissionManager
PermissionManager $permissionManager
Definition: PasswordReset.php:53
PasswordReset\isBlocked
isBlocked(User $user)
Check whether the user is blocked.
Definition: PasswordReset.php:345
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: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:2108
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81