Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
92.86% |
117 / 126 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
PasswordReset | |
93.60% |
117 / 125 |
|
71.43% |
5 / 7 |
51.68 | |
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 | |
96.39% |
80 / 83 |
|
0.00% |
0 / 1 |
37 | |||
isBlocked | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getUsersByEmail | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 |
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 | |
43 | /** |
44 | * Helper class for the password reset functionality shared by the web UI and the API. |
45 | * |
46 | * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the |
47 | * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent |
48 | * functionality) to be enabled. |
49 | */ |
50 | class PasswordReset implements LoggerAwareInterface { |
51 | use LoggerAwareTrait; |
52 | |
53 | private ServiceOptions $config; |
54 | private AuthManager $authManager; |
55 | private HookRunner $hookRunner; |
56 | private UserIdentityLookup $userIdentityLookup; |
57 | private UserFactory $userFactory; |
58 | private UserNameUtils $userNameUtils; |
59 | private UserOptionsLookup $userOptionsLookup; |
60 | |
61 | /** |
62 | * In-process cache for isAllowed lookups, by username. |
63 | * Contains a StatusValue object |
64 | * @var MapCacheLRU |
65 | */ |
66 | private $permissionCache; |
67 | |
68 | /** |
69 | * @internal For use by ServiceWiring |
70 | */ |
71 | public const CONSTRUCTOR_OPTIONS = [ |
72 | MainConfigNames::EnableEmail, |
73 | MainConfigNames::PasswordResetRoutes, |
74 | ]; |
75 | |
76 | /** |
77 | * This class is managed by MediaWikiServices, don't instantiate directly. |
78 | * |
79 | * @param ServiceOptions $config |
80 | * @param LoggerInterface $logger |
81 | * @param AuthManager $authManager |
82 | * @param HookContainer $hookContainer |
83 | * @param UserIdentityLookup $userIdentityLookup |
84 | * @param UserFactory $userFactory |
85 | * @param UserNameUtils $userNameUtils |
86 | * @param UserOptionsLookup $userOptionsLookup |
87 | */ |
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 | |
113 | /** |
114 | * Check if a given user has permission to use this functionality. |
115 | * @param User $user |
116 | * @since 1.29 Second argument for displayPassword removed. |
117 | * @return StatusValue |
118 | */ |
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 | |
128 | /** |
129 | * @since 1.42 |
130 | * @return StatusValue |
131 | */ |
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( |
140 | new TemporaryPasswordAuthenticationRequest(), false ); |
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 | |
153 | /** |
154 | * @param User $user |
155 | * @return StatusValue |
156 | */ |
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 | |
175 | /** |
176 | * Do a password reset. Authorization is the caller's responsibility. |
177 | * |
178 | * Process the form. At this point we know that the user passes all the criteria in |
179 | * userCanExecute(), and if the data array contains 'Username', etc, then Username |
180 | * resets are allowed. |
181 | * |
182 | * @since 1.29 Fourth argument for displayPassword removed. |
183 | * @param User $performingUser The user that does the password reset |
184 | * @param string|null $username The user whose password is reset |
185 | * @param string|null $email Alternative way to specify the user |
186 | * @return StatusValue |
187 | */ |
188 | public function execute( |
189 | User $performingUser, |
190 | $username = null, |
191 | $email = null |
192 | ) { |
193 | if ( !$this->isAllowed( $performingUser )->isGood() ) { |
194 | throw new LogicException( |
195 | 'User ' . $performingUser->getName() . ' is not allowed to reset passwords' |
196 | ); |
197 | } |
198 | |
199 | // Check against the rate limiter. If the $wgRateLimit is reached, we want to pretend |
200 | // that the request was good to avoid displaying an error message. |
201 | if ( $performingUser->pingLimiter( 'mailpassword' ) ) { |
202 | return StatusValue::newGood(); |
203 | } |
204 | |
205 | // We need to have a valid IP address for the hook 'User::mailPasswordInternal', but per T20347, |
206 | // we should send the user's name if they're logged in. |
207 | $ip = $performingUser->getRequest()->getIP(); |
208 | if ( !$ip ) { |
209 | return StatusValue::newFatal( 'badipaddress' ); |
210 | } |
211 | |
212 | $resetRoutes = $this->config->get( MainConfigNames::PasswordResetRoutes ) |
213 | + [ 'username' => false, 'email' => false ]; |
214 | if ( !$resetRoutes['username'] || $username === '' ) { |
215 | $username = null; |
216 | } |
217 | if ( !$resetRoutes['email'] || $email === '' ) { |
218 | $email = null; |
219 | } |
220 | |
221 | if ( $username !== null && !$this->userNameUtils->getCanonical( $username ) ) { |
222 | return StatusValue::newFatal( 'noname' ); |
223 | } |
224 | if ( $email !== null && !Sanitizer::validateEmail( $email ) ) { |
225 | return StatusValue::newFatal( 'passwordreset-invalidemail' ); |
226 | } |
227 | // At this point, $username and $email are either valid or not provided |
228 | |
229 | /** @var User[] $users */ |
230 | $users = []; |
231 | |
232 | if ( $username !== null ) { |
233 | $users[] = $this->userFactory->newFromName( $username ); |
234 | |
235 | } elseif ( $email !== null ) { |
236 | foreach ( $this->getUsersByEmail( $email ) as $userIdent ) { |
237 | // Skip users whose preference 'requireemail' is on since username was not submitted |
238 | if ( $this->userOptionsLookup->getBoolOption( $userIdent, 'requireemail' ) ) { |
239 | continue; |
240 | } |
241 | $users[] = $this->userFactory->newFromUserIdentity( $userIdent ); |
242 | } |
243 | |
244 | } else { |
245 | // The user didn't supply any data |
246 | return StatusValue::newFatal( 'passwordreset-nodata' ); |
247 | } |
248 | |
249 | // Check for hooks (captcha etc), and allow them to modify the users list |
250 | $data = [ |
251 | 'Username' => $username, |
252 | // Email is not provided to the hooks when we're resetting by username. |
253 | // We check for 'requireemail' below rather than relying on the hooks to do it. |
254 | // (However, we rely on the hooks doing it when resetting by email? That's a bit weird.) |
255 | 'Email' => $username === null ? $email : null, |
256 | ]; |
257 | |
258 | $error = []; |
259 | if ( !$this->hookRunner->onSpecialPasswordResetOnSubmit( $users, $data, $error ) ) { |
260 | return StatusValue::newFatal( Message::newFromSpecifier( $error ) ); |
261 | } |
262 | |
263 | // Get the first element in $users by using `reset` function since |
264 | // the key '0' might have been unset from $users array by a hook handler. |
265 | $firstUser = reset( $users ); |
266 | |
267 | $requireEmail = $username !== null |
268 | && $firstUser |
269 | && $this->userOptionsLookup->getBoolOption( $firstUser, 'requireemail' ); |
270 | if ( $requireEmail && $email === null ) { |
271 | // Email is required but not supplied: pretend everything's fine. |
272 | return StatusValue::newGood(); |
273 | } |
274 | |
275 | if ( !$users ) { |
276 | if ( $username === null ) { |
277 | // Don't reveal whether or not an email address is in use |
278 | return StatusValue::newGood(); |
279 | } else { |
280 | return StatusValue::newFatal( 'noname' ); |
281 | } |
282 | } |
283 | |
284 | // If the user doesn't exist, or if the user doesn't have an email address, |
285 | // don't disclose the information. We want to pretend everything is ok per T238961. |
286 | // Note that all the users will have the same email address (or none), |
287 | // so there's no need to check more than the first. |
288 | if ( !$firstUser || !$firstUser->getId() || !$firstUser->getEmail() ) { |
289 | return StatusValue::newGood(); |
290 | } |
291 | |
292 | // Email is required but the email doesn't match: pretend everything's fine. |
293 | if ( $requireEmail && $firstUser->getEmail() !== $email ) { |
294 | return StatusValue::newGood(); |
295 | } |
296 | |
297 | $this->hookRunner->onUser__mailPasswordInternal( $performingUser, $ip, $firstUser ); |
298 | |
299 | $result = StatusValue::newGood(); |
300 | $reqs = []; |
301 | foreach ( $users as $user ) { |
302 | $req = TemporaryPasswordAuthenticationRequest::newRandom(); |
303 | $req->username = $user->getName(); |
304 | $req->mailpassword = true; |
305 | $req->caller = $performingUser->getName(); |
306 | |
307 | $status = $this->authManager->allowsAuthenticationDataChange( $req, true ); |
308 | // If status is good and the value is 'throttled-mailpassword', we want to pretend |
309 | // that the request was good to avoid displaying an error message and disclose |
310 | // if a reset password was previously sent. |
311 | if ( $status->isGood() && $status->getValue() === 'throttled-mailpassword' ) { |
312 | return StatusValue::newGood(); |
313 | } |
314 | |
315 | if ( $status->isGood() && $status->getValue() !== 'ignored' ) { |
316 | $reqs[] = $req; |
317 | } elseif ( $result->isGood() ) { |
318 | // only record the first error, to avoid exposing the number of users having the |
319 | // same email address |
320 | if ( $status->getValue() === 'ignored' ) { |
321 | $status = StatusValue::newFatal( 'passwordreset-ignored' ); |
322 | } |
323 | $result->merge( $status ); |
324 | } |
325 | } |
326 | |
327 | $logContext = [ |
328 | 'requestingIp' => $ip, |
329 | 'requestingUser' => $performingUser->getName(), |
330 | 'targetUsername' => $username, |
331 | 'targetEmail' => $email, |
332 | ]; |
333 | |
334 | if ( !$result->isGood() ) { |
335 | $this->logger->info( |
336 | "{requestingUser} attempted password reset of {targetUsername} but failed", |
337 | $logContext + [ 'errors' => $result->getErrors() ] |
338 | ); |
339 | return $result; |
340 | } |
341 | |
342 | DeferredUpdates::addUpdate( |
343 | new SendPasswordResetEmailUpdate( $this->authManager, $reqs, $logContext ), |
344 | DeferredUpdates::POSTSEND |
345 | ); |
346 | |
347 | return StatusValue::newGood(); |
348 | } |
349 | |
350 | /** |
351 | * Check whether the user is blocked. |
352 | * Ignores certain types of system blocks that are only meant to force users to log in. |
353 | * @param User $user |
354 | * @return bool |
355 | * @since 1.30 |
356 | */ |
357 | private function isBlocked( User $user ) { |
358 | $block = $user->getBlock(); |
359 | return $block && $block->appliesToPasswordReset(); |
360 | } |
361 | |
362 | /** |
363 | * @note This is protected to allow configuring in tests. This class is not stable to extend. |
364 | * |
365 | * @param string $email |
366 | * @return iterable<UserIdentity> |
367 | */ |
368 | protected function getUsersByEmail( $email ) { |
369 | return $this->userIdentityLookup->newSelectQueryBuilder() |
370 | ->join( 'user', null, [ "actor_user=user_id" ] ) |
371 | ->where( [ 'user_email' => $email ] ) |
372 | ->caller( __METHOD__ ) |
373 | ->fetchUserIdentities(); |
374 | } |
375 | |
376 | } |
377 | |
378 | /** @deprecated class alias since 1.41 */ |
379 | class_alias( PasswordReset::class, 'PasswordReset' ); |