MediaWiki REL1_40
PasswordReset.php
Go to the documentation of this file.
1<?php
32use Psr\Log\LoggerAwareInterface;
33use Psr\Log\LoggerAwareTrait;
34use Psr\Log\LoggerInterface;
36
44class PasswordReset implements LoggerAwareInterface {
45 use LoggerAwareTrait;
46
48 private $config;
49
51 private $authManager;
52
54 private $hookRunner;
55
57 private $loadBalancer;
58
60 private $userFactory;
61
63 private $userNameUtils;
64
66 private $userOptionsLookup;
67
73 private $permissionCache;
74
78 public const CONSTRUCTOR_OPTIONS = [
79 MainConfigNames::AllowRequiringEmailForResets,
80 MainConfigNames::EnableEmail,
81 MainConfigNames::PasswordResetRoutes,
82 ];
83
96 public function __construct(
97 ServiceOptions $config,
98 LoggerInterface $logger,
99 AuthManager $authManager,
100 HookContainer $hookContainer,
101 ILoadBalancer $loadBalancer,
102 UserFactory $userFactory,
103 UserNameUtils $userNameUtils,
104 UserOptionsLookup $userOptionsLookup
105 ) {
106 $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
107
108 $this->config = $config;
109 $this->logger = $logger;
110
111 $this->authManager = $authManager;
112 $this->hookRunner = new HookRunner( $hookContainer );
113 $this->loadBalancer = $loadBalancer;
114 $this->userFactory = $userFactory;
115 $this->userNameUtils = $userNameUtils;
116 $this->userOptionsLookup = $userOptionsLookup;
117
118 $this->permissionCache = new MapCacheLRU( 1 );
119 }
120
127 public function isAllowed( User $user ) {
128 return $this->permissionCache->getWithSetCallback(
129 $user->getName(),
130 function () use ( $user ) {
131 return $this->computeIsAllowed( $user );
132 }
133 );
134 }
135
140 private function computeIsAllowed( User $user ): StatusValue {
141 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes );
142 $status = StatusValue::newGood();
143
144 if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
145 // Maybe password resets are disabled, or there are no allowable routes
146 $status = StatusValue::newFatal( 'passwordreset-disabled' );
147 } elseif (
148 ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
150 && !$providerStatus->isGood()
151 ) {
152 // Maybe the external auth plugin won't allow local password changes
153 $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
154 $providerStatus->getMessage() );
155 } elseif ( !$this->config->get( MainConfigNames::EnableEmail ) ) {
156 // Maybe email features have been disabled
157 $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
158 } elseif ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
159 // Maybe not all users have permission to change private data
160 $status = StatusValue::newFatal( 'badaccess' );
161 } elseif ( $this->isBlocked( $user ) ) {
162 // Maybe the user is blocked (check this here rather than relying on the parent
163 // method as we have a more specific error message to use here and we want to
164 // ignore some types of blocks)
165 $status = StatusValue::newFatal( 'blocked-mailpassword' );
166 }
167 return $status;
168 }
169
185 public function execute(
186 User $performingUser,
187 $username = null,
188 $email = null
189 ) {
190 if ( !$this->isAllowed( $performingUser )->isGood() ) {
191 throw new LogicException(
192 'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
193 );
194 }
195
196 // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
197 // that the request was good to avoid displaying an error message.
198 if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
199 return StatusValue::newGood();
200 }
201
202 // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
203 // we should send the user's name if they're logged in.
204 $ip = $performingUser->getRequest()->getIP();
205 if ( !$ip ) {
206 return StatusValue::newFatal( 'badipaddress' );
207 }
208
209 $username ??= '';
210 $email ??= '';
211
212 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes )
213 + [ 'username' => false, 'email' => false ];
214 if ( $resetRoutes['username'] && $username ) {
215 $method = 'username';
216 $users = [ $this->userFactory->newFromName( $username ) ];
217 } elseif ( $resetRoutes['email'] && $email ) {
218 if ( !Sanitizer::validateEmail( $email ) ) {
219 // Only email was supplied but not valid: pretend everything's fine.
220 return StatusValue::newGood();
221 }
222 // Only email was provided
223 $method = 'email';
224 $users = $this->getUsersByEmail( $email );
225 $username = null;
226 // Remove users whose preference 'requireemail' is on since username was not submitted
227 if ( $this->config->get( MainConfigNames::AllowRequiringEmailForResets ) ) {
228 $optionsLookup = $this->userOptionsLookup;
229 foreach ( $users as $index => $user ) {
230 if ( $optionsLookup->getBoolOption( $user, 'requireemail' ) ) {
231 unset( $users[$index] );
232 }
233 }
234 }
235 } else {
236 // The user didn't supply any data
237 return StatusValue::newFatal( 'passwordreset-nodata' );
238 }
239
240 // If the username is not valid, tell the user.
241 if ( $username && !$this->userNameUtils->getCanonical( $username ) ) {
242 return StatusValue::newFatal( 'noname' );
243 }
244
245 // Check for hooks (captcha etc), and allow them to modify the users list
246 $error = [];
247 $data = [
248 'Username' => $username,
249 // Email gets set to null for backward compatibility
250 'Email' => $method === 'email' ? $email : null,
251 ];
252
253 // Recreate the $users array with its values so that we reset the numeric keys since
254 // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
255 // hook assumes that index '0' is defined if $users is not empty.
256 $users = array_values( $users );
257
258 if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
259 return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
260 }
261
262 // Get the first element in $users by using `reset` function just in case $users is changed
263 // in 'SpecialPasswordResetOnSubmit' hook.
264 $firstUser = reset( $users );
265
266 $requireEmail = $this->config->get( MainConfigNames::AllowRequiringEmailForResets )
267 && $method === 'username'
268 && $firstUser
269 && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' );
270 if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
271 // Email is required, and not supplied or not valid: pretend everything's fine.
272 return StatusValue::newGood();
273 }
274
275 if ( !$users ) {
276 if ( $method === 'email' ) {
277 // Don't reveal whether or not an email address is in use
278 return StatusValue::newGood();
279 } else {
280 return StatusValue::newFatal( 'noname' );
281 }
282 }
283
284 // If the user doesn't exist, or if the user doesn't have an email address,
285 // don't disclose the information. We want to pretend everything is ok per T238961.
286 // Note that all the users will have the same email address (or none),
287 // so there's no need to check more than the first.
288 if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
289 return StatusValue::newGood();
290 }
291
292 // Email is required but the email doesn't match: pretend everything's fine.
293 if ( $requireEmail && $firstUser->getEmail() !== $email ) {
294 return StatusValue::newGood();
295 }
296
297 $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
298
299 $result = StatusValue::newGood();
300 $reqs = [];
301 foreach ( $users as $user ) {
302 $req = TemporaryPasswordAuthenticationRequest::newRandom();
303 $req->username = $user->getName();
304 $req->mailpassword = true;
305 $req->caller = $performingUser->getName();
306
307 $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
308 // If status is good and the value is 'throttled-mailpassword', we want to pretend
309 // that the request was good to avoid displaying an error message and disclose
310 // if a reset password was previously sent.
311 if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
312 return StatusValue::newGood();
313 }
314
315 if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
316 $reqs[] = $req;
317 } elseif ( $result->isGood() ) {
318 // only record the first error, to avoid exposing the number of users having the
319 // same email address
320 if ( $status->getValue() === 'ignored' ) {
321 $status = StatusValue::newFatal( 'passwordreset-ignored' );
322 }
323 $result->merge( $status );
324 }
325 }
326
327 $logContext = [
328 'requestingIp' => $ip,
329 'requestingUser' => $performingUser->getName(),
330 'targetUsername' => $username,
331 'targetEmail' => $email,
332 ];
333
334 if ( !$result->isGood() ) {
335 $this->logger->info(
336 "{requestingUser} attempted password reset of {actualUser} but failed",
337 $logContext + [ 'errors' => $result->getErrors() ]
338 );
339 return $result;
340 }
341
342 DeferredUpdates::addUpdate(
343 new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
344 DeferredUpdates::POSTSEND
345 );
346
347 return StatusValue::newGood();
348 }
349
357 private function isBlocked( User $user ) {
358 $block = $user->getBlock();
359 return $block && $block->appliesToPasswordReset();
360 }
361
369 protected function getUsersByEmail( $email ) {
370 $userQuery = User::getQueryInfo();
371 $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->select(
372 $userQuery['tables'],
373 $userQuery['fields'],
374 [ 'user_email' => $email ],
375 __METHOD__,
376 [],
377 $userQuery['joins']
378 );
379
380 if ( !$res ) {
381 // Some sort of database error, probably unreachable
382 throw new MWException( 'Unknown database error in ' . __METHOD__ );
383 }
384
385 $users = [];
386 foreach ( $res as $row ) {
387 $users[] = $this->userFactory->newFromRow( $row );
388 }
389 return $users;
390 }
391
392}
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...
A class containing constants representing the names of configuration variables.
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:426
Helper class for the password reset functionality shared by the web UI and the API.
isAllowed(User $user)
Check if a given user has permission to use this functionality.
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
__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.
const CONSTRUCTOR_OPTIONS
getUsersByEmail( $email)
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.
static newFatal( $message,... $parameters)
Factory function for fatal errors.
internal since 1.36
Definition User.php:71
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition User.php:1521
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2416
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:1680
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new user object.
Definition User.php:3368
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition User.php:1647
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition User.php:1479
isAllowed(string $permission)
Checks whether this authority has the given permission in general.
Definition User.php:2367
This class is a delegate to ILBFactory for a given database cluster.
const DB_REPLICA
Definition defines.php:26