MediaWiki  master
TemporaryPasswordPrimaryAuthenticationProvider.php
Go to the documentation of this file.
1 <?php
22 namespace MediaWiki\Auth;
23 
28 use SpecialPage;
29 use User;
30 use Wikimedia\IPUtils;
32 
46 {
48  protected $emailEnabled = null;
49 
51  protected $newPasswordExpiry = null;
52 
54  protected $passwordReminderResendTime = null;
55 
57  protected $allowRequiringEmail = null;
58 
60  private $loadBalancer;
61 
63  private $userOptionsLookup;
64 
74  public function __construct(
75  ILoadBalancer $loadBalancer,
76  UserOptionsLookup $userOptionsLookup,
77  $params = []
78  ) {
79  parent::__construct( $params );
80 
81  if ( isset( $params['emailEnabled'] ) ) {
82  $this->emailEnabled = (bool)$params['emailEnabled'];
83  }
84  if ( isset( $params['newPasswordExpiry'] ) ) {
85  $this->newPasswordExpiry = (int)$params['newPasswordExpiry'];
86  }
87  if ( isset( $params['passwordReminderResendTime'] ) ) {
88  $this->passwordReminderResendTime = $params['passwordReminderResendTime'];
89  }
90  if ( isset( $params['allowRequiringEmailForResets'] ) ) {
91  $this->allowRequiringEmail = $params['allowRequiringEmailForResets'];
92  }
93  $this->loadBalancer = $loadBalancer;
94  $this->userOptionsLookup = $userOptionsLookup;
95  }
96 
97  protected function postInitSetup() {
98  $this->emailEnabled ??= $this->config->get( MainConfigNames::EnableEmail );
99  $this->newPasswordExpiry ??= $this->config->get( MainConfigNames::NewPasswordExpiry );
100  $this->passwordReminderResendTime ??=
101  $this->config->get( MainConfigNames::PasswordReminderResendTime );
102  $this->allowRequiringEmail ??=
103  $this->config->get( MainConfigNames::AllowRequiringEmailForResets );
104  }
105 
106  protected function getPasswordResetData( $username, $data ) {
107  // Always reset
108  return (object)[
109  'msg' => wfMessage( 'resetpass-temp-emailed' ),
110  'hard' => true,
111  ];
112  }
113 
114  public function getAuthenticationRequests( $action, array $options ) {
115  switch ( $action ) {
117  return [ new PasswordAuthenticationRequest() ];
118 
121 
123  if ( isset( $options['username'] ) && $this->emailEnabled ) {
124  // Creating an account for someone else
126  } else {
127  // It's not terribly likely that an anonymous user will
128  // be creating an account for someone else.
129  return [];
130  }
131 
134 
135  default:
136  return [];
137  }
138  }
139 
140  public function beginPrimaryAuthentication( array $reqs ) {
141  $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
142  if ( !$req || $req->username === null || $req->password === null ) {
144  }
145 
146  $username = $this->userNameUtils->getCanonical(
147  $req->username, UserRigorOptions::RIGOR_USABLE );
148  if ( $username === false ) {
150  }
151 
152  $row = $this->loadBalancer->getConnection( DB_REPLICA )->newSelectQueryBuilder()
153  ->select( [ 'user_id', 'user_newpassword', 'user_newpass_time' ] )
154  ->from( 'user' )
155  ->where( [ 'user_name' => $username ] )
156  ->caller( __METHOD__ )->fetchRow();
157  if ( !$row ) {
159  }
160 
161  $status = $this->checkPasswordValidity( $username, $req->password );
162  if ( !$status->isOK() ) {
163  // Fatal, can't log in
164  return AuthenticationResponse::newFail( $status->getMessage() );
165  }
166 
167  $pwhash = $this->getPassword( $row->user_newpassword );
168  if ( !$pwhash->verify( $req->password ) ) {
169  return $this->failResponse( $req );
170  }
171 
172  if ( !$this->isTimestampValid( $row->user_newpass_time ) ) {
173  return $this->failResponse( $req );
174  }
175 
176  // Add an extra log entry since a temporary password is
177  // an unusual way to log in, so its important to keep track
178  // of in case of abuse.
179  $this->logger->info( "{user} successfully logged in using temp password",
180  [
181  'user' => $username,
182  'requestIP' => $this->manager->getRequest()->getIP()
183  ]
184  );
185 
186  $this->setPasswordResetFlag( $username, $status );
187 
188  return AuthenticationResponse::newPass( $username );
189  }
190 
191  public function testUserCanAuthenticate( $username ) {
192  $username = $this->userNameUtils->getCanonical( $username, UserRigorOptions::RIGOR_USABLE );
193  if ( $username === false ) {
194  return false;
195  }
196 
197  $row = $this->loadBalancer->getConnection( DB_REPLICA )->newSelectQueryBuilder()
198  ->select( [ 'user_newpassword', 'user_newpass_time' ] )
199  ->from( 'user' )
200  ->where( [ 'user_name' => $username ] )
201  ->caller( __METHOD__ )->fetchRow();
202  if ( !$row ) {
203  return false;
204  }
205 
206  if ( $this->getPassword( $row->user_newpassword ) instanceof \InvalidPassword ) {
207  return false;
208  }
209 
210  if ( !$this->isTimestampValid( $row->user_newpass_time ) ) {
211  return false;
212  }
213 
214  return true;
215  }
216 
217  public function testUserExists( $username, $flags = User::READ_NORMAL ) {
218  $username = $this->userNameUtils->getCanonical( $username, UserRigorOptions::RIGOR_USABLE );
219  if ( $username === false ) {
220  return false;
221  }
222 
223  [ $db, $options ] = \DBAccessObjectUtils::getDBOptions( $flags );
224  return (bool)$this->loadBalancer->getConnection( $db )->newSelectQueryBuilder()
225  ->select( [ 'user_id' ] )
226  ->from( 'user' )
227  ->where( [ 'user_name' => $username ] )
228  ->options( $options )
229  ->caller( __METHOD__ )->fetchField();
230  }
231 
233  AuthenticationRequest $req, $checkData = true
234  ) {
235  if ( get_class( $req ) !== TemporaryPasswordAuthenticationRequest::class ) {
236  // We don't really ignore it, but this is what the caller expects.
237  return \StatusValue::newGood( 'ignored' );
238  }
239 
240  if ( !$checkData ) {
241  return \StatusValue::newGood();
242  }
243 
244  $username = $this->userNameUtils->getCanonical(
245  $req->username, UserRigorOptions::RIGOR_USABLE );
246  if ( $username === false ) {
247  return \StatusValue::newGood( 'ignored' );
248  }
249 
250  $row = $this->loadBalancer->getConnection( DB_PRIMARY )->newSelectQueryBuilder()
251  ->select( [ 'user_id', 'user_newpass_time' ] )
252  ->from( 'user' )
253  ->where( [ 'user_name' => $username ] )
254  ->caller( __METHOD__ )->fetchRow();
255  if ( !$row ) {
256  return \StatusValue::newGood( 'ignored' );
257  }
258 
259  $sv = \StatusValue::newGood();
260  if ( $req->password !== null ) {
261  $sv->merge( $this->checkPasswordValidity( $username, $req->password ) );
262 
263  if ( $req->mailpassword ) {
264  if ( !$this->emailEnabled ) {
265  return \StatusValue::newFatal( 'passwordreset-emaildisabled' );
266  }
267 
268  // We don't check whether the user has an email address;
269  // that information should not be exposed to the caller.
270 
271  // do not allow temporary password creation within
272  // $wgPasswordReminderResendTime from the last attempt
273  if (
274  $this->passwordReminderResendTime
275  && $row->user_newpass_time
276  && time() < (int)wfTimestamp( TS_UNIX, $row->user_newpass_time )
277  + $this->passwordReminderResendTime * 3600
278  ) {
279  // Round the time in hours to 3 d.p., in case someone is specifying
280  // minutes or seconds.
281  return \StatusValue::newFatal( 'throttled-mailpassword',
282  round( $this->passwordReminderResendTime, 3 ) );
283  }
284 
285  if ( !$req->caller ) {
286  return \StatusValue::newFatal( 'passwordreset-nocaller' );
287  }
288  if ( !IPUtils::isValid( $req->caller ) ) {
289  $caller = User::newFromName( $req->caller );
290  if ( !$caller ) {
291  return \StatusValue::newFatal( 'passwordreset-nosuchcaller', $req->caller );
292  }
293  }
294  }
295  }
296  return $sv;
297  }
298 
300  $username = $req->username !== null ?
301  $this->userNameUtils->getCanonical( $req->username, UserRigorOptions::RIGOR_USABLE ) : false;
302  if ( $username === false ) {
303  return;
304  }
305 
306  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
307 
308  $sendMail = false;
309  if ( $req->action !== AuthManager::ACTION_REMOVE &&
310  get_class( $req ) === TemporaryPasswordAuthenticationRequest::class
311  ) {
312  $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
313  $newpassTime = $dbw->timestamp();
314  $sendMail = $req->mailpassword;
315  } else {
316  // Invalidate the temporary password when any other auth is reset, or when removing
317  $pwhash = $this->getPasswordFactory()->newFromCiphertext( null );
318  $newpassTime = null;
319  }
320 
321  $dbw->update(
322  'user',
323  [
324  'user_newpassword' => $pwhash->toString(),
325  'user_newpass_time' => $newpassTime,
326  ],
327  [ 'user_name' => $username ],
328  __METHOD__
329  );
330 
331  if ( $sendMail ) {
332  // Send email after DB commit
333  $dbw->onTransactionCommitOrIdle(
334  function () use ( $req ) {
336  $this->sendPasswordResetEmail( $req );
337  },
338  __METHOD__
339  );
340  }
341  }
342 
343  public function accountCreationType() {
344  return self::TYPE_CREATE;
345  }
346 
347  public function testForAccountCreation( $user, $creator, array $reqs ) {
350  $reqs, TemporaryPasswordAuthenticationRequest::class
351  );
352 
353  $ret = \StatusValue::newGood();
354  if ( $req ) {
355  if ( $req->mailpassword ) {
356  if ( !$this->emailEnabled ) {
357  $ret->merge( \StatusValue::newFatal( 'emaildisabled' ) );
358  } elseif ( !$user->getEmail() ) {
359  $ret->merge( \StatusValue::newFatal( 'noemailcreate' ) );
360  }
361  }
362 
363  $ret->merge(
364  $this->checkPasswordValidity( $user->getName(), $req->password )
365  );
366  }
367  return $ret;
368  }
369 
370  public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
373  $reqs, TemporaryPasswordAuthenticationRequest::class
374  );
375  if ( $req && $req->username !== null && $req->password !== null ) {
376  // Nothing we can do yet, because the user isn't in the DB yet
377  if ( $req->username !== $user->getName() ) {
378  $req = clone $req;
379  $req->username = $user->getName();
380  }
381 
382  if ( $req->mailpassword ) {
383  // prevent EmailNotificationSecondaryAuthenticationProvider from sending another mail
384  $this->manager->setAuthenticationSessionData( 'no-email', true );
385  }
386 
387  $ret = AuthenticationResponse::newPass( $req->username );
388  $ret->createRequest = $req;
389  return $ret;
390  }
392  }
393 
394  public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
396  $req = $res->createRequest;
397  $mailpassword = $req->mailpassword;
398  $req->mailpassword = false; // providerChangeAuthenticationData would send the wrong email
399 
400  // Now that the user is in the DB, set the password on it.
401  $this->providerChangeAuthenticationData( $req );
402 
403  if ( $mailpassword ) {
404  // Send email after DB commit
405  $this->loadBalancer->getConnectionRef( DB_PRIMARY )->onTransactionCommitOrIdle(
406  function () use ( $user, $creator, $req ) {
407  $this->sendNewAccountEmail( $user, $creator, $req->password );
408  },
409  __METHOD__
410  );
411  }
412 
413  return $mailpassword ? 'byemail' : null;
414  }
415 
421  protected function isTimestampValid( $timestamp ) {
422  $time = wfTimestampOrNull( TS_MW, $timestamp );
423  if ( $time !== null ) {
424  $expiry = (int)wfTimestamp( TS_UNIX, $time ) + $this->newPasswordExpiry;
425  if ( time() >= $expiry ) {
426  return false;
427  }
428  }
429  return true;
430  }
431 
439  protected function sendNewAccountEmail( User $user, User $creatingUser, $password ) {
440  $ip = $creatingUser->getRequest()->getIP();
441  // @codeCoverageIgnoreStart
442  if ( !$ip ) {
443  return \Status::newFatal( 'badipaddress' );
444  }
445  // @codeCoverageIgnoreEnd
446 
447  $this->getHookRunner()->onUser__mailPasswordInternal( $creatingUser, $ip, $user );
448 
449  $mainPageUrl = Title::newMainPage()->getCanonicalURL();
450  $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
451  $subjectMessage = wfMessage( 'createaccount-title' )->inLanguage( $userLanguage );
452  $bodyMessage = wfMessage( 'createaccount-text', $ip, $user->getName(), $password,
453  '<' . $mainPageUrl . '>', round( $this->newPasswordExpiry / 86400 ) )
454  ->inLanguage( $userLanguage );
455 
456  $status = $user->sendMail( $subjectMessage->text(), $bodyMessage->text() );
457 
458  // TODO show 'mailerror' message on error, 'accmailtext' success message otherwise?
459  // @codeCoverageIgnoreStart
460  if ( !$status->isGood() ) {
461  $this->logger->warning( 'Could not send account creation email: ' .
462  $status->getWikiText( false, false, 'en' ) );
463  }
464  // @codeCoverageIgnoreEnd
465 
466  return $status;
467  }
468 
474  $user = User::newFromName( $req->username );
475  if ( !$user ) {
476  return \Status::newFatal( 'noname' );
477  }
478  $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
479  $callerIsAnon = IPUtils::isValid( $req->caller );
480  $callerName = $callerIsAnon ? $req->caller : User::newFromName( $req->caller )->getName();
481  $passwordMessage = wfMessage( 'passwordreset-emailelement', $user->getName(),
482  $req->password )->inLanguage( $userLanguage );
483  $emailMessage = wfMessage( $callerIsAnon ? 'passwordreset-emailtext-ip'
484  : 'passwordreset-emailtext-user' )->inLanguage( $userLanguage );
485  $body = $emailMessage->params( $callerName, $passwordMessage->text(), 1,
486  '<' . Title::newMainPage()->getCanonicalURL() . '>',
487  round( $this->newPasswordExpiry / 86400 ) )->text();
488 
489  if ( $this->allowRequiringEmail && !$this->userOptionsLookup
490  ->getBoolOption( $user, 'requireemail' )
491  ) {
492  $body .= "\n\n";
493  $url = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-personal-email' )
494  ->getCanonicalURL();
495  $body .= wfMessage( 'passwordreset-emailtext-require-email' )
496  ->inLanguage( $userLanguage )
497  ->params( "<$url>" )
498  ->text();
499  }
500 
501  $emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
502  return $user->sendMail( $emailTitle->text(), $body );
503  }
504 }
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.
static getDBOptions( $bitfield)
Get an appropriate DB index, options, and fallback DB index for a query.
Represents an invalid password hash.
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.
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.
static newFail(Message $msg, array $failReasons=[])
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.
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.
__construct(ILoadBalancer $loadBalancer, UserOptionsLookup $userOptionsLookup, $params=[])
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.
testUserExists( $username, $flags=User::READ_NORMAL)
Test whether the named user exists.
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()
Represents a title within MediaWiki.
Definition: Title.php:82
static newMainPage(MessageLocalizer $localizer=null)
Create a new Title for the Main Page.
Definition: Title.php:733
Provides access to user options.
Parent class for all special pages.
Definition: SpecialPage.php:45
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,...
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:71
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:2416
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:1680
static newFromName( $name, $validate='valid')
Definition: User.php:592
sendMail( $subject, $body, $from=null, $replyto=null)
Send an e-mail to this user's account.
Definition: User.php:3052
Shared interface for rigor levels when dealing with User methods.
This class is a delegate to ILBFactory for a given database cluster.
const DB_REPLICA
Definition: defines.php:26
const DB_PRIMARY
Definition: defines.php:28