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