MediaWiki master
PasswordReset.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\User;
22
23use Iterator;
24use LogicException;
25use MapCacheLRU;
37use Psr\Log\LoggerAwareInterface;
38use Psr\Log\LoggerAwareTrait;
39use Psr\Log\LoggerInterface;
40use StatusValue;
41
49class PasswordReset implements LoggerAwareInterface {
50 use LoggerAwareTrait;
51
52 private ServiceOptions $config;
53 private AuthManager $authManager;
54 private HookRunner $hookRunner;
55 private UserIdentityLookup $userIdentityLookup;
56 private UserFactory $userFactory;
57 private UserNameUtils $userNameUtils;
58 private UserOptionsLookup $userOptionsLookup;
59
64 private MapCacheLRU $permissionCache;
65
69 public const CONSTRUCTOR_OPTIONS = [
72 ];
73
86 public function __construct(
87 ServiceOptions $config,
88 LoggerInterface $logger,
89 AuthManager $authManager,
90 HookContainer $hookContainer,
91 UserIdentityLookup $userIdentityLookup,
92 UserFactory $userFactory,
93 UserNameUtils $userNameUtils,
94 UserOptionsLookup $userOptionsLookup
95 ) {
96 $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
97
98 $this->config = $config;
99 $this->logger = $logger;
100
101 $this->authManager = $authManager;
102 $this->hookRunner = new HookRunner( $hookContainer );
103 $this->userIdentityLookup = $userIdentityLookup;
104 $this->userFactory = $userFactory;
105 $this->userNameUtils = $userNameUtils;
106 $this->userOptionsLookup = $userOptionsLookup;
107
108 $this->permissionCache = new MapCacheLRU( 1 );
109 }
110
117 public function isAllowed( User $user ) {
118 return $this->permissionCache->getWithSetCallback(
119 $user->getName(),
120 function () use ( $user ) {
121 return $this->computeIsAllowed( $user );
122 }
123 );
124 }
125
130 public function isEnabled(): StatusValue {
131 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes );
132 if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
133 // Maybe password resets are disabled, or there are no allowable routes
134 return StatusValue::newFatal( 'passwordreset-disabled' );
135 }
136
137 $providerStatus = $this->authManager->allowsAuthenticationDataChange(
139 if ( !$providerStatus->isGood() ) {
140 // Maybe the external auth plugin won't allow local password changes
141 return StatusValue::newFatal( 'resetpass_forbidden-reason',
142 $providerStatus->getMessage() );
143 }
144 if ( !$this->config->get( MainConfigNames::EnableEmail ) ) {
145 // Maybe email features have been disabled
146 return StatusValue::newFatal( 'passwordreset-emaildisabled' );
147 }
148 return StatusValue::newGood();
149 }
150
151 private function computeIsAllowed( User $user ): StatusValue {
152 $enabledStatus = $this->isEnabled();
153 if ( !$enabledStatus->isGood() ) {
154 return $enabledStatus;
155 }
156 if ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
157 // Maybe not all users have permission to change private data
158 return StatusValue::newFatal( 'badaccess' );
159 }
160 if ( $this->isBlocked( $user ) ) {
161 // Maybe the user is blocked (check this here rather than relying on the parent
162 // method as we have a more specific error message to use here, and we want to
163 // ignore some types of blocks)
164 return StatusValue::newFatal( 'blocked-mailpassword' );
165 }
166 return StatusValue::newGood();
167 }
168
184 public function execute(
185 User $performingUser,
186 $username = null,
187 $email = null
188 ) {
189 if ( !$this->isAllowed( $performingUser )->isGood() ) {
190 throw new LogicException(
191 'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
192 );
193 }
194
195 // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
196 // that the request was good to avoid displaying an error message.
197 if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
198 return StatusValue::newGood();
199 }
200
201 // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
202 // we should send the user's name if they're logged in.
203 $ip = $performingUser->getRequest()->getIP();
204 if ( !$ip ) {
205 return StatusValue::newFatal( 'badipaddress' );
206 }
207
208 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes )
209 + [ 'username' => false, 'email' => false ];
210 if ( !$resetRoutes['username'] || $username === '' ) {
211 $username = null;
212 }
213 if ( !$resetRoutes['email'] || $email === '' ) {
214 $email = null;
215 }
216
217 if ( $username !== null && !$this->userNameUtils->getCanonical( $username ) ) {
218 return StatusValue::newFatal( 'noname' );
219 }
220 if ( $email !== null && !Sanitizer::validateEmail( $email ) ) {
221 return StatusValue::newFatal( 'passwordreset-invalidemail' );
222 }
223 // At this point, $username and $email are either valid or not provided
224
226 $users = [];
227
228 if ( $username !== null ) {
229 $user = $this->userFactory->newFromName( $username );
230 // User must have an email address to attempt sending a password reset email
231 if ( $user && $user->isRegistered() && $user->getEmail() && (
232 !$this->userOptionsLookup->getBoolOption( $user, 'requireemail' ) ||
233 $user->getEmail() === $email
234 ) ) {
235 // Either providing the email in the form is not required to request a reset,
236 // or the correct email was provided
237 $users[] = $user;
238 }
239
240 } elseif ( $email !== null ) {
241 foreach ( $this->getUsersByEmail( $email ) as $userIdent ) {
242 // Skip users whose preference 'requireemail' is on since the username was not submitted
243 if ( $this->userOptionsLookup->getBoolOption( $userIdent, 'requireemail' ) ) {
244 continue;
245 }
246 $users[] = $this->userFactory->newFromUserIdentity( $userIdent );
247 }
248
249 } else {
250 // The user didn't supply any data
251 return StatusValue::newFatal( 'passwordreset-nodata' );
252 }
253
254 // Check for hooks (captcha etc.), and allow them to modify the list of users
255 $data = [
256 'Username' => $username,
257 'Email' => $email,
258 ];
259
260 $error = [];
261 if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
262 return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
263 }
264
265 if ( !$users ) {
266 // Don't reveal whether a username or email address is in use
267 return StatusValue::newGood();
268 }
269
270 // Get the first element in $users by using `reset` function since
271 // the key '0' might have been unset from $users array by a hook handler.
272 $firstUser = reset( $users );
273
274 $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
275
276 $result = StatusValue::newGood();
277 $reqs = [];
278 foreach ( $users as $user ) {
279 $req = TemporaryPasswordAuthenticationRequest::newRandom();
280 $req->username = $user->getName();
281 $req->mailpassword = true;
282 $req->caller = $performingUser->getName();
283
284 $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
285 // If the status is good and the value is 'throttled-mailpassword', we want to pretend
286 // that the request was good to avoid displaying an error message and disclose
287 // if a reset password was previously sent.
288 if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
289 return StatusValue::newGood();
290 }
291
292 if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
293 $reqs[] = $req;
294 } elseif ( $result->isGood() ) {
295 // only record the first error, to avoid exposing the number of users having the
296 // same email address
297 if ( $status->getValue() === 'ignored' ) {
298 $status = StatusValue::newFatal( 'passwordreset-ignored' );
299 }
300 $result->merge( $status );
301 }
302 }
303
304 $logContext = [
305 'requestingIp' => $ip,
306 'requestingUser' => $performingUser->getName(),
307 'targetUsername' => $username,
308 'targetEmail' => $email,
309 ];
310
311 if ( !$result->isGood() ) {
312 $this->logger->info(
313 "{requestingUser} attempted password reset of {targetUsername} but failed",
314 $logContext + [ 'errors' => $result->getErrors() ]
315 );
316 return $result;
317 }
318
319 DeferredUpdates::addUpdate(
320 new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
321 DeferredUpdates::POSTSEND
322 );
323
324 return StatusValue::newGood();
325 }
326
334 private function isBlocked( User $user ) {
335 $block = $user->getBlock();
336 return $block && $block->appliesToPasswordReset();
337 }
338
346 protected function getUsersByEmail( $email ) {
347 return $this->userIdentityLookup->newSelectQueryBuilder()
348 ->join( 'user', null, [ "actor_user=user_id" ] )
349 ->where( [ 'user_email' => $email ] )
350 ->caller( __METHOD__ )
351 ->fetchUserIdentities();
352 }
353
354}
355
357class_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.
Password reset helper for 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.
Create User objects.
UserNameUtils service.
User class for the MediaWiki software.
Definition User.php:119
getBlock( $freshness=IDBAccessObject::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition User.php:1443
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2243
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition User.php:1414
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:1591
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.
Service for looking up UserIdentity.