MediaWiki  master
PasswordReset.php
Go to the documentation of this file.
1 <?php
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  $method = 'email';
183  $users = $this->getUsersByEmail( $email );
184  $username = null;
185  } else {
186  // The user didn't supply any data
187  return StatusValue::newFatal( 'passwordreset-nodata' );
188  }
189 
190  // Check for hooks (captcha etc), and allow them to modify the users list
191  $error = [];
192  $data = [
193  'Username' => $username,
194  // Email gets set to null for backward compatibility
195  'Email' => $method === 'email' ? $email : null,
196  ];
197  if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
199  }
200 
201  $firstUser = $users[0] ?? null;
202  $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
203  && $method === 'username'
204  && $firstUser
205  && $firstUser->getBoolOption( 'requireemail' );
206  if ( $requireEmail ) {
207  if ( $email === '' ) {
208  return StatusValue::newFatal( 'passwordreset-username-email-required' );
209  }
210 
211  if ( !Sanitizer::validateEmail( $email ) ) {
212  return StatusValue::newFatal( 'passwordreset-invalidemail' );
213  }
214  }
215 
216  // Check against the rate limiter
217  if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
218  return StatusValue::newFatal( 'actionthrottledtext' );
219  }
220 
221  if ( !$users ) {
222  if ( $method === 'email' ) {
223  // Don't reveal whether or not an email address is in use
224  return StatusValue::newGood( [] );
225  } else {
226  return StatusValue::newFatal( 'noname' );
227  }
228  }
229 
230  if ( !$firstUser instanceof User || !$firstUser->getId() ) {
231  // Don't parse username as wikitext (T67501)
232  return StatusValue::newFatal( wfMessage( 'nosuchuser', wfEscapeWikiText( $username ) ) );
233  }
234 
235  // All the users will have the same email address
236  if ( !$firstUser->getEmail() ) {
237  // This won't be reachable from the email route, so safe to expose the username
238  return StatusValue::newFatal( wfMessage( 'noemail',
239  wfEscapeWikiText( $firstUser->getName() ) ) );
240  }
241 
242  if ( $requireEmail && $firstUser->getEmail() !== $email ) {
243  // Pretend everything's fine to avoid disclosure
244  return StatusValue::newGood();
245  }
246 
247  // We need to have a valid IP address for the hook, but per T20347, we should
248  // send the user's name if they're logged in.
249  $ip = $performingUser->getRequest()->getIP();
250  if ( !$ip ) {
251  return StatusValue::newFatal( 'badipaddress' );
252  }
253 
254  Hooks::run( 'User::mailPasswordInternal', [ &$performingUser, &$ip, &$firstUser ] );
255 
256  $result = StatusValue::newGood();
257  $reqs = [];
258  foreach ( $users as $user ) {
259  $req = TemporaryPasswordAuthenticationRequest::newRandom();
260  $req->username = $user->getName();
261  $req->mailpassword = true;
262  $req->caller = $performingUser->getName();
263  $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
264  if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
265  $reqs[] = $req;
266  } elseif ( $result->isGood() ) {
267  // only record the first error, to avoid exposing the number of users having the
268  // same email address
269  if ( $status->getValue() === 'ignored' ) {
270  $status = StatusValue::newFatal( 'passwordreset-ignored' );
271  }
272  $result->merge( $status );
273  }
274  }
275 
276  $logContext = [
277  'requestingIp' => $ip,
278  'requestingUser' => $performingUser->getName(),
279  'targetUsername' => $username,
280  'targetEmail' => $email,
281  'actualUser' => $firstUser->getName(),
282  ];
283 
284  if ( !$result->isGood() ) {
285  $this->logger->info(
286  "{requestingUser} attempted password reset of {actualUser} but failed",
287  $logContext + [ 'errors' => $result->getErrors() ]
288  );
289  return $result;
290  }
291 
292  $passwords = [];
293  foreach ( $reqs as $req ) {
294  // This is adding a new temporary password, not intentionally changing anything
295  // (even though it might technically invalidate an old temporary password).
296  $this->authManager->changeAuthenticationData( $req, /* $isAddition */ true );
297  }
298 
299  $this->logger->info(
300  "{requestingUser} did password reset of {actualUser}",
301  $logContext
302  );
303 
304  return StatusValue::newGood( $passwords );
305  }
306 
314  protected function isBlocked( User $user ) {
315  $block = $user->getBlock() ?: $user->getGlobalBlock();
316  if ( !$block ) {
317  return false;
318  }
319  return $block->appliesToPasswordReset();
320  }
321 
327  protected function getUsersByEmail( $email ) {
328  $userQuery = User::getQueryInfo();
329  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
330  $userQuery['tables'],
331  $userQuery['fields'],
332  [ 'user_email' => $email ],
333  __METHOD__,
334  [],
335  $userQuery['joins']
336  );
337 
338  if ( !$res ) {
339  // Some sort of database error, probably unreachable
340  throw new MWException( 'Unknown database error in ' . __METHOD__ );
341  }
342 
343  $users = [];
344  foreach ( $res as $row ) {
345  $users[] = User::newFromRow( $row );
346  }
347  return $users;
348  }
349 
357  protected function lookupUser( $username ) {
358  return User::newFromName( $username );
359  }
360 }
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:69
ServiceOptions Config $config
wfEscapeWikiText( $text)
Escapes the given text so that it may be output using addWikiText() without any linking, formatting, etc.
getBlock( $fromReplica=true)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:2065
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object...
Definition: User.php:5249
ILoadBalancer $loadBalancer
getGlobalBlock( $ip='')
Check if user is blocked on all wikis.
Definition: User.php:2135
__construct( $config, AuthManager $authManager, PermissionManager $permissionManager, ILoadBalancer $loadBalancer=null, LoggerInterface $logger=null)
This class is managed by MediaWikiServices, don&#39;t instantiate directly.
isAllowed(User $user)
Check if a given user has permission to use this functionality.
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:2229
const CONSTRUCTOR_OPTIONS
static validateEmail( $addr)
Does a string look like an e-mail address?
Definition: Sanitizer.php:2165
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
PermissionManager $permissionManager
isBlocked(User $user)
Check whether the user is blocked.
AuthManager $authManager
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:81
This serves as the entry point to the authentication system.
Definition: AuthManager.php:85
This represents the intention to set a temporary password for the user.
MapCacheLRU $permissionCache
In-process cache for isAllowed lookups, by username.
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3628
Database cluster connection, tracking, load balancing, and transaction manager interface.
getId()
Get the user&#39;s ID.
Definition: User.php:2200
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition: User.php:1908
static newFromSpecifier( $value)
Transform a MessageSpecifier or a primitive value used interchangeably with specifiers (a message key...
Definition: Message.php:427
static newFromRow( $row, $data=null)
Create a new user object from a user row.
Definition: User.php:696
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
getUsersByEmail( $email)
const DB_REPLICA
Definition: defines.php:25
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
static newFromName( $name, $validate='valid')
Static factory method for creation from username.
Definition: User.php:515
lookupUser( $username)
User object creation helper for testability.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200