Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.73% |
122 / 133 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
PasswordReset | |
92.42% |
122 / 132 |
|
71.43% |
5 / 7 |
53.18 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
isAllowed | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
isEnabled | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
computeIsAllowed | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
execute | |
97.70% |
85 / 87 |
|
0.00% |
0 / 1 |
37 | |||
isBlocked | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getUsersByEmail | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | /** |
3 | * User password reset helper for MediaWiki. |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | */ |
22 | |
23 | namespace MediaWiki\User; |
24 | |
25 | use LogicException; |
26 | use MapCacheLRU; |
27 | use MediaWiki\Auth\AuthManager; |
28 | use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest; |
29 | use MediaWiki\Config\ServiceOptions; |
30 | use MediaWiki\Deferred\DeferredUpdates; |
31 | use MediaWiki\Deferred\SendPasswordResetEmailUpdate; |
32 | use MediaWiki\HookContainer\HookContainer; |
33 | use MediaWiki\HookContainer\HookRunner; |
34 | use MediaWiki\MainConfigNames; |
35 | use MediaWiki\Message\Message; |
36 | use MediaWiki\Parser\Sanitizer; |
37 | use MediaWiki\User\Options\UserOptionsLookup; |
38 | use Psr\Log\LoggerAwareInterface; |
39 | use Psr\Log\LoggerAwareTrait; |
40 | use Psr\Log\LoggerInterface; |
41 | use StatusValue; |
42 | use Wikimedia\Rdbms\IConnectionProvider; |
43 | |
44 | /** |
45 | * Helper class for the password reset functionality shared by the web UI and the API. |
46 | * |
47 | * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the |
48 | * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent |
49 | * functionality) to be enabled. |
50 | */ |
51 | class 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 | |
62 | /** |
63 | * In-process cache for isAllowed lookups, by username. |
64 | * Contains a StatusValue object |
65 | * @var MapCacheLRU |
66 | */ |
67 | private $permissionCache; |
68 | |
69 | /** |
70 | * @internal For use by ServiceWiring |
71 | */ |
72 | public const CONSTRUCTOR_OPTIONS = [ |
73 | MainConfigNames::AllowRequiringEmailForResets, |
74 | MainConfigNames::EnableEmail, |
75 | MainConfigNames::PasswordResetRoutes, |
76 | ]; |
77 | |
78 | /** |
79 | * This class is managed by MediaWikiServices, don't instantiate directly. |
80 | * |
81 | * @param ServiceOptions $config |
82 | * @param LoggerInterface $logger |
83 | * @param AuthManager $authManager |
84 | * @param HookContainer $hookContainer |
85 | * @param IConnectionProvider $dbProvider |
86 | * @param UserFactory $userFactory |
87 | * @param UserNameUtils $userNameUtils |
88 | * @param UserOptionsLookup $userOptionsLookup |
89 | */ |
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 | |
115 | /** |
116 | * Check if a given user has permission to use this functionality. |
117 | * @param User $user |
118 | * @since 1.29 Second argument for displayPassword removed. |
119 | * @return StatusValue |
120 | */ |
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 | |
130 | /** |
131 | * @since 1.42 |
132 | * @return StatusValue |
133 | */ |
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( |
142 | new TemporaryPasswordAuthenticationRequest(), false ); |
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 | |
155 | /** |
156 | * @param User $user |
157 | * @return StatusValue |
158 | */ |
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 | |
177 | /** |
178 | * Do a password reset. Authorization is the caller's responsibility. |
179 | * |
180 | * Process the form. At this point we know that the user passes all the criteria in |
181 | * userCanExecute(), and if the data array contains 'Username', etc, then Username |
182 | * resets are allowed. |
183 | * |
184 | * @since 1.29 Fourth argument for displayPassword removed. |
185 | * @param User $performingUser The user that does the password reset |
186 | * @param string|null $username The user whose password is reset |
187 | * @param string|null $email Alternative way to specify the user |
188 | * @return StatusValue |
189 | */ |
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 | |
355 | /** |
356 | * Check whether the user is blocked. |
357 | * Ignores certain types of system blocks that are only meant to force users to log in. |
358 | * @param User $user |
359 | * @return bool |
360 | * @since 1.30 |
361 | */ |
362 | private function isBlocked( User $user ) { |
363 | $block = $user->getBlock(); |
364 | return $block && $block->appliesToPasswordReset(); |
365 | } |
366 | |
367 | /** |
368 | * @note This is protected to allow configuring in tests. This class is not stable to extend. |
369 | * |
370 | * @param string $email |
371 | * @return User[] |
372 | */ |
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 | |
388 | /** @deprecated class alias since 1.41 */ |
389 | class_alias( PasswordReset::class, 'PasswordReset' ); |