wiki.techinc.nl/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php
Tim Starling 68c433bd23 Hooks::run() call site migration
Migrate all callers of Hooks::run() to use the new
HookContainer/HookRunner system.

General principles:
* Use DI if it is already used. We're not changing the way state is
  managed in this patch.
* HookContainer is always injected, not HookRunner. HookContainer
  is a service, it's a more generic interface, it is the only
  thing that provides isRegistered() which is needed in some cases,
  and a HookRunner can be efficiently constructed from it
  (confirmed by benchmark). Because HookContainer is needed
  for object construction, it is also needed by all factories.
* "Ask your friendly local base class". Big hierarchies like
  SpecialPage and ApiBase have getHookContainer() and getHookRunner()
  methods in the base class, and classes that extend that base class
  are not expected to know or care where the base class gets its
  HookContainer from.
* ProtectedHookAccessorTrait provides protected getHookContainer() and
  getHookRunner() methods, getting them from the global service
  container. The point of this is to ease migration to DI by ensuring
  that call sites ask their local friendly base class rather than
  getting a HookRunner from the service container directly.
* Private $this->hookRunner. In some smaller classes where accessor
  methods did not seem warranted, there is a private HookRunner property
  which is accessed directly. Very rarely (two cases), there is a
  protected property, for consistency with code that conventionally
  assumes protected=private, but in cases where the class might actually
  be overridden, a protected accessor is preferred over a protected
  property.
* The last resort: Hooks::runner(). Mostly for static, file-scope and
  global code. In a few cases it was used for objects with broken
  construction schemes, out of horror or laziness.

Constructors with new required arguments:
* AuthManager
* BadFileLookup
* BlockManager
* ClassicInterwikiLookup
* ContentHandlerFactory
* ContentSecurityPolicy
* DefaultOptionsManager
* DerivedPageDataUpdater
* FullSearchResultWidget
* HtmlCacheUpdater
* LanguageFactory
* LanguageNameUtils
* LinkRenderer
* LinkRendererFactory
* LocalisationCache
* MagicWordFactory
* MessageCache
* NamespaceInfo
* PageEditStash
* PageHandlerFactory
* PageUpdater
* ParserFactory
* PermissionManager
* RevisionStore
* RevisionStoreFactory
* SearchEngineConfig
* SearchEngineFactory
* SearchFormWidget
* SearchNearMatcher
* SessionBackend
* SpecialPageFactory
* UserNameUtils
* UserOptionsManager
* WatchedItemQueryService
* WatchedItemStore

Constructors with new optional arguments:
* DefaultPreferencesFactory
* Language
* LinkHolderArray
* MovePage
* Parser
* ParserCache
* PasswordReset
* Router

setHookContainer() now required after construction:
* AuthenticationProvider
* ResourceLoaderModule
* SearchEngine

Change-Id: Id442b0dbe43aba84bd5cf801d86dedc768b082c7
2020-05-30 14:23:28 +00:00

497 lines
15 KiB
PHP

