Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.49% covered (success)
99.49%
194 / 195
94.12% covered (success)
94.12%
16 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractTemporaryPasswordPrimaryAuthenticationProvider
99.49% covered (success)
99.49%
194 / 195
94.12% covered (success)
94.12%
16 / 17
76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 postInitSetup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getPasswordResetData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getAuthenticationRequests
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 beginPrimaryAuthentication
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 testUserCanAuthenticate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 providerAllowsAuthenticationDataChange
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
14
 providerChangeAuthenticationData
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 accountCreationType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 testForAccountCreation
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 beginPrimaryAccountCreation
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 finishAccountCreation
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isTimestampValid
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 maybeSendNewAccountEmail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 sendNewAccountEmail
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 maybeSendPasswordResetEmail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 sendPasswordResetEmail
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
5
 getTemporaryPassword
n/a
0 / 0
n/a
0 / 0
0
 setTemporaryPassword
n/a
0 / 0
n/a
0 / 0
0
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Auth;
8
9use MediaWiki\MainConfigNames;
10use MediaWiki\Password\InvalidPassword;
11use MediaWiki\Password\Password;
12use MediaWiki\Password\PasswordFactory;
13use MediaWiki\SpecialPage\SpecialPage;
14use MediaWiki\Title\Title;
15use MediaWiki\User\Options\UserOptionsLookup;
16use MediaWiki\User\User;
17use MediaWiki\User\UserRigorOptions;
18use Wikimedia\IPUtils;
19use Wikimedia\Rdbms\IConnectionProvider;
20use Wikimedia\Rdbms\IDBAccessObject;
21use Wikimedia\Timestamp\TimestampFormat as TS;
22
23/**
24 * A primary authentication provider that uses a temporary password.
25 *
26 * A successful login will force a password reset.
27 *
28 * @note For proper operation, this should generally come before any other
29 *  password-based authentication providers.
30 * @stable to extend
31 * @ingroup Auth
32 * @since 1.43
33 */
34abstract class AbstractTemporaryPasswordPrimaryAuthenticationProvider
35    extends AbstractPasswordPrimaryAuthenticationProvider
36{
37    /** @var bool */
38    protected $emailEnabled = null;
39
40    /** @var int */
41    protected $newPasswordExpiry = null;
42
43    /** @var int */
44    protected $passwordReminderResendTime = null;
45
46    protected IConnectionProvider $dbProvider;
47    protected UserOptionsLookup $userOptionsLookup;
48
49    /**
50     * @param IConnectionProvider $dbProvider
51     * @param UserOptionsLookup $userOptionsLookup
52     * @param array $params
53     *  - emailEnabled: (bool) must be true for the option to email passwords to be present
54     *  - newPasswordExpiry: (int) expiration time of temporary passwords, in seconds
55     *  - passwordReminderResendTime: (int) cooldown period in hours until a password reminder can
56     *    be sent to the same user again
57     */
58    public function __construct(
59        IConnectionProvider $dbProvider,
60        UserOptionsLookup $userOptionsLookup,
61        $params = []
62    ) {
63        parent::__construct( $params );
64
65        if ( isset( $params['emailEnabled'] ) ) {
66            $this->emailEnabled = (bool)$params['emailEnabled'];
67        }
68        if ( isset( $params['newPasswordExpiry'] ) ) {
69            $this->newPasswordExpiry = (int)$params['newPasswordExpiry'];
70        }
71        if ( isset( $params['passwordReminderResendTime'] ) ) {
72            $this->passwordReminderResendTime = $params['passwordReminderResendTime'];
73        }
74        $this->dbProvider = $dbProvider;
75        $this->userOptionsLookup = $userOptionsLookup;
76    }
77
78    protected function postInitSetup() {
79        $this->emailEnabled ??= $this->config->get( MainConfigNames::EnableEmail );
80        $this->newPasswordExpiry ??= $this->config->get( MainConfigNames::NewPasswordExpiry );
81        $this->passwordReminderResendTime ??=
82            $this->config->get( MainConfigNames::PasswordReminderResendTime );
83    }
84
85    /** @inheritDoc */
86    protected function getPasswordResetData( $username, $data ) {
87        // Always reset
88        return (object)[
89            'msg' => wfMessage( 'resetpass-temp-emailed' ),
90            'hard' => true,
91        ];
92    }
93
94    /** @inheritDoc */
95    public function getAuthenticationRequests( $action, array $options ) {
96        switch ( $action ) {
97            case AuthManager::ACTION_LOGIN:
98                return [ new PasswordAuthenticationRequest() ];
99
100            case AuthManager::ACTION_CHANGE:
101                return [ TemporaryPasswordAuthenticationRequest::newRandom() ];
102
103            case AuthManager::ACTION_CREATE:
104                // Allow named users creating a new account to email a temporary password to a given address
105                // in case they are creating an account for somebody else.
106                // This isn't a likely scenario for account creations by anonymous or temporary users
107                // and is therefore disabled for them (T328718).
108                if (
109                    isset( $options['username'] ) &&
110                    !$this->userNameUtils->isTemp( $options['username'] ) &&
111                    $this->emailEnabled
112                ) {
113                    return [ TemporaryPasswordAuthenticationRequest::newRandom() ];
114                } else {
115                    return [];
116                }
117
118            case AuthManager::ACTION_REMOVE:
119                return [ new TemporaryPasswordAuthenticationRequest ];
120
121            default:
122                return [];
123        }
124    }
125
126    /** @inheritDoc */
127    public function beginPrimaryAuthentication( array $reqs ) {
128        $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
129        if ( !$req || $req->username === null || $req->password === null ) {
130            return AuthenticationResponse::newAbstain();
131        }
132
133        $username = $this->userNameUtils->getCanonical(
134            $req->username, UserRigorOptions::RIGOR_USABLE );
135        if ( $username === false ) {
136            return AuthenticationResponse::newAbstain();
137        }
138
139        [ $tempPassHash, $tempPassTime ] = $this->getTemporaryPassword( $username, IDBAccessObject::READ_LATEST );
140        if ( !$tempPassHash ) {
141            return AuthenticationResponse::newAbstain();
142        }
143
144        $status = $this->checkPasswordValidity( $username, $req->password );
145        if ( !$status->isOK() ) {
146            return $this->getFatalPasswordErrorResponse( $username, $status );
147        }
148
149        if ( !$tempPassHash->verify( $req->password ) ||
150            !$this->isTimestampValid( $tempPassTime )
151        ) {
152            return $this->failResponse( $req );
153        }
154
155        // Add an extra log entry since a temporary password is
156        // an unusual way to log in, so its important to keep track
157        // of in case of abuse.
158        $this->logger->info( "{user} successfully logged in using temp password",
159            [
160                'provider' => static::class,
161                'user' => $username,
162                'requestIP' => $this->manager->getRequest()->getIP()
163            ]
164        );
165
166        $this->setPasswordResetFlag( $username, $status );
167
168        return AuthenticationResponse::newPass( $username );
169    }
170
171    /** @inheritDoc */
172    public function testUserCanAuthenticate( $username ) {
173        $username = $this->userNameUtils->getCanonical( $username, UserRigorOptions::RIGOR_USABLE );
174        if ( $username === false ) {
175            return false;
176        }
177
178        [ $tempPassHash, $tempPassTime ] = $this->getTemporaryPassword( $username );
179        return $tempPassHash &&
180            !( $tempPassHash instanceof InvalidPassword ) &&
181            $this->isTimestampValid( $tempPassTime );
182    }
183
184    /** @inheritDoc */
185    public function providerAllowsAuthenticationDataChange(
186        AuthenticationRequest $req, $checkData = true
187    ) {
188        if ( get_class( $req ) !== TemporaryPasswordAuthenticationRequest::class ) {
189            // We don't really ignore it, but this is what the caller expects.
190            return \StatusValue::newGood( 'ignored' );
191        }
192
193        if ( !$checkData ) {
194            return \StatusValue::newGood();
195        }
196
197        $username = $this->userNameUtils->getCanonical(
198            $req->username, UserRigorOptions::RIGOR_USABLE );
199        if ( $username === false ) {
200            return \StatusValue::newGood( 'ignored' );
201        }
202
203        [ $tempPassHash, $tempPassTime ] = $this->getTemporaryPassword( $username, IDBAccessObject::READ_LATEST );
204        if ( !$tempPassHash ) {
205            return \StatusValue::newGood( 'ignored' );
206        }
207
208        $sv = \StatusValue::newGood();
209        if ( $req->password !== null ) {
210            $sv->merge( $this->checkPasswordValidity( $username, $req->password ) );
211
212            if ( $req->mailpassword ) {
213                if ( !$this->emailEnabled ) {
214                    return \StatusValue::newFatal( 'passwordreset-emaildisabled' );
215                }
216
217                // We don't check whether the user has an email address;
218                // that information should not be exposed to the caller.
219
220                // do not allow temporary password creation within
221                // $wgPasswordReminderResendTime from the last attempt
222                if (
223                    $this->passwordReminderResendTime
224                    && $tempPassTime
225                    && time() < (int)wfTimestamp( TS::UNIX, $tempPassTime )
226                        + $this->passwordReminderResendTime * 3600
227                ) {
228                    // Round the time in hours to 3 d.p., in case someone is specifying
229                    // minutes or seconds.
230                    return \StatusValue::newFatal( 'throttled-mailpassword',
231                        round( $this->passwordReminderResendTime, 3 ) );
232                }
233
234                if ( !$req->caller ) {
235                    return \StatusValue::newFatal( 'passwordreset-nocaller' );
236                }
237                if ( !IPUtils::isValid( $req->caller ) ) {
238                    $caller = User::newFromName( $req->caller );
239                    if ( !$caller ) {
240                        return \StatusValue::newFatal( 'passwordreset-nosuchcaller', $req->caller );
241                    }
242                }
243            }
244        }
245        return $sv;
246    }
247
248    public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
249        $username = $req->username !== null ?
250            $this->userNameUtils->getCanonical( $req->username, UserRigorOptions::RIGOR_USABLE ) : false;
251        if ( $username === false ) {
252            return;
253        }
254
255        $sendMail = false;
256        if ( $req->action !== AuthManager::ACTION_REMOVE &&
257            get_class( $req ) === TemporaryPasswordAuthenticationRequest::class
258        ) {
259            $tempPassHash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
260            $tempPassTime = wfTimestampNow();
261            $sendMail = $req->mailpassword;
262            // Prevent other temp password providers from sending duplicate emails
263            $req->mailpassword = false;
264        } else {
265            // Invalidate the temporary password when any other auth is reset, or when removing
266            $tempPassHash = PasswordFactory::newInvalidPassword();
267            $tempPassTime = null;
268        }
269
270        $this->setTemporaryPassword( $username, $tempPassHash, $tempPassTime );
271
272        if ( $sendMail ) {
273            $this->maybeSendPasswordResetEmail( $req );
274        }
275    }
276
277    /** @inheritDoc */
278    public function accountCreationType() {
279        return self::TYPE_CREATE;
280    }
281
282    /** @inheritDoc */
283    public function testForAccountCreation( $user, $creator, array $reqs ) {
284        /** @var TemporaryPasswordAuthenticationRequest $req */
285        $req = AuthenticationRequest::getRequestByClass(
286            $reqs, TemporaryPasswordAuthenticationRequest::class
287        );
288
289        $ret = \StatusValue::newGood();
290        if ( $req ) {
291            if ( $req->mailpassword ) {
292                if ( !$this->emailEnabled ) {
293                    $ret->merge( \StatusValue::newFatal( 'emaildisabled' ) );
294                } elseif ( !$user->getEmail() ) {
295                    $ret->merge( \StatusValue::newFatal( 'noemailcreate' ) );
296                }
297            }
298
299            $ret->merge(
300                $this->checkPasswordValidity( $user->getName(), $req->password )
301            );
302        }
303        return $ret;
304    }
305
306    /** @inheritDoc */
307    public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
308        /** @var TemporaryPasswordAuthenticationRequest $req */
309        $req = AuthenticationRequest::getRequestByClass(
310            $reqs, TemporaryPasswordAuthenticationRequest::class
311        );
312        if ( $req && $req->username !== null && $req->password !== null ) {
313            // Nothing we can do yet, because the user isn't in the DB yet
314            if ( $req->username !== $user->getName() ) {
315                $req = clone $req;
316                $req->username = $user->getName();
317            }
318
319            if ( $req->mailpassword ) {
320                // prevent EmailNotificationSecondaryAuthenticationProvider from sending another mail
321                $this->manager->setAuthenticationSessionData( 'no-email', true );
322            }
323
324            $ret = AuthenticationResponse::newPass( $req->username );
325            $ret->createRequest = $req;
326            return $ret;
327        }
328        return AuthenticationResponse::newAbstain();
329    }
330
331    /** @inheritDoc */
332    public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
333        /** @var TemporaryPasswordAuthenticationRequest $req */
334        $req = $res->createRequest;
335        $mailpassword = $req->mailpassword;
336        // Prevent providerChangeAuthenticationData() from sending the wrong email
337        $req->mailpassword = false;
338
339        // Now that the user is in the DB, set the password on it.
340        $this->providerChangeAuthenticationData( $req );
341
342        if ( $mailpassword ) {
343            $this->maybeSendNewAccountEmail( $user, $creator, $req->password );
344        }
345
346        return $mailpassword ? 'byemail' : null;
347    }
348
349    /**
350     * Check that a temporary password is still valid (hasn't expired).
351     * @param string|int|null $timestamp Timestamp in the database's format; null means it doesn't
352     *   expire
353     * @return bool
354     */
355    protected function isTimestampValid( $timestamp ) {
356        $time = wfTimestampOrNull( TS::MW, $timestamp );
357        if ( $time !== null ) {
358            $expiry = (int)wfTimestamp( TS::UNIX, $time ) + $this->newPasswordExpiry;
359            if ( time() >= $expiry ) {
360                return false;
361            }
362        }
363        return true;
364    }
365
366    /**
367     * Wait for the new account to be recorded, and if successful, send an email about the new
368     * account creation and the temporary password.
369     *
370     * If overridden, the override must call sendNewAccountEmail().
371     *
372     * @stable to override
373     * @param User $user The new user account
374     * @param User $creatingUser The user who created the account (can be anonymous)
375     * @param string $password The temporary password
376     */
377    protected function maybeSendNewAccountEmail( User $user, User $creatingUser, $password ): void {
378        // Send email after DB commit (the callback does not run in case of DB rollback)
379        $this->dbProvider->getPrimaryDatabase()->onTransactionCommitOrIdle(
380            function () use ( $user, $creatingUser, $password ) {
381                $this->sendNewAccountEmail( $user, $creatingUser, $password );
382            },
383            __METHOD__
384        );
385    }
386
387    /**
388     * Send an email about the new account creation and the temporary password.
389     *
390     * @stable to override
391     * @param User $user The new user account
392     * @param User $creatingUser The user who created the account (can be anonymous)
393     * @param string $password The temporary password
394     */
395    protected function sendNewAccountEmail( User $user, User $creatingUser, $password ): void {
396        $ip = $creatingUser->getRequest()->getIP();
397        // @codeCoverageIgnoreStart
398        if ( !$ip ) {
399            return;
400        }
401        // @codeCoverageIgnoreEnd
402
403        $this->getHookRunner()->onUser__mailPasswordInternal( $creatingUser, $ip, $user );
404
405        $mainPageUrl = Title::newMainPage()->getCanonicalURL();
406        $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
407        $subjectMessage = wfMessage( 'createaccount-title' );
408        $bodyMessage = wfMessage( 'createaccount-text', $ip, $user->getName(), $password,
409            '<' . $mainPageUrl . '>', round( $this->newPasswordExpiry / 86400 ) );
410
411        $this->getHookRunner()->onUserModifyCreateAccountEmail(
412            $user,
413            $creatingUser,
414            $subjectMessage,
415            $bodyMessage
416        );
417
418        $status = $user->sendMail(
419            $subjectMessage->inLanguage( $userLanguage )->text(),
420            $bodyMessage->inLanguage( $userLanguage )->text()
421        );
422
423        // @codeCoverageIgnoreStart
424        if ( !$status->isGood() ) {
425            $this->logger->warning( 'Could not send account creation email: ' .
426                $status->getWikiText( false, false, 'en' ) );
427        }
428        // @codeCoverageIgnoreEnd
429    }
430
431    /**
432     * Wait for the new temporary password to be recorded, and if successful, send an email about it.
433     *
434     * If overridden, the override must call sendPasswordResetEmail().
435     *
436     * @stable to override
437     * @param TemporaryPasswordAuthenticationRequest $req
438     */
439    protected function maybeSendPasswordResetEmail( TemporaryPasswordAuthenticationRequest $req ): void {
440        // Send email after DB commit (the callback does not run in case of DB rollback)
441        $this->dbProvider->getPrimaryDatabase()->onTransactionCommitOrIdle(
442            function () use ( $req ) {
443                $this->sendPasswordResetEmail( $req );
444            },
445            __METHOD__
446        );
447    }
448
449    /**
450     * Send an email about the new temporary password.
451     *
452     * @stable to override
453     * @param TemporaryPasswordAuthenticationRequest $req
454     */
455    protected function sendPasswordResetEmail( TemporaryPasswordAuthenticationRequest $req ): void {
456        $user = User::newFromName( $req->username );
457        if ( !$user ) {
458            return;
459        }
460        $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
461        $callerIsAnon = IPUtils::isValid( $req->caller );
462        $callerName = $callerIsAnon ? $req->caller : User::newFromName( $req->caller )->getName();
463        $passwordMessage = wfMessage( 'passwordreset-emailelement', $user->getName(),
464            $req->password )->inLanguage( $userLanguage );
465        $emailMessage = wfMessage( $callerIsAnon ? 'passwordreset-emailtext-ip'
466            : 'passwordreset-emailtext-user' )->inLanguage( $userLanguage );
467        $body = $emailMessage->params( $callerName, $passwordMessage, 1,
468            '<' . Title::newMainPage()->getCanonicalURL() . '>',
469            round( $this->newPasswordExpiry / 86400 ) )->text();
470
471        // Hint that the user can choose to require email address to request a temporary password
472        if (
473            !$this->userOptionsLookup->getBoolOption( $user, 'requireemail' )
474        ) {
475            $url = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-personal-email' )
476                ->getCanonicalURL();
477            $body .= "\n\n" . wfMessage( 'passwordreset-emailtext-require-email' )
478                ->inLanguage( $userLanguage )
479                ->params( "<$url>" )
480                ->text();
481        }
482
483        $emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
484        $user->sendMail( $emailTitle->text(), $body );
485    }
486
487    /**
488     * Return a tuple of temporary password and the time when it was generated.
489     *
490     * The password may be an InvalidPassword to represent that it was unset, or null if the user
491     * can't authenticate for other reasons.
492     *
493     * The time is a a timestamp in the database's format or null (use wfTimestampOrNull() to parse
494     * it). If it's null, the password doesn't expire. Otherwise, the password should be considered
495     * expired after $wgNewPasswordExpiry seconds since that time.
496     *
497     * @stable to override
498     * @param string $username Canonical username
499     * @param int $flags Bitfield of IDBAccessObject::READ_* constants
500     * @return array
501     * @phan-return array{0:?Password, 1:?string|int}
502     */
503    abstract protected function getTemporaryPassword( string $username, $flags = IDBAccessObject::READ_NORMAL ): array;
504
505    /**
506     * Set a temporary password and the time when it was generated.
507     *
508     * @param string $username Canonical username
509     * @param Password $tempPassHash Password, or an InvalidPassword to unset
510     * @param string|int|null $tempPassTime Timestamp in a format accepted by wfTimestampOrNull();
511     *   null means it doesn't expire
512     */
513    abstract protected function setTemporaryPassword( string $username, Password $tempPassHash, $tempPassTime ): void;
514}