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 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\User; |
22 | |
23 | use Iterator; |
24 | use LogicException; |
25 | use MapCacheLRU; |
26 | use MediaWiki\Auth\AuthManager; |
27 | use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest; |
28 | use MediaWiki\Config\ServiceOptions; |
29 | use MediaWiki\Deferred\DeferredUpdates; |
30 | use MediaWiki\Deferred\SendPasswordResetEmailUpdate; |
31 | use MediaWiki\HookContainer\HookContainer; |
32 | use MediaWiki\HookContainer\HookRunner; |
33 | use MediaWiki\MainConfigNames; |
34 | use MediaWiki\Message\Message; |
35 | use MediaWiki\Parser\Sanitizer; |
36 | use MediaWiki\User\Options\UserOptionsLookup; |
37 | use Psr\Log\LoggerAwareInterface; |
38 | use Psr\Log\LoggerAwareTrait; |
39 | use Psr\Log\LoggerInterface; |
40 | use StatusValue; |
41 | |
42 | /** |
43 | * Password reset helper for functionality shared by the web UI and the API. |
44 | * |
45 | * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the |
46 | * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent |
47 | * functionality) to be enabled. |
48 | */ |
49 | class 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 | |
60 | /** |
61 | * In-process cache for isAllowed lookups, by username. |
62 | * Contains a StatusValue object |
63 | */ |
64 | private MapCacheLRU $permissionCache; |
65 | |
66 | /** |
67 | * @internal For use by ServiceWiring |
68 | */ |
69 | public const CONSTRUCTOR_OPTIONS = [ |
70 | MainConfigNames::EnableEmail, |
71 | MainConfigNames::PasswordResetRoutes, |
72 | ]; |
73 | |
74 | /** |
75 | * This class is managed by MediaWikiServices, don't instantiate directly. |
76 | * |
77 | * @param ServiceOptions $config |
78 | * @param LoggerInterface $logger |
79 | * @param AuthManager $authManager |
80 | * @param HookContainer $hookContainer |
81 | * @param UserIdentityLookup $userIdentityLookup |
82 | * @param UserFactory $userFactory |
83 | * @param UserNameUtils $userNameUtils |
84 | * @param UserOptionsLookup $userOptionsLookup |
85 | */ |
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 | |
111 | /** |
112 | * Check if a given user has permission to use this functionality. |
113 | * @param User $user |
114 | * @since 1.29 Second argument for displayPassword removed. |
115 | * @return StatusValue |
116 | */ |
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 | |
126 | /** |
127 | * @since 1.42 |
128 | * @return StatusValue |
129 | */ |
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( |
138 | new TemporaryPasswordAuthenticationRequest(), false ); |
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 | |
169 | /** |
170 | * Do a password reset. Authorization is the caller's responsibility. |
171 | * |
172 | * Process the form. |
173 | * |
174 | * At this point, we know that the user passes all the criteria in |
175 | * userCanExecute(), and if the data array contains 'Username', etc., then Username |
176 | * resets are allowed. |
177 | * |
178 | * @since 1.29 Fourth argument for displayPassword removed. |
179 | * @param User $performingUser The user that does the password reset |
180 | * @param string|null $username The user whose password is reset |
181 | * @param string|null $email Alternative way to specify the user |
182 | * @return StatusValue |
183 | */ |
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 | |
225 | /** @var User[] $users */ |
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 | |
327 | /** |
328 | * Check whether the user is blocked. |
329 | * Ignores certain types of system blocks that are only meant to force users to log in. |
330 | * @param User $user |
331 | * @return bool |
332 | * @since 1.30 |
333 | */ |
334 | private function isBlocked( User $user ) { |
335 | $block = $user->getBlock(); |
336 | return $block && $block->appliesToPasswordReset(); |
337 | } |
338 | |
339 | /** |
340 | * @note This is protected to allow configuring in tests. This class is not stable to extend. |
341 | * |
342 | * @param string $email |
343 | * |
344 | * @return Iterator<UserIdentity> |
345 | */ |
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 | |
356 | /** @deprecated class alias since 1.41 */ |
357 | class_alias( PasswordReset::class, 'PasswordReset' ); |