MediaWiki REL1_37
PasswordReset.php
Go to the documentation of this file.
1<?php
31use Psr\Log\LoggerAwareInterface;
32use Psr\Log\LoggerAwareTrait;
33use Psr\Log\LoggerInterface;
35
43class PasswordReset implements LoggerAwareInterface {
44 use LoggerAwareTrait;
45
47 private $config;
48
50 private $authManager;
51
53 private $hookRunner;
54
57
59 private $userFactory;
60
63
66
73
77 public const CONSTRUCTOR_OPTIONS = [
78 'AllowRequiringEmailForResets',
79 'EnableEmail',
80 'PasswordResetRoutes',
81 ];
82
95 public function __construct(
96 ServiceOptions $config,
97 LoggerInterface $logger,
98 AuthManager $authManager,
99 HookContainer $hookContainer,
100 ILoadBalancer $loadBalancer,
101 UserFactory $userFactory,
102 UserNameUtils $userNameUtils,
104 ) {
105 $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
106
107 $this->config = $config;
108 $this->logger = $logger;
109
110 $this->authManager = $authManager;
111 $this->hookRunner = new HookRunner( $hookContainer );
112 $this->loadBalancer = $loadBalancer;
113 $this->userFactory = $userFactory;
114 $this->userNameUtils = $userNameUtils;
115 $this->userOptionsLookup = $userOptionsLookup;
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 ( !$user->isAllowed( '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,
179 $username = null,
180 $email = null
181 ) {
182 if ( !$this->isAllowed( $performingUser )->isGood() ) {
183 throw new LogicException(
184 'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
185 );
186 }
187
188 // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
189 // that the request was good to avoid displaying an error message.
190 if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
191 return StatusValue::newGood();
192 }
193
194 // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
195 // we should send the user's name if they're logged in.
196 $ip = $performingUser->getRequest()->getIP();
197 if ( !$ip ) {
198 return StatusValue::newFatal( 'badipaddress' );
199 }
200
201 $username = $username ?? '';
202 $email = $email ?? '';
203
204 $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
205 + [ 'username' => false, 'email' => false ];
206 if ( $resetRoutes['username'] && $username ) {
207 $method = 'username';
208 $users = [ $this->userFactory->newFromName( $username ) ];
209 } elseif ( $resetRoutes['email'] && $email ) {
210 if ( !Sanitizer::validateEmail( $email ) ) {
211 // Only email was supplied but not valid: pretend everything's fine.
212 return StatusValue::newGood();
213 }
214 // Only email was provided
215 $method = 'email';
216 $users = $this->getUsersByEmail( $email );
217 $username = null;
218 // Remove users whose preference 'requireemail' is on since username was not submitted
219 if ( $this->config->get( 'AllowRequiringEmailForResets' ) ) {
220 $optionsLookup = $this->userOptionsLookup;
221 foreach ( $users as $index => $user ) {
222 if ( $optionsLookup->getBoolOption( $user, 'requireemail' ) ) {
223 unset( $users[$index] );
224 }
225 }
226 }
227 } else {
228 // The user didn't supply any data
229 return StatusValue::newFatal( 'passwordreset-nodata' );
230 }
231
232 // If the username is not valid, tell the user.
233 if ( $username && !$this->userNameUtils->getCanonical( $username ) ) {
234 return StatusValue::newFatal( 'noname' );
235 }
236
237 // Check for hooks (captcha etc), and allow them to modify the users list
238 $error = [];
239 $data = [
240 'Username' => $username,
241 // Email gets set to null for backward compatibility
242 'Email' => $method === 'email' ? $email : null,
243 ];
244
245 // Recreate the $users array with its values so that we reset the numeric keys since
246 // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
247 // hook assumes that index '0' is defined if $users is not empty.
248 $users = array_values( $users );
249
250 if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
251 return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
252 }
253
254 // Get the first element in $users by using `reset` function just in case $users is changed
255 // in 'SpecialPasswordResetOnSubmit' hook.
256 $firstUser = reset( $users );
257
258 $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
259 && $method === 'username'
260 && $firstUser
261 && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' );
262 if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
263 // Email is required, and not supplied or not valid: pretend everything's fine.
264 return StatusValue::newGood();
265 }
266
267 if ( !$users ) {
268 if ( $method === 'email' ) {
269 // Don't reveal whether or not an email address is in use
270 return StatusValue::newGood();
271 } else {
272 return StatusValue::newFatal( 'noname' );
273 }
274 }
275
276 // If the user doesn't exist, or if the user doesn't have an email address,
277 // don't disclose the information. We want to pretend everything is ok per T238961.
278 // Note that all the users will have the same email address (or none),
279 // so there's no need to check more than the first.
280 if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
281 return StatusValue::newGood();
282 }
283
284 // Email is required but the email doesn't match: pretend everything's fine.
285 if ( $requireEmail && $firstUser->getEmail() !== $email ) {
286 return StatusValue::newGood();
287 }
288
289 $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
290
291 $result = StatusValue::newGood();
292 $reqs = [];
293 foreach ( $users as $user ) {
294 $req = TemporaryPasswordAuthenticationRequest::newRandom();
295 $req->username = $user->getName();
296 $req->mailpassword = true;
297 $req->caller = $performingUser->getName();
298
299 $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
300 // If status is good and the value is 'throttled-mailpassword', we want to pretend
301 // that the request was good to avoid displaying an error message and disclose
302 // if a reset password was previously sent.
303 if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
304 return StatusValue::newGood();
305 }
306
307 if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
308 $reqs[] = $req;
309 } elseif ( $result->isGood() ) {
310 // only record the first error, to avoid exposing the number of users having the
311 // same email address
312 if ( $status->getValue() === 'ignored' ) {
313 $status = StatusValue::newFatal( 'passwordreset-ignored' );
314 }
315 $result->merge( $status );
316 }
317 }
318
319 $logContext = [
320 'requestingIp' => $ip,
321 'requestingUser' => $performingUser->getName(),
322 'targetUsername' => $username,
323 'targetEmail' => $email,
324 ];
325
326 if ( !$result->isGood() ) {
327 $this->logger->info(
328 "{requestingUser} attempted password reset of {actualUser} but failed",
329 $logContext + [ 'errors' => $result->getErrors() ]
330 );
331 return $result;
332 }
333
334 DeferredUpdates::addUpdate(
335 new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
336 DeferredUpdates::POSTSEND
337 );
338
339 return StatusValue::newGood();
340 }
341
349 private function isBlocked( User $user ) {
350 $block = $user->getBlock() ?: $user->getGlobalBlock();
351 if ( !$block ) {
352 return false;
353 }
354 return $block->appliesToPasswordReset();
355 }
356
364 protected function getUsersByEmail( $email ) {
365 $userQuery = User::getQueryInfo();
366 $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
367 $userQuery['tables'],
368 $userQuery['fields'],
369 [ 'user_email' => $email ],
370 __METHOD__,
371 [],
372 $userQuery['joins']
373 );
374
375 if ( !$res ) {
376 // Some sort of database error, probably unreachable
377 throw new MWException( 'Unknown database error in ' . __METHOD__ );
378 }
379
380 $users = [];
381 foreach ( $res as $row ) {
382 $users[] = $this->userFactory->newFromRow( $row );
383 }
384 return $users;
385 }
386
387}
UserOptionsLookup $userOptionsLookup
MediaWiki exception.
Handles a simple LRU key/value map with a maximum number of entries.
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...
Creates User objects.
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:414
Helper class for the password reset functionality shared by the web UI and the API.
isBlocked(User $user)
Check whether the user is blocked.
isAllowed(User $user)
Check if a given user has permission to use this functionality.
ServiceOptions $config
HookRunner $hookRunner
UserFactory $userFactory
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
ILoadBalancer $loadBalancer
AuthManager $authManager
__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.
UserOptionsLookup $userOptionsLookup
const CONSTRUCTOR_OPTIONS
UserNameUtils $userNameUtils
MapCacheLRU $permissionCache
In-process cache for isAllowed lookups, by username.
getUsersByEmail( $email)
Sends emails to all accounts associated with that email to reset the password.
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:69
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition User.php:1941
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:3075
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:2116
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition User.php:4182
getGlobalBlock( $ip='')
Check if user is blocked on all wikis.
Definition User.php:2024
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition User.php:2083
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition User.php:1689
isAllowed(string $permission)
Checks whether this authority has the given permission in general.
Definition User.php:3033
Database cluster connection, tracking, load balancing, and transaction manager interface.
const DB_REPLICA
Definition defines.php:25