MediaWiki master
PasswordReset.php
Go to the documentation of this file.
1<?php
23namespace MediaWiki\User;
24
25use LogicException;
26use MapCacheLRU;
38use Psr\Log\LoggerAwareInterface;
39use Psr\Log\LoggerAwareTrait;
40use Psr\Log\LoggerInterface;
41use StatusValue;
43
51class PasswordReset implements LoggerAwareInterface {
52 use LoggerAwareTrait;
53
54 private ServiceOptions $config;
55 private AuthManager $authManager;
56 private HookRunner $hookRunner;
57 private IConnectionProvider $dbProvider;
58 private UserFactory $userFactory;
59 private UserNameUtils $userNameUtils;
60 private UserOptionsLookup $userOptionsLookup;
61
67 private $permissionCache;
68
72 public const CONSTRUCTOR_OPTIONS = [
76 ];
77
90 public function __construct(
91 ServiceOptions $config,
92 LoggerInterface $logger,
93 AuthManager $authManager,
94 HookContainer $hookContainer,
95 IConnectionProvider $dbProvider,
96 UserFactory $userFactory,
97 UserNameUtils $userNameUtils,
98 UserOptionsLookup $userOptionsLookup
99 ) {
100 $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
101
102 $this->config = $config;
103 $this->logger = $logger;
104
105 $this->authManager = $authManager;
106 $this->hookRunner = new HookRunner( $hookContainer );
107 $this->dbProvider = $dbProvider;
108 $this->userFactory = $userFactory;
109 $this->userNameUtils = $userNameUtils;
110 $this->userOptionsLookup = $userOptionsLookup;
111
112 $this->permissionCache = new MapCacheLRU( 1 );
113 }
114
121 public function isAllowed( User $user ) {
122 return $this->permissionCache->getWithSetCallback(
123 $user->getName(),
124 function () use ( $user ) {
125 return $this->computeIsAllowed( $user );
126 }
127 );
128 }
129
134 public function isEnabled(): StatusValue {
135 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes );
136 if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
137 // Maybe password resets are disabled, or there are no allowable routes
138 return StatusValue::newFatal( 'passwordreset-disabled' );
139 }
140
141 $providerStatus = $this->authManager->allowsAuthenticationDataChange(
143 if ( !$providerStatus->isGood() ) {
144 // Maybe the external auth plugin won't allow local password changes
145 return StatusValue::newFatal( 'resetpass_forbidden-reason',
146 $providerStatus->getMessage() );
147 }
148 if ( !$this->config->get( MainConfigNames::EnableEmail ) ) {
149 // Maybe email features have been disabled
150 return StatusValue::newFatal( 'passwordreset-emaildisabled' );
151 }
152 return StatusValue::newGood();
153 }
154
159 private function computeIsAllowed( User $user ): StatusValue {
160 $enabledStatus = $this->isEnabled();
161 if ( !$enabledStatus->isGood() ) {
162 return $enabledStatus;
163 }
164 if ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
165 // Maybe not all users have permission to change private data
166 return StatusValue::newFatal( 'badaccess' );
167 }
168 if ( $this->isBlocked( $user ) ) {
169 // Maybe the user is blocked (check this here rather than relying on the parent
170 // method as we have a more specific error message to use here and we want to
171 // ignore some types of blocks)
172 return StatusValue::newFatal( 'blocked-mailpassword' );
173 }
174 return StatusValue::newGood();
175 }
176
190 public function execute(
191 User $performingUser,
192 $username = null,
193 $email = null
194 ) {
195 if ( !$this->isAllowed( $performingUser )->isGood() ) {
196 throw new LogicException(
197 'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
198 );
199 }
200
201 // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
202 // that the request was good to avoid displaying an error message.
203 if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
204 return StatusValue::newGood();
205 }
206
207 // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
208 // we should send the user's name if they're logged in.
209 $ip = $performingUser->getRequest()->getIP();
210 if ( !$ip ) {
211 return StatusValue::newFatal( 'badipaddress' );
212 }
213
214 $username ??= '';
215 $email ??= '';
216
217 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes )
218 + [ 'username' => false, 'email' => false ];
219 if ( $resetRoutes['username'] && $username ) {
220 $method = 'username';
221 $users = [ $this->userFactory->newFromName( $username ) ];
222 } elseif ( $resetRoutes['email'] && $email ) {
223 if ( !Sanitizer::validateEmail( $email ) ) {
224 // Only email was supplied but not valid: pretend everything's fine.
225 return StatusValue::newGood();
226 }
227 // Only email was provided
228 $method = 'email';
229 $users = $this->getUsersByEmail( $email );
230 $username = null;
231 // Remove users whose preference 'requireemail' is on since username was not submitted
232 if ( $this->config->get( MainConfigNames::AllowRequiringEmailForResets ) ) {
233 $optionsLookup = $this->userOptionsLookup;
234 foreach ( $users as $index => $user ) {
235 if ( $optionsLookup->getBoolOption( $user, 'requireemail' ) ) {
236 unset( $users[$index] );
237 }
238 }
239 }
240 } else {
241 // The user didn't supply any data
242 return StatusValue::newFatal( 'passwordreset-nodata' );
243 }
244
245 // If the username is not valid, tell the user.
246 if ( $username && !$this->userNameUtils->getCanonical( $username ) ) {
247 return StatusValue::newFatal( 'noname' );
248 }
249
250 // Check for hooks (captcha etc), and allow them to modify the users list
251 $error = [];
252 $data = [
253 'Username' => $username,
254 // Email gets set to null for backward compatibility
255 'Email' => $method === 'email' ? $email : null,
256 ];
257
258 // Recreate the $users array with its values so that we reset the numeric keys since
259 // the key '0' might have been unset from $users array. 'SpecialPasswordResetOnSubmit'
260 // hook assumes that index '0' is defined if $users is not empty.
261 $users = array_values( $users );
262
263 if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
264 return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
265 }
266
267 // Get the first element in $users by using `reset` function just in case $users is changed
268 // in 'SpecialPasswordResetOnSubmit' hook.
269 $firstUser = reset( $users );
270
271 $requireEmail = $this->config->get( MainConfigNames::AllowRequiringEmailForResets )
272 && $method === 'username'
273 && $firstUser
274 && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' );
275 if ( $requireEmail && ( $email === '' || !Sanitizer::validateEmail( $email ) ) ) {
276 // Email is required, and not supplied or not valid: pretend everything's fine.
277 return StatusValue::newGood();
278 }
279
280 if ( !$users ) {
281 if ( $method === 'email' ) {
282 // Don't reveal whether or not an email address is in use
283 return StatusValue::newGood();
284 } else {
285 return StatusValue::newFatal( 'noname' );
286 }
287 }
288
289 // If the user doesn't exist, or if the user doesn't have an email address,
290 // don't disclose the information. We want to pretend everything is ok per T238961.
291 // Note that all the users will have the same email address (or none),
292 // so there's no need to check more than the first.
293 if ( !$firstUser instanceof User || !$firstUser->getId() || !$firstUser->getEmail() ) {
294 return StatusValue::newGood();
295 }
296
297 // Email is required but the email doesn't match: pretend everything's fine.
298 if ( $requireEmail && $firstUser->getEmail() !== $email ) {
299 return StatusValue::newGood();
300 }
301
302 $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
303
304 $result = StatusValue::newGood();
305 $reqs = [];
306 foreach ( $users as $user ) {
307 $req = TemporaryPasswordAuthenticationRequest::newRandom();
308 $req->username = $user->getName();
309 $req->mailpassword = true;
310 $req->caller = $performingUser->getName();
311
312 $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
313 // If status is good and the value is 'throttled-mailpassword', we want to pretend
314 // that the request was good to avoid displaying an error message and disclose
315 // if a reset password was previously sent.
316 if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
317 return StatusValue::newGood();
318 }
319
320 if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
321 $reqs[] = $req;
322 } elseif ( $result->isGood() ) {
323 // only record the first error, to avoid exposing the number of users having the
324 // same email address
325 if ( $status->getValue() === 'ignored' ) {
326 $status = StatusValue::newFatal( 'passwordreset-ignored' );
327 }
328 $result->merge( $status );
329 }
330 }
331
332 $logContext = [
333 'requestingIp' => $ip,
334 'requestingUser' => $performingUser->getName(),
335 'targetUsername' => $username,
336 'targetEmail' => $email,
337 ];
338
339 if ( !$result->isGood() ) {
340 $this->logger->info(
341 "{requestingUser} attempted password reset of {actualUser} but failed",
342 $logContext + [ 'errors' => $result->getErrors() ]
343 );
344 return $result;
345 }
346
347 DeferredUpdates::addUpdate(
348 new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
349 DeferredUpdates::POSTSEND
350 );
351
352 return StatusValue::newGood();
353 }
354
362 private function isBlocked( User $user ) {
363 $block = $user->getBlock();
364 return $block && $block->appliesToPasswordReset();
365 }
366
373 protected function getUsersByEmail( $email ) {
374 $res = User::newQueryBuilder( $this->dbProvider->getReplicaDatabase() )
375 ->where( [ 'user_email' => $email ] )
376 ->caller( __METHOD__ )
377 ->fetchResultSet();
378
379 $users = [];
380 foreach ( $res as $row ) {
381 $users[] = $this->userFactory->newFromRow( $row );
382 }
383 return $users;
384 }
385
386}
387
389class_alias( PasswordReset::class, 'PasswordReset' );
Store key-value entries in a size-limited in-memory LRU cache.
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,...
Defer callable updates to run later in the PHP process.
Sends emails to all accounts associated with that email to reset the password.
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.
const EnableEmail
Name constant for the EnableEmail setting, for use with Config::get()
const PasswordResetRoutes
Name constant for the PasswordResetRoutes setting, for use with Config::get()
const AllowRequiringEmailForResets
Name constant for the AllowRequiringEmailForResets setting, for use with Config::get()
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:158
HTML sanitizer for MediaWiki.
Definition Sanitizer.php:46
Provides access to user options.
Helper class for the password reset functionality shared by the web UI and the API.
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
__construct(ServiceOptions $config, LoggerInterface $logger, AuthManager $authManager, HookContainer $hookContainer, IConnectionProvider $dbProvider, UserFactory $userFactory, UserNameUtils $userNameUtils, UserOptionsLookup $userOptionsLookup)
This class is managed by MediaWikiServices, don't instantiate directly.
isAllowed(User $user)
Check if a given user has permission to use this functionality.
Creates User objects.
UserNameUtils service.
internal since 1.36
Definition User.php:93
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition User.php:1530
getBlock( $freshness=IDBAccessObject::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition User.php:1415
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2234
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition User.php:1386
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:1563
Generic operation result class Has warning/error list, boolean status and arbitrary value.
static newFatal( $message,... $parameters)
Factory function for fatal errors.
static newGood( $value=null)
Factory function for good results.
Provide primary and replica IDatabase connections.
Utility class for bot passwords.