Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.28% |
111 / 119 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
PasswordReset | |
94.07% |
111 / 118 |
|
71.43% |
5 / 7 |
45.42 | |
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.37% |
74 / 76 |
|
0.00% |
0 / 1 |
31 | |||
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 Iterator; |
26 | use LogicException; |
27 | use MapCacheLRU; |
28 | use MediaWiki\Auth\AuthManager; |
29 | use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest; |
30 | use MediaWiki\Config\ServiceOptions; |
31 | use MediaWiki\Deferred\DeferredUpdates; |
32 | use MediaWiki\Deferred\SendPasswordResetEmailUpdate; |
33 | use MediaWiki\HookContainer\HookContainer; |
34 | use MediaWiki\HookContainer\HookRunner; |
35 | use MediaWiki\MainConfigNames; |
36 | use MediaWiki\Message\Message; |
37 | use MediaWiki\Parser\Sanitizer; |
38 | use MediaWiki\User\Options\UserOptionsLookup; |
39 | use Psr\Log\LoggerAwareInterface; |
40 | use Psr\Log\LoggerAwareTrait; |
41 | use Psr\Log\LoggerInterface; |
42 | use StatusValue; |
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 UserIdentityLookup $userIdentityLookup; |
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 | */ |
66 | private MapCacheLRU $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. |
179 | * |
180 | * 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 | $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 | |
231 | /** @var User[] $users */ |
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 | |
333 | /** |
334 | * Check whether the user is blocked. |
335 | * Ignores certain types of system blocks that are only meant to force users to log in. |
336 | * @param User $user |
337 | * @return bool |
338 | * @since 1.30 |
339 | */ |
340 | private function isBlocked( User $user ) { |
341 | $block = $user->getBlock(); |
342 | return $block && $block->appliesToPasswordReset(); |
343 | } |
344 | |
345 | /** |
346 | * @note This is protected to allow configuring in tests. This class is not stable to extend. |
347 | * |
348 | * @param string $email |
349 | * |
350 | * @return Iterator<UserIdentity> |
351 | */ |
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 | |
362 | /** @deprecated class alias since 1.41 */ |
363 | class_alias( PasswordReset::class, 'PasswordReset' ); |