Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.92% covered (success)
92.92%
105 / 113
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordReset
93.75% covered (success)
93.75%
105 / 112
71.43% covered (warning)
71.43%
5 / 7
44.47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isAllowed
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 computeIsAllowed
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 execute
97.37% covered (success)
97.37%
74 / 76
0.00% covered (danger)
0.00%
0 / 1
31
 isBlocked
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUsersByEmail
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use Iterator;
10use LogicException;
11use MediaWiki\Auth\AuthManager;
12use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
13use MediaWiki\Config\ServiceOptions;
14use MediaWiki\Deferred\DeferredUpdates;
15use MediaWiki\Deferred\SendPasswordResetEmailUpdate;
16use MediaWiki\HookContainer\HookContainer;
17use MediaWiki\HookContainer\HookRunner;
18use MediaWiki\MainConfigNames;
19use MediaWiki\Message\Message;
20use MediaWiki\Parser\Sanitizer;
21use MediaWiki\User\Options\UserOptionsLookup;
22use Psr\Log\LoggerAwareInterface;
23use Psr\Log\LoggerAwareTrait;
24use Psr\Log\LoggerInterface;
25use StatusValue;
26use Wikimedia\ObjectCache\MapCacheLRU;
27
28/**
29 * Password reset helper for functionality shared by the web UI and the API.
30 *
31 * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the
32 * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent
33 * functionality) to be enabled.
34 */
35class PasswordReset implements LoggerAwareInterface {
36    use LoggerAwareTrait;
37
38    private readonly HookRunner $hookRunner;
39
40    /**
41     * In-process cache for isAllowed lookups, by username.
42     * Contains a StatusValue object
43     */
44    private readonly MapCacheLRU $permissionCache;
45
46    /**
47     * @internal For use by ServiceWiring
48     */
49    public const CONSTRUCTOR_OPTIONS = [
50        MainConfigNames::EnableEmail,
51        MainConfigNames::PasswordResetRoutes,
52    ];
53
54    /**
55     * This class is managed by MediaWikiServices, don't instantiate directly.
56     */
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
75    /**
76     * Check if a given user has permission to use this functionality.
77     * @param User $user
78     * @since 1.29 Second argument for displayPassword removed.
79     * @return StatusValue
80     */
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
90    /**
91     * @since 1.42
92     */
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(
101            new TemporaryPasswordAuthenticationRequest(), false );
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
132    /**
133     * Do a password reset. Authorization is the caller's responsibility.
134     *
135     * Process the form.
136     *
137     * At this point, we know that the user passes all the criteria in
138     * userCanExecute(), and if the data array contains 'Username', etc., then Username
139     * resets are allowed.
140     *
141     * @since 1.29 Fourth argument for displayPassword removed.
142     * @param User $performingUser The user that does the password reset
143     * @param string|null $username The user whose password is reset
144     * @param string|null $email Alternative way to specify the user
145     * @return StatusValue
146     */
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
188        /** @var User[] $users */
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
290    /**
291     * Check whether the user is blocked.
292     * Ignores certain types of system blocks that are only meant to force users to log in.
293     * @since 1.30
294     */
295    private function isBlocked( User $user ): bool {
296        $block = $user->getBlock();
297        return (bool)$block?->appliesToPasswordReset();
298    }
299
300    /**
301     * @note This is protected to allow configuring in tests. This class is not stable to extend.
302     *
303     * @param string $email
304     *
305     * @return Iterator<UserIdentity>
306     */
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
317/** @deprecated class alias since 1.41 */
318class_alias( PasswordReset::class, 'PasswordReset' );