MediaWiki master
PasswordReset.php
Go to the documentation of this file.
1<?php
23namespace MediaWiki\User;
24
25use Iterator;
26use LogicException;
27use MapCacheLRU;
39use Psr\Log\LoggerAwareInterface;
40use Psr\Log\LoggerAwareTrait;
41use Psr\Log\LoggerInterface;
42use StatusValue;
43
51class PasswordReset implements LoggerAwareInterface {
52 use LoggerAwareTrait;
53
54 private ServiceOptions $config;
55 private AuthManager $authManager;
56 private HookRunner $hookRunner;
57 private UserIdentityLookup $userIdentityLookup;
58 private UserFactory $userFactory;
59 private UserNameUtils $userNameUtils;
60 private UserOptionsLookup $userOptionsLookup;
61
66 private MapCacheLRU $permissionCache;
67
71 public const CONSTRUCTOR_OPTIONS = [
74 ];
75
88 public function __construct(
89 ServiceOptions $config,
90 LoggerInterface $logger,
91 AuthManager $authManager,
92 HookContainer $hookContainer,
93 UserIdentityLookup $userIdentityLookup,
94 UserFactory $userFactory,
95 UserNameUtils $userNameUtils,
96 UserOptionsLookup $userOptionsLookup
97 ) {
98 $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
99
100 $this->config = $config;
101 $this->logger = $logger;
102
103 $this->authManager = $authManager;
104 $this->hookRunner = new HookRunner( $hookContainer );
105 $this->userIdentityLookup = $userIdentityLookup;
106 $this->userFactory = $userFactory;
107 $this->userNameUtils = $userNameUtils;
108 $this->userOptionsLookup = $userOptionsLookup;
109
110 $this->permissionCache = new MapCacheLRU( 1 );
111 }
112
119 public function isAllowed( User $user ) {
120 return $this->permissionCache->getWithSetCallback(
121 $user->getName(),
122 function () use ( $user ) {
123 return $this->computeIsAllowed( $user );
124 }
125 );
126 }
127
132 public function isEnabled(): StatusValue {
133 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes );
134 if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
135 // Maybe password resets are disabled, or there are no allowable routes
136 return StatusValue::newFatal( 'passwordreset-disabled' );
137 }
138
139 $providerStatus = $this->authManager->allowsAuthenticationDataChange(
141 if ( !$providerStatus->isGood() ) {
142 // Maybe the external auth plugin won't allow local password changes
143 return StatusValue::newFatal( 'resetpass_forbidden-reason',
144 $providerStatus->getMessage() );
145 }
146 if ( !$this->config->get( MainConfigNames::EnableEmail ) ) {
147 // Maybe email features have been disabled
148 return StatusValue::newFatal( 'passwordreset-emaildisabled' );
149 }
150 return StatusValue::newGood();
151 }
152
157 private function computeIsAllowed( User $user ): StatusValue {
158 $enabledStatus = $this->isEnabled();
159 if ( !$enabledStatus->isGood() ) {
160 return $enabledStatus;
161 }
162 if ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
163 // Maybe not all users have permission to change private data
164 return StatusValue::newFatal( 'badaccess' );
165 }
166 if ( $this->isBlocked( $user ) ) {
167 // Maybe the user is blocked (check this here rather than relying on the parent
168 // method as we have a more specific error message to use here, and we want to
169 // ignore some types of blocks)
170 return StatusValue::newFatal( 'blocked-mailpassword' );
171 }
172 return StatusValue::newGood();
173 }
174
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 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes )
215 + [ 'username' => false, 'email' => false ];
216 if ( !$resetRoutes['username'] || $username === '' ) {
217 $username = null;
218 }
219 if ( !$resetRoutes['email'] || $email === '' ) {
220 $email = null;
221 }
222
223 if ( $username !== null && !$this->userNameUtils->getCanonical( $username ) ) {
224 return StatusValue::newFatal( 'noname' );
225 }
226 if ( $email !== null && !Sanitizer::validateEmail( $email ) ) {
227 return StatusValue::newFatal( 'passwordreset-invalidemail' );
228 }
229 // At this point, $username and $email are either valid or not provided
230
232 $users = [];
233
234 if ( $username !== null ) {
235 $user = $this->userFactory->newFromName( $username );
236 // User must have an email address to attempt sending a password reset email
237 if ( $user && $user->isRegistered() && $user->getEmail() && (
238 !$this->userOptionsLookup->getBoolOption( $user, 'requireemail' ) ||
239 $user->getEmail() === $email
240 ) ) {
241 // Either providing the email in the form is not required to request a reset,
242 // or the correct email was provided
243 $users[] = $user;
244 }
245
246 } elseif ( $email !== null ) {
247 foreach ( $this->getUsersByEmail( $email ) as $userIdent ) {
248 // Skip users whose preference 'requireemail' is on since the username was not submitted
249 if ( $this->userOptionsLookup->getBoolOption( $userIdent, 'requireemail' ) ) {
250 continue;
251 }
252 $users[] = $this->userFactory->newFromUserIdentity( $userIdent );
253 }
254
255 } else {
256 // The user didn't supply any data
257 return StatusValue::newFatal( 'passwordreset-nodata' );
258 }
259
260 // Check for hooks (captcha etc.), and allow them to modify the list of users
261 $data = [
262 'Username' => $username,
263 'Email' => $email,
264 ];
265
266 $error = [];
267 if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
268 return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
269 }
270
271 if ( !$users ) {
272 // Don't reveal whether a username or email address is in use
273 return StatusValue::newGood();
274 }
275
276 // Get the first element in $users by using `reset` function since
277 // the key '0' might have been unset from $users array by a hook handler.
278 $firstUser = reset( $users );
279
280 $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
281
282 $result = StatusValue::newGood();
283 $reqs = [];
284 foreach ( $users as $user ) {
285 $req = TemporaryPasswordAuthenticationRequest::newRandom();
286 $req->username = $user->getName();
287 $req->mailpassword = true;
288 $req->caller = $performingUser->getName();
289
290 $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
291 // If the status is good and the value is 'throttled-mailpassword', we want to pretend
292 // that the request was good to avoid displaying an error message and disclose
293 // if a reset password was previously sent.
294 if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
295 return StatusValue::newGood();
296 }
297
298 if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
299 $reqs[] = $req;
300 } elseif ( $result->isGood() ) {
301 // only record the first error, to avoid exposing the number of users having the
302 // same email address
303 if ( $status->getValue() === 'ignored' ) {
304 $status = StatusValue::newFatal( 'passwordreset-ignored' );
305 }
306 $result->merge( $status );
307 }
308 }
309
310 $logContext = [
311 'requestingIp' => $ip,
312 'requestingUser' => $performingUser->getName(),
313 'targetUsername' => $username,
314 'targetEmail' => $email,
315 ];
316
317 if ( !$result->isGood() ) {
318 $this->logger->info(
319 "{requestingUser} attempted password reset of {targetUsername} but failed",
320 $logContext + [ 'errors' => $result->getErrors() ]
321 );
322 return $result;
323 }
324
325 DeferredUpdates::addUpdate(
326 new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
327 DeferredUpdates::POSTSEND
328 );
329
330 return StatusValue::newGood();
331 }
332
340 private function isBlocked( User $user ) {
341 $block = $user->getBlock();
342 return $block && $block->appliesToPasswordReset();
343 }
344
352 protected function getUsersByEmail( $email ) {
353 return $this->userIdentityLookup->newSelectQueryBuilder()
354 ->join( 'user', null, [ "actor_user=user_id" ] )
355 ->where( [ 'user_email' => $email ] )
356 ->caller( __METHOD__ )
357 ->fetchUserIdentities();
358 }
359
360}
361
363class_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()
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:155
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.
__construct(ServiceOptions $config, LoggerInterface $logger, AuthManager $authManager, HookContainer $hookContainer, UserIdentityLookup $userIdentityLookup, UserFactory $userFactory, UserNameUtils $userNameUtils, UserOptionsLookup $userOptionsLookup)
This class is managed by MediaWikiServices, don't instantiate directly.
execute(User $performingUser, $username=null, $email=null)
Do a password reset.
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
getBlock( $freshness=IDBAccessObject::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition User.php:1418
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2220
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition User.php:1389
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:1566
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.
Utility class for bot passwords.