MediaWiki master
TemporaryPasswordPrimaryAuthenticationProvider.php
Go to the documentation of this file.
1<?php
22namespace MediaWiki\Auth;
23
31use Wikimedia\IPUtils;
33
47{
49 protected $emailEnabled = null;
50
52 protected $newPasswordExpiry = null;
53
56
58 protected $allowRequiringEmail = null;
59
61 private $dbProvider;
62
64 private $userOptionsLookup;
65
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 ??=
103 $this->allowRequiringEmail ??=
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 ) {
118 return [ new PasswordAuthenticationRequest() ];
119
122
124 if ( isset( $options['username'] ) && $this->emailEnabled ) {
125 // Creating an account for someone else
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
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 ) {
145 }
146
147 $username = $this->userNameUtils->getCanonical(
148 $req->username, UserRigorOptions::RIGOR_USABLE );
149 if ( $username === false ) {
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 ) {
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
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
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 ) {
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 ) {
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 ) {
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 }
379 }
380
381 public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
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.
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
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
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
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}
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
array $params
The job parameters.
Basic framework for a primary authentication provider that uses passwords.
failResponse(PasswordAuthenticationRequest $req)
Return the appropriate response for failure.
setPasswordResetFlag( $username, Status $status, $data=null)
Check if the password should be reset.
getFatalPasswordErrorResponse(string $username, Status $status)
Adds user-friendly description to a fatal password validity check error.
const ACTION_CHANGE
Change a user's credentials.
const ACTION_REMOVE
Remove a user's credentials.
const ACTION_LOGIN
Log in with an existing (not necessarily local) user.
const ACTION_CREATE
Create a new user.
This is a value object for authentication requests.
static getRequestByClass(array $reqs, $class, $allowSubclasses=false)
Select a request by class name.
This is a value object to hold authentication response data.
This is a value object for authentication requests with a username and password.
This represents the intention to set a temporary password for the user.
static newRandom()
Return an instance with a new, random password.
A primary authentication provider that uses the temporary password field in the 'user' table.
testUserExists( $username, $flags=IDBAccessObject::READ_NORMAL)
Test whether the named user exists.
__construct(IConnectionProvider $dbProvider, UserOptionsLookup $userOptionsLookup, $params=[])
beginPrimaryAccountCreation( $user, $creator, array $reqs)
Start an account creation flow.
sendNewAccountEmail(User $user, User $creatingUser, $password)
Send an email about the new account creation and the temporary password.
providerAllowsAuthenticationDataChange(AuthenticationRequest $req, $checkData=true)
Validate a change of authentication data (e.g.
finishAccountCreation( $user, $creator, AuthenticationResponse $res)
Post-creation callback.Called after the user is added to the database, before secondary authenticatio...
testForAccountCreation( $user, $creator, array $reqs)
Determine whether an account creation may begin.Called from AuthManager::beginAccountCreation()No nee...
providerChangeAuthenticationData(AuthenticationRequest $req)
Change or remove authentication data (e.g.
isTimestampValid( $timestamp)
Check that a temporary password is still valid (hasn't expired).
postInitSetup()
A provider can override this to do any necessary setup after init() is called.
testUserCanAuthenticate( $username)
Test whether the named user can authenticate with this provider.Should return true if the provider ha...
A class containing constants representing the names of configuration variables.
const PasswordReminderResendTime
Name constant for the PasswordReminderResendTime setting, for use with Config::get()
const EnableEmail
Name constant for the EnableEmail setting, for use with Config::get()
const NewPasswordExpiry
Name constant for the NewPasswordExpiry setting, for use with Config::get()
const AllowRequiringEmailForResets
Name constant for the AllowRequiringEmailForResets setting, for use with Config::get()
Parent class for all special pages.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
Represents a title within MediaWiki.
Definition Title.php:78
Provides access to user options.
internal since 1.36
Definition User.php:93
sendMail( $subject, $body, $from=null, $replyto=null)
Send an e-mail to this user's account.
Definition User.php:2996
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2340
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:1594
Interface for database access objects.
Shared interface for rigor levels when dealing with User methods.
Provide primary and replica IDatabase connections.