<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
* @ingroup Auth
*/
namespace MediaWiki\Auth;
use SpecialPage;
use User;
use Wikimedia\IPUtils;
/**
* A primary authentication provider that uses the temporary password field in
* the 'user' table.
*
* A successful login will force a password reset.
*
* @note For proper operation, this should generally come before any other
* password-based authentication providers.
* @ingroup Auth
* @since 1.27
*/
class TemporaryPasswordPrimaryAuthenticationProvider
extends AbstractPasswordPrimaryAuthenticationProvider
{
/** @var bool */
protected $emailEnabled = null;
/** @var int */
protected $newPasswordExpiry = null;
/** @var int */
protected $passwordReminderResendTime = null;
/** @var bool */
protected $allowRequiringEmail = null;
/**
* @param array $params
* - emailEnabled: (bool) must be true for the option to email passwords to be present
* - newPasswordExpiry: (int) expiraton time of temporary passwords, in seconds
* - passwordReminderResendTime: (int) cooldown period in hours until a password reminder can
* be sent to the same user again,
*/
public function __construct( $params = [] ) {
parent::__construct( $params );
if ( isset( $params['emailEnabled'] ) ) {
$this->emailEnabled = (bool)$params['emailEnabled'];
}
if ( isset( $params['newPasswordExpiry'] ) ) {
$this->newPasswordExpiry = (int)$params['newPasswordExpiry'];
}
if ( isset( $params['passwordReminderResendTime'] ) ) {
$this->passwordReminderResendTime = $params['passwordReminderResendTime'];
}
if ( isset( $params['allowRequiringEmailForResets'] ) ) {
$this->allowRequiringEmail = $params['allowRequiringEmailForResets'];
}
}
public function setConfig( \Config $config ) {
parent::setConfig( $config );
if ( $this->emailEnabled === null ) {
$this->emailEnabled = $this->config->get( 'EnableEmail' );
}
if ( $this->newPasswordExpiry === null ) {
$this->newPasswordExpiry = $this->config->get( 'NewPasswordExpiry' );
}
if ( $this->passwordReminderResendTime === null ) {
$this->passwordReminderResendTime = $this->config->get( 'PasswordReminderResendTime' );
}
if ( $this->allowRequiringEmail === null ) {
$this->allowRequiringEmail = $this->config->get( 'AllowRequiringEmailForResets' );
}
}
protected function getPasswordResetData( $username, $data ) {
// Always reset
return (object)[
'msg' => wfMessage( 'resetpass-temp-emailed' ),
'hard' => true,
];
}
public function getAuthenticationRequests( $action, array $options ) {
switch ( $action ) {
case AuthManager::ACTION_LOGIN:
return [ new PasswordAuthenticationRequest() ];
case AuthManager::ACTION_CHANGE:
return [ TemporaryPasswordAuthenticationRequest::newRandom() ];
case AuthManager::ACTION_CREATE:
if ( isset( $options['username'] ) && $this->emailEnabled ) {
// Creating an account for someone else
return [ TemporaryPasswordAuthenticationRequest::newRandom() ];
} else {
// It's not terribly likely that an anonymous user will
// be creating an account for someone else.
return [];
}
case AuthManager::ACTION_REMOVE:
return [ new TemporaryPasswordAuthenticationRequest ];
default:
return [];
}
}
public function beginPrimaryAuthentication( array $reqs ) {
$req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
if ( !$req || $req->username === null || $req->password === null ) {
return AuthenticationResponse::newAbstain();
}
$username = User::getCanonicalName( $req->username, 'usable' );
if ( $username === false ) {
return AuthenticationResponse::newAbstain();
}
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
'user',
[
'user_id', 'user_newpassword', 'user_newpass_time',
],
[ 'user_name' => $username ],
__METHOD__
);
if ( !$row ) {
return AuthenticationResponse::newAbstain();
}
$status = $this->checkPasswordValidity( $username, $req->password );
if ( !$status->isOK() ) {
// Fatal, can't log in
return AuthenticationResponse::newFail( $status->getMessage() );
}
$pwhash = $this->getPassword( $row->user_newpassword );
if ( !$pwhash->verify( $req->password ) ) {
return $this->failResponse( $req );
}
if ( !$this->isTimestampValid( $row->user_newpass_time ) ) {
return $this->failResponse( $req );
}
// Add an extra log entry since a temporary password is
// an unusual way to log in, so its important to keep track
// of in case of abuse.
$this->logger->info( "{user} successfully logged in using temp password",
[
'user' => $username,
'requestIP' => $this->manager->getRequest()->getIP()
]
);
$this->setPasswordResetFlag( $username, $status );
return AuthenticationResponse::newPass( $username );
}
public function testUserCanAuthenticate( $username ) {
$username = User::getCanonicalName( $username, 'usable' );
if ( $username === false ) {
return false;
}
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
'user',
[ 'user_newpassword', 'user_newpass_time' ],
[ 'user_name' => $username ],
__METHOD__
);
if ( !$row ) {
return false;
}
if ( $this->getPassword( $row->user_newpassword ) instanceof \InvalidPassword ) {
return false;
}
if ( !$this->isTimestampValid( $row->user_newpass_time ) ) {
return false;
}
return true;
}
public function testUserExists( $username, $flags = User::READ_NORMAL ) {
$username = User::getCanonicalName( $username, 'usable' );
if ( $username === false ) {
return false;
}
list( $db, $options ) = \DBAccessObjectUtils::getDBOptions( $flags );
return (bool)wfGetDB( $db )->selectField(
[ 'user' ],
'user_id',
[ 'user_name' => $username ],
__METHOD__,
$options
);
}
public function providerAllowsAuthenticationDataChange(
AuthenticationRequest $req, $checkData = true
) {
if ( get_class( $req ) !== TemporaryPasswordAuthenticationRequest::class ) {
// We don't really ignore it, but this is what the caller expects.
return \StatusValue::newGood( 'ignored' );
}
if ( !$checkData ) {
return \StatusValue::newGood();
}
$username = User::getCanonicalName( $req->username, 'usable' );
if ( $username === false ) {
return \StatusValue::newGood( 'ignored' );
}
$row = wfGetDB( DB_MASTER )->selectRow(
'user',
[ 'user_id', 'user_newpass_time' ],
[ 'user_name' => $username ],
__METHOD__
);
if ( !$row ) {
return \StatusValue::newGood( 'ignored' );
}
$sv = \StatusValue::newGood();
if ( $req->password !== null ) {
$sv->merge( $this->checkPasswordValidity( $username, $req->password ) );
if ( $req->mailpassword ) {
if ( !$this->emailEnabled ) {
return \StatusValue::newFatal( 'passwordreset-emaildisabled' );
}
// We don't check whether the user has an email address;
// that information should not be exposed to the caller.
// do not allow temporary password creation within
// $wgPasswordReminderResendTime from the last attempt
if (
$this->passwordReminderResendTime
&& $row->user_newpass_time
&& time() < (int)wfTimestamp( TS_UNIX, $row->user_newpass_time )
+ $this->passwordReminderResendTime * 3600
) {
// Round the time in hours to 3 d.p., in case someone is specifying
// minutes or seconds.
return \StatusValue::newFatal( 'throttled-mailpassword',
round( $this->passwordReminderResendTime, 3 ) );
}
if ( !$req->caller ) {
return \StatusValue::newFatal( 'passwordreset-nocaller' );
}
if ( !IPUtils::isValid( $req->caller ) ) {
$caller = User::newFromName( $req->caller );
if ( !$caller ) {
return \StatusValue::newFatal( 'passwordreset-nosuchcaller', $req->caller );
}
}
}
}
return $sv;
}
public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
$username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false;
if ( $username === false ) {
return;
}
$dbw = wfGetDB( DB_MASTER );
$sendMail = false;
if ( $req->action !== AuthManager::ACTION_REMOVE &&
get_class( $req ) === TemporaryPasswordAuthenticationRequest::class
) {
$pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
$newpassTime = $dbw->timestamp();
$sendMail = $req->mailpassword;
} else {
// Invalidate the temporary password when any other auth is reset, or when removing
$pwhash = $this->getPasswordFactory()->newFromCiphertext( null );
$newpassTime = null;
}
$dbw->update(
'user',
[
'user_newpassword' => $pwhash->toString(),
'user_newpass_time' => $newpassTime,
],
[ 'user_name' => $username ],
__METHOD__
);
if ( $sendMail ) {
// Send email after DB commit
$dbw->onTransactionCommitOrIdle(
function () use ( $req ) {
/** @var TemporaryPasswordAuthenticationRequest $req */
$this->sendPasswordResetEmail( $req );
},
__METHOD__
);
}
}
public function accountCreationType() {
return self::TYPE_CREATE;
}
public function testForAccountCreation( $user, $creator, array $reqs ) {
/** @var TemporaryPasswordAuthenticationRequest $req */
$req = AuthenticationRequest::getRequestByClass(
$reqs, TemporaryPasswordAuthenticationRequest::class
);
$ret = \StatusValue::newGood();
if ( $req ) {
if ( $req->mailpassword ) {
if ( !$this->emailEnabled ) {
$ret->merge( \StatusValue::newFatal( 'emaildisabled' ) );
} elseif ( !$user->getEmail() ) {
$ret->merge( \StatusValue::newFatal( 'noemailcreate' ) );
}
}
$ret->merge(
$this->checkPasswordValidity( $user->getName(), $req->password )
);
}
return $ret;
}
public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
/** @var TemporaryPasswordAuthenticationRequest $req */
$req = AuthenticationRequest::getRequestByClass(
$reqs, TemporaryPasswordAuthenticationRequest::class
);
if ( $req && $req->username !== null && $req->password !== null ) {
// Nothing we can do yet, because the user isn't in the DB yet
if ( $req->username !== $user->getName() ) {
$req = clone $req;
$req->username = $user->getName();
}
if ( $req->mailpassword ) {
// prevent EmailNotificationSecondaryAuthenticationProvider from sending another mail
$this->manager->setAuthenticationSessionData( 'no-email', true );
}
$ret = AuthenticationResponse::newPass( $req->username );
$ret->createRequest = $req;
return $ret;
}
return AuthenticationResponse::newAbstain();
}
public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
/** @var TemporaryPasswordAuthenticationRequest $req */
$req = $res->createRequest;
$mailpassword = $req->mailpassword;
$req->mailpassword = false; // providerChangeAuthenticationData would send the wrong email
// Now that the user is in the DB, set the password on it.
$this->providerChangeAuthenticationData( $req );
if ( $mailpassword ) {
// Send email after DB commit
wfGetDB( DB_MASTER )->onTransactionCommitOrIdle(
function () use ( $user, $creator, $req ) {
$this->sendNewAccountEmail( $user, $creator, $req->password );
},
__METHOD__
);
}
return $mailpassword ? 'byemail' : null;
}
/**
* Check that a temporary password is still valid (hasn't expired).
* @param string $timestamp A timestamp in MediaWiki (TS_MW) format
* @return bool
*/
protected function isTimestampValid( $timestamp ) {
$time = wfTimestampOrNull( TS_MW, $timestamp );
if ( $time !== null ) {
$expiry = (int)wfTimestamp( TS_UNIX, $time ) + $this->newPasswordExpiry;
if ( time() >= $expiry ) {
return false;
}
}
return true;
}
/**
* Send an email about the new account creation and the temporary password.
* @param User $user The new user account
* @param User $creatingUser The user who created the account (can be anonymous)
* @param string $password The temporary password
* @return \Status
*/
protected function sendNewAccountEmail( User $user, User $creatingUser, $password ) {
$ip = $creatingUser->getRequest()->getIP();
// @codeCoverageIgnoreStart
if ( !$ip ) {
return \Status::newFatal( 'badipaddress' );
}
// @codeCoverageIgnoreEnd
$this->getHookRunner()->onUser__mailPasswordInternal( $creatingUser, $ip, $user );
$mainPageUrl = \Title::newMainPage()->getCanonicalURL();
$userLanguage = $user->getOption( 'language' );
$subjectMessage = wfMessage( 'createaccount-title' )->inLanguage( $userLanguage );
$bodyMessage = wfMessage( 'createaccount-text', $ip, $user->getName(), $password,
'<' . $mainPageUrl . '>', round( $this->newPasswordExpiry / 86400 ) )
->inLanguage( $userLanguage );
$status = $user->sendMail( $subjectMessage->text(), $bodyMessage->text() );
// TODO show 'mailerror' message on error, 'accmailtext' success message otherwise?
// @codeCoverageIgnoreStart
if ( !$status->isGood() ) {
$this->logger->warning( 'Could not send account creation email: ' .
$status->getWikiText( false, false, 'en' ) );
}
// @codeCoverageIgnoreEnd
return $status;
}
/**
* @param TemporaryPasswordAuthenticationRequest $req
* @return \Status
*/
protected function sendPasswordResetEmail( TemporaryPasswordAuthenticationRequest $req ) {
$user = User::newFromName( $req->username );
if ( !$user ) {
return \Status::newFatal( 'noname' );
}
$userLanguage = $user->getOption( 'language' );
$callerIsAnon = IPUtils::isValid( $req->caller );
$callerName = $callerIsAnon ? $req->caller : User::newFromName( $req->caller )->getName();
$passwordMessage = wfMessage( 'passwordreset-emailelement', $user->getName(),
$req->password )->inLanguage( $userLanguage );
$emailMessage = wfMessage( $callerIsAnon ? 'passwordreset-emailtext-ip'
: 'passwordreset-emailtext-user' )->inLanguage( $userLanguage );
$body = $emailMessage->params( $callerName, $passwordMessage->text(), 1,
'<' . \Title::newMainPage()->getCanonicalURL() . '>',
round( $this->newPasswordExpiry / 86400 ) )->text();
if ( $this->allowRequiringEmail && !$user->getBoolOption( 'requireemail' ) ) {
$body .= "\n\n";
$url = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-personal-email' )
->getFullURL();
$body .= wfMessage( 'passwordreset-emailtext-require-email' )
->inLanguage( $userLanguage )
->params( "<$url>" )
->text();
}
$emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
return $user->sendMail( $emailTitle->text(), $body );
}
}