MediaWiki master
PasswordReset.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\User;
8
9use Iterator;
10use LogicException;
22use Psr\Log\LoggerAwareInterface;
23use Psr\Log\LoggerAwareTrait;
24use Psr\Log\LoggerInterface;
25use StatusValue;
27
35class PasswordReset implements LoggerAwareInterface {
36 use LoggerAwareTrait;
37
38 private readonly HookRunner $hookRunner;
39
44 private readonly MapCacheLRU $permissionCache;
45
49 public const CONSTRUCTOR_OPTIONS = [
52 ];
53
57 public function __construct(
58 private readonly ServiceOptions $config,
59 LoggerInterface $logger,
60 private readonly AuthManager $authManager,
61 HookContainer $hookContainer,
62 private readonly UserIdentityLookup $userIdentityLookup,
63 private readonly UserFactory $userFactory,
64 private readonly UserNameUtils $userNameUtils,
65 private readonly UserOptionsLookup $userOptionsLookup,
66 ) {
67 $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
68
69 $this->logger = $logger;
70 $this->hookRunner = new HookRunner( $hookContainer );
71
72 $this->permissionCache = new MapCacheLRU( 1 );
73 }
74
81 public function isAllowed( User $user ) {
82 return $this->permissionCache->getWithSetCallback(
83 $user->getName(),
84 function () use ( $user ) {
85 return $this->computeIsAllowed( $user );
86 }
87 );
88 }
89
93 public function isEnabled(): StatusValue {
94 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes );
95 if ( !is_array( $resetRoutes ) || !in_array( true, $resetRoutes, true ) ) {
96 // Maybe password resets are disabled, or there are no allowable routes
97 return StatusValue::newFatal( 'passwordreset-disabled' );
98 }
99
100 $providerStatus = $this->authManager->allowsAuthenticationDataChange(
102 if ( !$providerStatus->isGood() ) {
103 // Maybe the external auth plugin won't allow local password changes
104 return StatusValue::newFatal( 'resetpass_forbidden-reason',
105 $providerStatus->getMessage() );
106 }
107 if ( !$this->config->get( MainConfigNames::EnableEmail ) ) {
108 // Maybe email features have been disabled
109 return StatusValue::newFatal( 'passwordreset-emaildisabled' );
110 }
111 return StatusValue::newGood();
112 }
113
114 private function computeIsAllowed( User $user ): StatusValue {
115 $enabledStatus = $this->isEnabled();
116 if ( !$enabledStatus->isGood() ) {
117 return $enabledStatus;
118 }
119 if ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
120 // Maybe not all users have permission to change private data
121 return StatusValue::newFatal( 'badaccess' );
122 }
123 if ( $this->isBlocked( $user ) ) {
124 // Maybe the user is blocked (check this here rather than relying on the parent
125 // method as we have a more specific error message to use here, and we want to
126 // ignore some types of blocks)
127 return StatusValue::newFatal( 'blocked-mailpassword' );
128 }
129 return StatusValue::newGood();
130 }
131
147 public function execute(
148 User $performingUser,
149 $username = null,
150 $email = null
151 ) {
152 if ( !$this->isAllowed( $performingUser )->isGood() ) {
153 throw new LogicException(
154 'User ' . $performingUser->getName() . ' is not allowed to reset passwords'
155 );
156 }
157
158 // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend
159 // that the request was good to avoid displaying an error message.
160 if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
161 return StatusValue::newGood();
162 }
163
164 // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347,
165 // we should send the user's name if they're logged in.
166 $ip = $performingUser->getRequest()->getIP();
167 if ( !$ip ) {
168 return StatusValue::newFatal( 'badipaddress' );
169 }
170
171 $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes )
172 + [ 'username' => false, 'email' => false ];
173 if ( !$resetRoutes['username'] || $username === '' ) {
174 $username = null;
175 }
176 if ( !$resetRoutes['email'] || $email === '' ) {
177 $email = null;
178 }
179
180 if ( $username !== null && !$this->userNameUtils->getCanonical( $username ) ) {
181 return StatusValue::newFatal( 'noname' );
182 }
183 if ( $email !== null && !Sanitizer::validateEmail( $email ) ) {
184 return StatusValue::newFatal( 'passwordreset-invalidemail' );
185 }
186 // At this point, $username and $email are either valid or not provided
187
189 $users = [];
190
191 if ( $username !== null ) {
192 $user = $this->userFactory->newFromName( $username );
193 // User must have an email address to attempt sending a password reset email
194 if ( $user && $user->isRegistered() && $user->getEmail() && (
195 !$this->userOptionsLookup->getBoolOption( $user, 'requireemail' ) ||
196 $user->getEmail() === $email
197 ) ) {
198 // Either providing the email in the form is not required to request a reset,
199 // or the correct email was provided
200 $users[] = $user;
201 }
202
203 } elseif ( $email !== null ) {
204 foreach ( $this->getUsersByEmail( $email ) as $userIdent ) {
205 // Skip users whose preference 'requireemail' is on since the username was not submitted
206 if ( $this->userOptionsLookup->getBoolOption( $userIdent, 'requireemail' ) ) {
207 continue;
208 }
209 $users[] = $this->userFactory->newFromUserIdentity( $userIdent );
210 }
211
212 } else {
213 // The user didn't supply any data
214 return StatusValue::newFatal( 'passwordreset-nodata' );
215 }
216
217 // Check for hooks (captcha etc.), and allow them to modify the list of users
218 $data = [
219 'Username' => $username,
220 'Email' => $email,
221 ];
222
223 $error = [];
224 if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) {
225 return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
226 }
227
228 if ( !$users ) {
229 // Don't reveal whether a username or email address is in use
230 return StatusValue::newGood();
231 }
232
233 // Get the first element in $users by using `reset` function since
234 // the key '0' might have been unset from $users array by a hook handler.
235 $firstUser = reset( $users );
236
237 $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser );
238
239 $result = StatusValue::newGood();
240 $reqs = [];
241 foreach ( $users as $user ) {
242 $req = TemporaryPasswordAuthenticationRequest::newRandom();
243 $req->username = $user->getName();
244 $req->mailpassword = true;
245 $req->caller = $performingUser->getName();
246
247 $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
248 // If the status is good and the value is 'throttled-mailpassword', we want to pretend
249 // that the request was good to avoid displaying an error message and disclose
250 // if a reset password was previously sent.
251 if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) {
252 return StatusValue::newGood();
253 }
254
255 if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
256 $reqs[] = $req;
257 } elseif ( $result->isGood() ) {
258 // only record the first error, to avoid exposing the number of users having the
259 // same email address
260 if ( $status->getValue() === 'ignored' ) {
261 $status = StatusValue::newFatal( 'passwordreset-ignored' );
262 }
263 $result->merge( $status );
264 }
265 }
266
267 $logContext = [
268 'requestingIp' => $ip,
269 'requestingUser' => $performingUser->getName(),
270 'targetUsername' => $username,
271 'targetEmail' => $email,
272 ] + $performingUser->getRequest()->getSecurityLogContext();
273
274 if ( !$result->isGood() ) {
275 $this->logger->info(
276 "{requestingUser} attempted password reset of {targetUsername} but failed",
277 $logContext + [ 'errors' => $result->getErrors() ]
278 );
279 return $result;
280 }
281
282 DeferredUpdates::addUpdate(
283 new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ),
284 DeferredUpdates::POSTSEND
285 );
286
287 return StatusValue::newGood();
288 }
289
295 private function isBlocked( User $user ): bool {
296 $block = $user->getBlock();
297 return (bool)$block?->appliesToPasswordReset();
298 }
299
307 protected function getUsersByEmail( $email ) {
308 return $this->userIdentityLookup->newSelectQueryBuilder()
309 ->join( 'user', null, [ "actor_user=user_id" ] )
310 ->where( [ 'user_email' => $email ] )
311 ->caller( __METHOD__ )
312 ->fetchUserIdentities();
313 }
314
315}
316
318class_alias( PasswordReset::class, 'PasswordReset' );
AuthManager is the authentication system in MediaWiki and serves entry point for authentication.
This represents the intention to set a temporary password for the user.
A class for passing options to services.
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:144
HTML sanitizer for MediaWiki.
Definition Sanitizer.php:34
Provides access to user options.
Password reset helper for functionality shared by the web UI and the API.
__construct(private readonly ServiceOptions $config, LoggerInterface $logger, private readonly AuthManager $authManager, HookContainer $hookContainer, private readonly UserIdentityLookup $userIdentityLookup, private readonly UserFactory $userFactory, private readonly UserNameUtils $userNameUtils, private readonly 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:129
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2187
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition User.php:1387
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:1515
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.
Store key-value entries in a size-limited in-memory LRU cache.
Service for looking up UserIdentity.