MediaWiki master
TemporaryPasswordPrimaryAuthenticationProvider.php
Go to the documentation of this file.
1<?php
22namespace MediaWiki\Auth;
23
32use Wikimedia\IPUtils;
34
48{
50 protected $emailEnabled = null;
51
53 protected $newPasswordExpiry = null;
54
57
59 private $dbProvider;
60
62 private $userOptionsLookup;
63
73 public function __construct(
74 IConnectionProvider $dbProvider,
75 UserOptionsLookup $userOptionsLookup,
76 $params = []
77 ) {
78 parent::__construct( $params );
79
80 if ( isset( $params['emailEnabled'] ) ) {
81 $this->emailEnabled = (bool)$params['emailEnabled'];
82 }
83 if ( isset( $params['newPasswordExpiry'] ) ) {
84 $this->newPasswordExpiry = (int)$params['newPasswordExpiry'];
85 }
86 if ( isset( $params['passwordReminderResendTime'] ) ) {
87 $this->passwordReminderResendTime = $params['passwordReminderResendTime'];
88 }
89 $this->dbProvider = $dbProvider;
90 $this->userOptionsLookup = $userOptionsLookup;
91 }
92
93 protected function postInitSetup() {
94 $this->emailEnabled ??= $this->config->get( MainConfigNames::EnableEmail );
95 $this->newPasswordExpiry ??= $this->config->get( MainConfigNames::NewPasswordExpiry );
96 $this->passwordReminderResendTime ??=
98 }
99
100 protected function getPasswordResetData( $username, $data ) {
101 // Always reset
102 return (object)[
103 'msg' => wfMessage( 'resetpass-temp-emailed' ),
104 'hard' => true,
105 ];
106 }
107
108 public function getAuthenticationRequests( $action, array $options ) {
109 switch ( $action ) {
111 return [ new PasswordAuthenticationRequest() ];
112
115
117 // Allow named users creating a new account to email a temporary password to a given address
118 // in case they are creating an account for somebody else.
119 // This isn't a likely scenario for account creations by anonymous or temporary users
120 // and is therefore disabled for them (T328718).
121 if (
122 isset( $options['username'] ) &&
123 !$this->userNameUtils->isTemp( $options['username'] ) &&
124 $this->emailEnabled
125 ) {
127 } else {
128 return [];
129 }
130
133
134 default:
135 return [];
136 }
137 }
138
139 public function beginPrimaryAuthentication( array $reqs ) {
140 $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
141 if ( !$req || $req->username === null || $req->password === null ) {
143 }
144
145 $username = $this->userNameUtils->getCanonical(
146 $req->username, UserRigorOptions::RIGOR_USABLE );
147 if ( $username === false ) {
149 }
150
151 $row = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
152 ->select( [ 'user_id', 'user_newpassword', 'user_newpass_time' ] )
153 ->from( 'user' )
154 ->where( [ 'user_name' => $username ] )
155 ->caller( __METHOD__ )->fetchRow();
156 if ( !$row ) {
158 }
159
160 $status = $this->checkPasswordValidity( $username, $req->password );
161 if ( !$status->isOK() ) {
162 return $this->getFatalPasswordErrorResponse( $username, $status );
163 }
164
165 $pwhash = $this->getPassword( $row->user_newpassword );
166 if ( !$pwhash->verify( $req->password ) ||
167 !$this->isTimestampValid( $row->user_newpass_time )
168 ) {
169 return $this->failResponse( $req );
170 }
171
172 // Add an extra log entry since a temporary password is
173 // an unusual way to log in, so its important to keep track
174 // of in case of abuse.
175 $this->logger->info( "{user} successfully logged in using temp password",
176 [
177 'user' => $username,
178 'requestIP' => $this->manager->getRequest()->getIP()
179 ]
180 );
181
182 $this->setPasswordResetFlag( $username, $status );
183
184 return AuthenticationResponse::newPass( $username );
185 }
186
187 public function testUserCanAuthenticate( $username ) {
188 $username = $this->userNameUtils->getCanonical( $username, UserRigorOptions::RIGOR_USABLE );
189 if ( $username === false ) {
190 return false;
191 }
192
193 $row = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
194 ->select( [ 'user_newpassword', 'user_newpass_time' ] )
195 ->from( 'user' )
196 ->where( [ 'user_name' => $username ] )
197 ->caller( __METHOD__ )->fetchRow();
198 return $row &&
199 !( $this->getPassword( $row->user_newpassword ) instanceof InvalidPassword ) &&
200 $this->isTimestampValid( $row->user_newpass_time );
201 }
202
203 public function testUserExists( $username, $flags = IDBAccessObject::READ_NORMAL ) {
204 $username = $this->userNameUtils->getCanonical( $username, UserRigorOptions::RIGOR_USABLE );
205 if ( $username === false ) {
206 return false;
207 }
208
209 $db = \DBAccessObjectUtils::getDBFromRecency( $this->dbProvider, $flags );
210 return (bool)$db->newSelectQueryBuilder()
211 ->select( [ 'user_id' ] )
212 ->from( 'user' )
213 ->where( [ 'user_name' => $username ] )
214 ->recency( $flags )
215 ->caller( __METHOD__ )->fetchField();
216 }
217
219 AuthenticationRequest $req, $checkData = true
220 ) {
221 if ( get_class( $req ) !== TemporaryPasswordAuthenticationRequest::class ) {
222 // We don't really ignore it, but this is what the caller expects.
223 return \StatusValue::newGood( 'ignored' );
224 }
225
226 if ( !$checkData ) {
227 return \StatusValue::newGood();
228 }
229
230 $username = $this->userNameUtils->getCanonical(
231 $req->username, UserRigorOptions::RIGOR_USABLE );
232 if ( $username === false ) {
233 return \StatusValue::newGood( 'ignored' );
234 }
235
236 $row = $this->dbProvider->getPrimaryDatabase()->newSelectQueryBuilder()
237 ->select( [ 'user_id', 'user_newpass_time' ] )
238 ->from( 'user' )
239 ->where( [ 'user_name' => $username ] )
240 ->caller( __METHOD__ )->fetchRow();
241 if ( !$row ) {
242 return \StatusValue::newGood( 'ignored' );
243 }
244
245 $sv = \StatusValue::newGood();
246 if ( $req->password !== null ) {
247 $sv->merge( $this->checkPasswordValidity( $username, $req->password ) );
248
249 if ( $req->mailpassword ) {
250 if ( !$this->emailEnabled ) {
251 return \StatusValue::newFatal( 'passwordreset-emaildisabled' );
252 }
253
254 // We don't check whether the user has an email address;
255 // that information should not be exposed to the caller.
256
257 // do not allow temporary password creation within
258 // $wgPasswordReminderResendTime from the last attempt
259 if (
260 $this->passwordReminderResendTime
261 && $row->user_newpass_time
262 && time() < (int)wfTimestamp( TS_UNIX, $row->user_newpass_time )
263 + $this->passwordReminderResendTime * 3600
264 ) {
265 // Round the time in hours to 3 d.p., in case someone is specifying
266 // minutes or seconds.
267 return \StatusValue::newFatal( 'throttled-mailpassword',
268 round( $this->passwordReminderResendTime, 3 ) );
269 }
270
271 if ( !$req->caller ) {
272 return \StatusValue::newFatal( 'passwordreset-nocaller' );
273 }
274 if ( !IPUtils::isValid( $req->caller ) ) {
275 $caller = User::newFromName( $req->caller );
276 if ( !$caller ) {
277 return \StatusValue::newFatal( 'passwordreset-nosuchcaller', $req->caller );
278 }
279 }
280 }
281 }
282 return $sv;
283 }
284
286 $username = $req->username !== null ?
287 $this->userNameUtils->getCanonical( $req->username, UserRigorOptions::RIGOR_USABLE ) : false;
288 if ( $username === false ) {
289 return;
290 }
291
292 $dbw = $this->dbProvider->getPrimaryDatabase();
293
294 $sendMail = false;
295 if ( $req->action !== AuthManager::ACTION_REMOVE &&
296 get_class( $req ) === TemporaryPasswordAuthenticationRequest::class
297 ) {
298 $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
299 $newpassTime = $dbw->timestamp();
300 $sendMail = $req->mailpassword;
301 } else {
302 // Invalidate the temporary password when any other auth is reset, or when removing
303 $pwhash = $this->getPasswordFactory()->newFromCiphertext( null );
304 $newpassTime = null;
305 }
306
307 $dbw->newUpdateQueryBuilder()
308 ->update( 'user' )
309 ->set( [
310 'user_newpassword' => $pwhash->toString(),
311 'user_newpass_time' => $newpassTime,
312 ] )
313 ->where( [ 'user_name' => $username ] )
314 ->caller( __METHOD__ )->execute();
315
316 if ( $sendMail ) {
317 // Send email after DB commit
318 $dbw->onTransactionCommitOrIdle(
319 function () use ( $req ) {
321 $this->sendPasswordResetEmail( $req );
322 },
323 __METHOD__
324 );
325 }
326 }
327
328 public function accountCreationType() {
329 return self::TYPE_CREATE;
330 }
331
332 public function testForAccountCreation( $user, $creator, array $reqs ) {
335 $reqs, TemporaryPasswordAuthenticationRequest::class
336 );
337
338 $ret = \StatusValue::newGood();
339 if ( $req ) {
340 if ( $req->mailpassword ) {
341 if ( !$this->emailEnabled ) {
342 $ret->merge( \StatusValue::newFatal( 'emaildisabled' ) );
343 } elseif ( !$user->getEmail() ) {
344 $ret->merge( \StatusValue::newFatal( 'noemailcreate' ) );
345 }
346 }
347
348 $ret->merge(
349 $this->checkPasswordValidity( $user->getName(), $req->password )
350 );
351 }
352 return $ret;
353 }
354
355 public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
358 $reqs, TemporaryPasswordAuthenticationRequest::class
359 );
360 if ( $req && $req->username !== null && $req->password !== null ) {
361 // Nothing we can do yet, because the user isn't in the DB yet
362 if ( $req->username !== $user->getName() ) {
363 $req = clone $req;
364 $req->username = $user->getName();
365 }
366
367 if ( $req->mailpassword ) {
368 // prevent EmailNotificationSecondaryAuthenticationProvider from sending another mail
369 $this->manager->setAuthenticationSessionData( 'no-email', true );
370 }
371
372 $ret = AuthenticationResponse::newPass( $req->username );
373 $ret->createRequest = $req;
374 return $ret;
375 }
377 }
378
379 public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
381 $req = $res->createRequest;
382 $mailpassword = $req->mailpassword;
383 $req->mailpassword = false; // providerChangeAuthenticationData would send the wrong email
384
385 // Now that the user is in the DB, set the password on it.
387
388 if ( $mailpassword ) {
389 // Send email after DB commit
390 $this->dbProvider->getPrimaryDatabase()->onTransactionCommitOrIdle(
391 function () use ( $user, $creator, $req ) {
392 $this->sendNewAccountEmail( $user, $creator, $req->password );
393 },
394 __METHOD__
395 );
396 }
397
398 return $mailpassword ? 'byemail' : null;
399 }
400
406 protected function isTimestampValid( $timestamp ) {
407 $time = wfTimestampOrNull( TS_MW, $timestamp );
408 if ( $time !== null ) {
409 $expiry = (int)wfTimestamp( TS_UNIX, $time ) + $this->newPasswordExpiry;
410 if ( time() >= $expiry ) {
411 return false;
412 }
413 }
414 return true;
415 }
416
424 protected function sendNewAccountEmail( User $user, User $creatingUser, $password ) {
425 $ip = $creatingUser->getRequest()->getIP();
426 // @codeCoverageIgnoreStart
427 if ( !$ip ) {
428 return \MediaWiki\Status\Status::newFatal( 'badipaddress' );
429 }
430 // @codeCoverageIgnoreEnd
431
432 $this->getHookRunner()->onUser__mailPasswordInternal( $creatingUser, $ip, $user );
433
434 $mainPageUrl = Title::newMainPage()->getCanonicalURL();
435 $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
436 $subjectMessage = wfMessage( 'createaccount-title' )->inLanguage( $userLanguage );
437 $bodyMessage = wfMessage( 'createaccount-text', $ip, $user->getName(), $password,
438 '<' . $mainPageUrl . '>', round( $this->newPasswordExpiry / 86400 ) )
439 ->inLanguage( $userLanguage );
440
441 $status = $user->sendMail( $subjectMessage->text(), $bodyMessage->text() );
442
443 // TODO show 'mailerror' message on error, 'accmailtext' success message otherwise?
444 // @codeCoverageIgnoreStart
445 if ( !$status->isGood() ) {
446 $this->logger->warning( 'Could not send account creation email: ' .
447 $status->getWikiText( false, false, 'en' ) );
448 }
449 // @codeCoverageIgnoreEnd
450
451 return $status;
452 }
453
459 $user = User::newFromName( $req->username );
460 if ( !$user ) {
461 return \MediaWiki\Status\Status::newFatal( 'noname' );
462 }
463 $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
464 $callerIsAnon = IPUtils::isValid( $req->caller );
465 $callerName = $callerIsAnon ? $req->caller : User::newFromName( $req->caller )->getName();
466 $passwordMessage = wfMessage( 'passwordreset-emailelement', $user->getName(),
467 $req->password )->inLanguage( $userLanguage );
468 $emailMessage = wfMessage( $callerIsAnon ? 'passwordreset-emailtext-ip'
469 : 'passwordreset-emailtext-user' )->inLanguage( $userLanguage );
470 $body = $emailMessage->params( $callerName, $passwordMessage->text(), 1,
471 '<' . Title::newMainPage()->getCanonicalURL() . '>',
472 round( $this->newPasswordExpiry / 86400 ) )->text();
473
474 if ( !$this->userOptionsLookup->getBoolOption( $user, 'requireemail' ) ) {
475 $body .= "\n\n";
476 $url = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-personal-email' )
477 ->getCanonicalURL();
478 $body .= wfMessage( 'passwordreset-emailtext-require-email' )
479 ->inLanguage( $userLanguage )
480 ->params( "<$url>" )
481 ->text();
482 }
483
484 $emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
485 return $user->sendMail( $emailTitle->text(), $body );
486 }
487}
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()
Represents an invalid password hash.
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:2855
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2222
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:1568
Interface for database access objects.
Shared interface for rigor levels when dealing with User methods.
Provide primary and replica IDatabase connections.