MediaWiki  master
PasswordReset.php
Go to the documentation of this file.
1 <?php
29 use Psr\Log\LoggerAwareInterface;
30 use Psr\Log\LoggerAwareTrait;
31 use Psr\Log\LoggerInterface;
33 
41 class PasswordReset implements LoggerAwareInterface {
42  use LoggerAwareTrait;
43 
45  protected $config;
46 
48  protected $authManager;
49 
51  protected $permissionManager;
52 
54  protected $loadBalancer;
55 
62 
63  public const CONSTRUCTOR_OPTIONS = [
64  'AllowRequiringEmailForResets',
65  'EnableEmail',
66  'PasswordResetRoutes',
67  ];
68 
78  public function __construct(
79  $config,
83  LoggerInterface $logger = null
84  ) {
85  $this->config = $config;
86  $this->authManager = $authManager;
87  $this->permissionManager = $permissionManager;
88 
89  if ( !$loadBalancer ) {
90  wfDeprecated( 'Not passing LoadBalancer to ' . __METHOD__, '1.34' );
91  $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
92  }
93  $this->loadBalancer = $loadBalancer;
94 
95  if ( !$logger ) {
96  wfDeprecated( 'Not passing LoggerInterface to ' . __METHOD__, '1.34' );
97  $logger = LoggerFactory::getInstance( 'authentication' );
98  }
99  $this->logger = $logger;
100 
101  $this->permissionCache = new MapCacheLRU( 1 );
102  }
103 
110  public function isAllowed( User $user ) {
111  $status = $this->permissionCache->get( $user->getName() );
112  if ( !$status ) {
113  $resetRoutes = $this->config->get( 'PasswordResetRoutes' );
114  $status = StatusValue::newGood();
115 
116  if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
117  // Maybe password resets are disabled, or there are no allowable routes
118  $status = StatusValue::newFatal( 'passwordreset-disabled' );
119  } elseif (
120  ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
122  && !$providerStatus->isGood()
123  ) {
124  // Maybe the external auth plugin won't allow local password changes
125  $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
126  $providerStatus->getMessage() );
127  } elseif ( !$this->config->get( 'EnableEmail' ) ) {
128  // Maybe email features have been disabled
129  $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
130  } elseif ( !$this->permissionManager->userHasRight( $user, 'editmyprivateinfo' ) ) {
131  // Maybe not all users have permission to change private data
132  $status = StatusValue::newFatal( 'badaccess' );
133  } elseif ( $this->isBlocked( $user ) ) {
134  // Maybe the user is blocked (check this here rather than relying on the parent
135  // method as we have a more specific error message to use here and we want to
136  // ignore some types of blocks)
137  $status = StatusValue::newFatal( 'blocked-mailpassword' );
138  }
139 
140  $this->permissionCache->set( $user->getName(), $status );
141  }
142 
143  return $status;
144  }
145 
162  public function execute(
163  User $performingUser, $username = null, $email = null
164  ) {
165  if ( !$this->isAllowed( $performingUser )->isGood() ) {
166  throw new LogicException( 'User ' . $performingUser->getName()
167  . ' is not allowed to reset passwords' );
168  }
169 
170  $username = $username ?? '';
171  $email = $email ?? '';
172 
173  $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
174  + [ 'username' => false, 'email' => false ];
175  if ( $resetRoutes['username'] && $username ) {
176  $method = 'username';
177  $users = [ $this->lookupUser( $username ) ];
178  } elseif ( $resetRoutes['email'] && $email ) {
179  if ( !Sanitizer::validateEmail( $email ) ) {
180  return StatusValue::newFatal( 'passwordreset-invalidemail' );
181  }
182  // Only email was provided
183  $method = 'email';
184  $users = $this->getUsersByEmail( $email );
185  $username = null;
186  // Remove users whose preference 'requireemail' is on since username was not submitted
187  if ( $this->config->get( 'AllowRequiringEmailForResets' ) ) {
188  foreach ( $users as $index => $user ) {
189  if ( $user->getBoolOption( 'requireemail' ) ) {
190  unset( $users[$index] );
191  }
192  }
193  }
194  } else {
195  // The user didn't supply any data
196  return StatusValue::newFatal( 'passwordreset-nodata' );
197  }
198 
199  // Check for hooks (captcha etc), and allow them to modify the users list
200  $error = [];
201  $data = [
202  'Username' => $username,
203  // Email gets set to null for backward compatibility
204  'Email' => $method === 'email' ? $email : null,
205  ];
206  if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
207  return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
208  }
209  // Use 'reset' since users[ 0 ] might not exist because users with 'requireemail' option
210  // turned on might have been unset from users array
211  $firstUser = reset( $users ) ?? null;
212  $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
213  && $method === 'username'
214  && $firstUser
215  && $firstUser->getBoolOption( 'requireemail' );
216  if ( $requireEmail ) {
217  if ( $email === '' ) {
218  return StatusValue::newFatal( 'passwordreset-username-email-required' );
219  }
220 
221  if ( !Sanitizer::validateEmail( $email ) ) {
222  return StatusValue::newFatal( 'passwordreset-invalidemail' );
223  }
224  }
225 
226  // Check against the rate limiter
227  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
228  return StatusValue::newFatal( 'actionthrottledtext' );
229  }
230 
231  if ( !$users ) {
232  if ( $method === 'email' ) {
233  // Don't reveal whether or not an email address is in use
234  return StatusValue::newGood( [] );
235  } else {
236  return StatusValue::newFatal( 'noname' );
237  }
238  }
239 
240  if ( !$firstUser instanceof User || !$firstUser->getId() ) {
241  // Don't parse username as wikitext (T67501)
242  return StatusValue::newFatal( wfMessage( 'nosuchuser', wfEscapeWikiText( $username ) ) );
243  }
244 
245  // All the users will have the same email address
246  if ( !$firstUser->getEmail() ) {
247  // This won't be reachable from the email route, so safe to expose the username
248  return StatusValue::newFatal( wfMessage( 'noemail',
249  wfEscapeWikiText( $firstUser->getName() ) ) );
250  }
251 
252  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
253  // Pretend everything's fine to avoid disclosure but do not send email
254  return StatusValue::newGood();
255  }
256 
257  // We need to have a valid IP address for the hook, but per T20347, we should
258  // send the user's name if they're logged in.
259  $ip = $performingUser->getRequest()->getIP();
260  if ( !$ip ) {
261  return StatusValue::newFatal( 'badipaddress' );
262  }
263 
264  Hooks::run( 'User::mailPasswordInternal', [ &$performingUser, &$ip, &$firstUser ] );
265 
266  $result = StatusValue::newGood();
267  $reqs = [];
268  foreach ( $users as $user ) {
269  $req = TemporaryPasswordAuthenticationRequest::newRandom();
270  $req->username = $user->getName();
271  $req->mailpassword = true;
272  $req->caller = $performingUser->getName();
273  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
274  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
275  $reqs[] = $req;
276  } elseif ( $result->isGood() ) {
277  // only record the first error, to avoid exposing the number of users having the
278  // same email address
279  if ( $status->getValue() === 'ignored' ) {
280  $status = StatusValue::newFatal( 'passwordreset-ignored' );
281  }
282  $result->merge( $status );
283  }
284  }
285 
286  $logContext = [
287  'requestingIp' => $ip,
288  'requestingUser' => $performingUser->getName(),
289  'targetUsername' => $username,
290  'targetEmail' => $email,
291  'actualUser' => $firstUser->getName(),
292  ];
293 
294  if ( !$result->isGood() ) {
295  $this->logger->info(
296  "{requestingUser} attempted password reset of {actualUser} but failed",
297  $logContext + [ 'errors' => $result->getErrors() ]
298  );
299  return $result;
300  }
301 
302  $passwords = [];
303  foreach ( $reqs as $req ) {
304  // This is adding a new temporary password, not intentionally changing anything
305  // (even though it might technically invalidate an old temporary password).
306  $this->authManager->changeAuthenticationData( $req, /* $isAddition */ true );
307  }
308 
309  $this->logger->info(
310  "{requestingUser} did password reset of {actualUser}",
311  $logContext
312  );
313 
314  return StatusValue::newGood( $passwords );
315  }
316 
324  protected function isBlocked( User $user ) {
325  $block = $user->getBlock() ?: $user->getGlobalBlock();
326  if ( !$block ) {
327  return false;
328  }
329  return $block->appliesToPasswordReset();
330  }
331 
337  protected function getUsersByEmail( $email ) {
338  $userQuery = User::getQueryInfo();
339  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
340  $userQuery['tables'],
341  $userQuery['fields'],
342  [ 'user_email' => $email ],
343  __METHOD__,
344  [],
345  $userQuery['joins']
346  );
347 
348  if ( !$res ) {
349  // Some sort of database error, probably unreachable
350  throw new MWException( 'Unknown database error in ' . __METHOD__ );
351  }
352 
353  $users = [];
354  foreach ( $res as $row ) {
355  $users[] = User::newFromRow( $row );
356  }
357  return $users;
358  }
359 
367  protected function lookupUser( $username ) {
368  return User::newFromName( $username );
369  }
370 }
PasswordReset\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: PasswordReset.php:63
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:2253
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:129
PasswordReset\getUsersByEmail
getUsersByEmail( $email)
Definition: PasswordReset.php:337
PasswordReset\$config
ServiceOptions Config $config
Definition: PasswordReset.php:45
User\newFromName
static newFromName( $name, $validate='valid')
Static factory method for creation from username.
Definition: User.php:536
wfMessage
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Definition: GlobalFunctions.php:1263
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:1959
$res
$res
Definition: testCompression.php:54
User\newFromRow
static newFromRow( $row, $data=null)
Create a new user object from a user row.
Definition: User.php:717
User\getRequest
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3700
PasswordReset\$permissionCache
MapCacheLRU $permissionCache
In-process cache for isAllowed lookups, by username.
Definition: PasswordReset.php:61
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)
Throws a warning that $function is deprecated.
Definition: GlobalFunctions.php:1044
MediaWiki\Logger\LoggerFactory
PSR-3 logger instance factory.
Definition: LoggerFactory.php:45
PasswordReset\$loadBalancer
ILoadBalancer $loadBalancer
Definition: PasswordReset.php:54
PasswordReset\lookupUser
lookupUser( $username)
User object creation helper for testability.
Definition: PasswordReset.php:367
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\getBlock
getBlock( $fromReplica=true)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:2116
MediaWiki\Permissions\PermissionManager
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Definition: PermissionManager.php:48
PasswordReset\__construct
__construct( $config, AuthManager $authManager, PermissionManager $permissionManager, ILoadBalancer $loadBalancer=null, LoggerInterface $logger=null)
This class is managed by MediaWikiServices, don't instantiate directly.
Definition: PasswordReset.php:78
PasswordReset\isAllowed
isAllowed(User $user)
Check if a given user has permission to use this functionality.
Definition: PasswordReset.php:110
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:2188
wfEscapeWikiText
wfEscapeWikiText( $text)
Escapes the given text so that it may be output using addWikiText() without any linking,...
Definition: GlobalFunctions.php:1550
User\getQueryInfo
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition: User.php:5322
MediaWiki\Auth\AuthManager
This serves as the entry point to the authentication system.
Definition: AuthManager.php:85
MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
This represents the intention to set a temporary password for the user.
Definition: TemporaryPasswordAuthenticationRequest.php:31
PasswordReset\$permissionManager
PermissionManager $permissionManager
Definition: PasswordReset.php:51
PasswordReset\isBlocked
isBlocked(User $user)
Check whether the user is blocked.
Definition: PasswordReset.php:324
PasswordReset\$authManager
AuthManager $authManager
Definition: PasswordReset.php:48
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:51
Hooks\run
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
PasswordReset\execute
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
Definition: PasswordReset.php:162
PasswordReset
Helper class for the password reset functionality shared by the web UI and the API.
Definition: PasswordReset.php:41
User\getName
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:2282
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81