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