diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/auth |
first commit
Diffstat (limited to 'www/wiki/includes/auth')
34 files changed, 7774 insertions, 0 deletions
diff --git a/www/wiki/includes/auth/AbstractAuthenticationProvider.php b/www/wiki/includes/auth/AbstractAuthenticationProvider.php new file mode 100644 index 00000000..58cec118 --- /dev/null +++ b/www/wiki/includes/auth/AbstractAuthenticationProvider.php @@ -0,0 +1,59 @@ +<?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 Config; +use Psr\Log\LoggerInterface; + +/** + * A base class that implements some of the boilerplate for an AuthenticationProvider + * @ingroup Auth + * @since 1.27 + */ +abstract class AbstractAuthenticationProvider implements AuthenticationProvider { + /** @var LoggerInterface */ + protected $logger; + /** @var AuthManager */ + protected $manager; + /** @var Config */ + protected $config; + + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + public function setManager( AuthManager $manager ) { + $this->manager = $manager; + } + + public function setConfig( Config $config ) { + $this->config = $config; + } + + /** + * @inheritDoc + * @note Override this if it makes sense to support more than one instance + */ + public function getUniqueId() { + return static::class; + } +} diff --git a/www/wiki/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 00000000..f5bfc2a2 --- /dev/null +++ b/www/wiki/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,171 @@ +<?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 Password; +use PasswordFactory; +use Status; + +/** + * Basic framework for a primary authentication provider that uses passwords + * @ingroup Auth + * @since 1.27 + */ +abstract class AbstractPasswordPrimaryAuthenticationProvider + extends AbstractPrimaryAuthenticationProvider +{ + /** @var bool Whether this provider should ABSTAIN (false) or FAIL (true) on password failure */ + protected $authoritative; + + private $passwordFactory = null; + + /** + * @param array $params Settings + * - authoritative: Whether this provider should ABSTAIN (false) or FAIL + * (true) on password failure + */ + public function __construct( array $params = [] ) { + $this->authoritative = !isset( $params['authoritative'] ) || (bool)$params['authoritative']; + } + + /** + * Get the PasswordFactory + * @return PasswordFactory + */ + protected function getPasswordFactory() { + if ( $this->passwordFactory === null ) { + $this->passwordFactory = new PasswordFactory(); + $this->passwordFactory->init( $this->config ); + } + return $this->passwordFactory; + } + + /** + * Get a Password object from the hash + * @param string $hash + * @return Password + */ + protected function getPassword( $hash ) { + $passwordFactory = $this->getPasswordFactory(); + try { + return $passwordFactory->newFromCiphertext( $hash ); + } catch ( \PasswordError $e ) { + $class = static::class; + $this->logger->debug( "Invalid password hash in {$class}::getPassword()" ); + return $passwordFactory->newFromCiphertext( null ); + } + } + + /** + * Return the appropriate response for failure + * @param PasswordAuthenticationRequest $req + * @return AuthenticationResponse + */ + protected function failResponse( PasswordAuthenticationRequest $req ) { + if ( $this->authoritative ) { + return AuthenticationResponse::newFail( + wfMessage( $req->password === '' ? 'wrongpasswordempty' : 'wrongpassword' ) + ); + } else { + return AuthenticationResponse::newAbstain(); + } + } + + /** + * Check that the password is valid + * + * This should be called *before* validating the password. If the result is + * not ok, login should fail immediately. + * + * @param string $username + * @param string $password + * @return Status + */ + protected function checkPasswordValidity( $username, $password ) { + return \User::newFromName( $username )->checkPasswordValidity( $password ); + } + + /** + * Check if the password should be reset + * + * This should be called after a successful login. It sets 'reset-pass' + * authentication data if necessary, see + * ResetPassSecondaryAuthenticationProvider. + * + * @param string $username + * @param Status $status From $this->checkPasswordValidity() + * @param mixed $data Passed through to $this->getPasswordResetData() + */ + protected function setPasswordResetFlag( $username, Status $status, $data = null ) { + $reset = $this->getPasswordResetData( $username, $data ); + + if ( !$reset && $this->config->get( 'InvalidPasswordReset' ) && !$status->isGood() ) { + $reset = (object)[ + 'msg' => $status->getMessage( 'resetpass-validity-soft' ), + 'hard' => false, + ]; + } + + if ( $reset ) { + $this->manager->setAuthenticationSessionData( 'reset-pass', $reset ); + } + } + + /** + * Get password reset data, if any + * + * @param string $username + * @param mixed $data + * @return object|null { 'hard' => bool, 'msg' => Message } + */ + protected function getPasswordResetData( $username, $data ) { + return null; + } + + /** + * Get expiration date for a new password, if any + * + * @param string $username + * @return string|null + */ + protected function getNewPasswordExpiry( $username ) { + $days = $this->config->get( 'PasswordExpirationDays' ); + $expires = $days ? wfTimestamp( TS_MW, time() + $days * 86400 ) : null; + + // Give extensions a chance to force an expiration + \Hooks::run( 'ResetPasswordExpiration', [ \User::newFromName( $username ), &$expires ] ); + + return $expires; + } + + public function getAuthenticationRequests( $action, array $options ) { + switch ( $action ) { + case AuthManager::ACTION_LOGIN: + case AuthManager::ACTION_REMOVE: + case AuthManager::ACTION_CREATE: + case AuthManager::ACTION_CHANGE: + return [ new PasswordAuthenticationRequest() ]; + default: + return []; + } + } +} diff --git a/www/wiki/includes/auth/AbstractPreAuthenticationProvider.php b/www/wiki/includes/auth/AbstractPreAuthenticationProvider.php new file mode 100644 index 00000000..d997dbbc --- /dev/null +++ b/www/wiki/includes/auth/AbstractPreAuthenticationProvider.php @@ -0,0 +1,62 @@ +<?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; + +/** + * A base class that implements some of the boilerplate for a PreAuthenticationProvider + * @ingroup Auth + * @since 1.27 + */ +abstract class AbstractPreAuthenticationProvider extends AbstractAuthenticationProvider + implements PreAuthenticationProvider +{ + + public function getAuthenticationRequests( $action, array $options ) { + return []; + } + + public function testForAuthentication( array $reqs ) { + return \StatusValue::newGood(); + } + + public function postAuthentication( $user, AuthenticationResponse $response ) { + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + return \StatusValue::newGood(); + } + + public function testUserForCreation( $user, $autocreate, array $options = [] ) { + return \StatusValue::newGood(); + } + + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) { + } + + public function testForAccountLink( $user ) { + return \StatusValue::newGood(); + } + + public function postAccountLink( $user, AuthenticationResponse $response ) { + } + +} diff --git a/www/wiki/includes/auth/AbstractPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/AbstractPrimaryAuthenticationProvider.php new file mode 100644 index 00000000..ca947b61 --- /dev/null +++ b/www/wiki/includes/auth/AbstractPrimaryAuthenticationProvider.php @@ -0,0 +1,118 @@ +<?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 User; + +/** + * A base class that implements some of the boilerplate for a PrimaryAuthenticationProvider + * + * @ingroup Auth + * @since 1.27 + */ +abstract class AbstractPrimaryAuthenticationProvider extends AbstractAuthenticationProvider + implements PrimaryAuthenticationProvider +{ + + public function continuePrimaryAuthentication( array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function postAuthentication( $user, AuthenticationResponse $response ) { + } + + public function testUserCanAuthenticate( $username ) { + // Assume it can authenticate if it exists + return $this->testUserExists( $username ); + } + + /** + * @inheritDoc + * @note Reimplement this if you do anything other than + * User::getCanonicalName( $req->username ) to determine the user being + * authenticated. + */ + public function providerNormalizeUsername( $username ) { + $name = User::getCanonicalName( $username ); + return $name === false ? null : $name; + } + + /** + * @inheritDoc + * @note Reimplement this if self::getAuthenticationRequests( AuthManager::ACTION_REMOVE ) + * doesn't return requests that will revoke all access for the user. + */ + public function providerRevokeAccessForUser( $username ) { + $reqs = $this->getAuthenticationRequests( + AuthManager::ACTION_REMOVE, [ 'username' => $username ] + ); + foreach ( $reqs as $req ) { + $req->username = $username; + $req->action = AuthManager::ACTION_REMOVE; + $this->providerChangeAuthenticationData( $req ); + } + } + + public function providerAllowsPropertyChange( $property ) { + return true; + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + return \StatusValue::newGood(); + } + + public function continuePrimaryAccountCreation( $user, $creator, array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function finishAccountCreation( $user, $creator, AuthenticationResponse $response ) { + return null; + } + + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) { + } + + public function testUserForCreation( $user, $autocreate, array $options = [] ) { + return \StatusValue::newGood(); + } + + public function autoCreatedAccount( $user, $source ) { + } + + public function beginPrimaryAccountLink( $user, array $reqs ) { + if ( $this->accountCreationType() === self::TYPE_LINK ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } else { + throw new \BadMethodCallException( + __METHOD__ . ' should not be called on a non-link provider.' + ); + } + } + + public function continuePrimaryAccountLink( $user, array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function postAccountLink( $user, AuthenticationResponse $response ) { + } + +} diff --git a/www/wiki/includes/auth/AbstractSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/AbstractSecondaryAuthenticationProvider.php new file mode 100644 index 00000000..4a2accaf --- /dev/null +++ b/www/wiki/includes/auth/AbstractSecondaryAuthenticationProvider.php @@ -0,0 +1,86 @@ +<?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; + +/** + * A base class that implements some of the boilerplate for a SecondaryAuthenticationProvider + * + * @ingroup Auth + * @since 1.27 + */ +abstract class AbstractSecondaryAuthenticationProvider extends AbstractAuthenticationProvider + implements SecondaryAuthenticationProvider +{ + + public function continueSecondaryAuthentication( $user, array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function postAuthentication( $user, AuthenticationResponse $response ) { + } + + public function providerAllowsPropertyChange( $property ) { + return true; + } + + /** + * @inheritDoc + * @note Reimplement this if self::getAuthenticationRequests( AuthManager::ACTION_REMOVE ) + * doesn't return requests that will revoke all access for the user. + */ + public function providerRevokeAccessForUser( $username ) { + $reqs = $this->getAuthenticationRequests( + AuthManager::ACTION_REMOVE, [ 'username' => $username ] + ); + foreach ( $reqs as $req ) { + $req->username = $username; + $this->providerChangeAuthenticationData( $req ); + } + } + + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ) { + return \StatusValue::newGood( 'ignored' ); + } + + public function providerChangeAuthenticationData( AuthenticationRequest $req ) { + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + return \StatusValue::newGood(); + } + + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) { + } + + public function testUserForCreation( $user, $autocreate, array $options = [] ) { + return \StatusValue::newGood(); + } + + public function autoCreatedAccount( $user, $source ) { + } +} diff --git a/www/wiki/includes/auth/AuthManager.php b/www/wiki/includes/auth/AuthManager.php new file mode 100644 index 00000000..611a8cdc --- /dev/null +++ b/www/wiki/includes/auth/AuthManager.php @@ -0,0 +1,2455 @@ +<?php +/** + * Authentication (and possibly Authorization in the future) system entry point + * + * 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 Config; +use MediaWiki\MediaWikiServices; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Status; +use StatusValue; +use User; +use WebRequest; +use Wikimedia\ObjectFactory; + +/** + * This serves as the entry point to the authentication system. + * + * In the future, it may also serve as the entry point to the authorization + * system. + * + * If you are looking at this because you are working on an extension that creates its own + * login or signup page, then 1) you really shouldn't do that, 2) if you feel you absolutely + * have to, subclass AuthManagerSpecialPage or build it on the client side using the clientlogin + * or the createaccount API. Trying to call this class directly will very likely end up in + * security vulnerabilities or broken UX in edge cases. + * + * If you are working on an extension that needs to integrate with the authentication system + * (e.g. by providing a new login method, or doing extra permission checks), you'll probably + * need to write an AuthenticationProvider. + * + * If you want to create a "reserved" user programmatically, User::newSystemUser() might be what + * you are looking for. If you want to change user data, use User::changeAuthenticationData(). + * Code that is related to some SessionProvider or PrimaryAuthenticationProvider can + * create a (non-reserved) user by calling AuthManager::autoCreateUser(); it is then the provider's + * responsibility to ensure that the user can authenticate somehow (see especially + * PrimaryAuthenticationProvider::autoCreatedAccount()). + * If you are writing code that is not associated with such a provider and needs to create accounts + * programmatically for real users, you should rethink your architecture. There is no good way to + * do that as such code has no knowledge of what authentication methods are enabled on the wiki and + * cannot provide any means for users to access the accounts it would create. + * + * The two main control flows when using this class are as follows: + * * Login, user creation or account linking code will call getAuthenticationRequests(), populate + * the requests with data (by using them to build a HTMLForm and have the user fill it, or by + * exposing a form specification via the API, so that the client can build it), and pass them to + * the appropriate begin* method. That will return either a success/failure response, or more + * requests to fill (either by building a form or by redirecting the user to some external + * provider which will send the data back), in which case they need to be submitted to the + * appropriate continue* method and that step has to be repeated until the response is a success + * or failure response. AuthManager will use the session to maintain internal state during the + * process. + * * Code doing an authentication data change will call getAuthenticationRequests(), select + * a single request, populate it, and pass it to allowsAuthenticationDataChange() and then + * changeAuthenticationData(). If the data change is user-initiated, the whole process needs + * to be preceded by a call to securitySensitiveOperationStatus() and aborted if that returns + * a non-OK status. + * + * @ingroup Auth + * @since 1.27 + * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager + */ +class AuthManager implements LoggerAwareInterface { + /** Log in with an existing (not necessarily local) user */ + const ACTION_LOGIN = 'login'; + /** Continue a login process that was interrupted by the need for user input or communication + * with an external provider */ + const ACTION_LOGIN_CONTINUE = 'login-continue'; + /** Create a new user */ + const ACTION_CREATE = 'create'; + /** Continue a user creation process that was interrupted by the need for user input or + * communication with an external provider */ + const ACTION_CREATE_CONTINUE = 'create-continue'; + /** Link an existing user to a third-party account */ + const ACTION_LINK = 'link'; + /** Continue a user linking process that was interrupted by the need for user input or + * communication with an external provider */ + const ACTION_LINK_CONTINUE = 'link-continue'; + /** Change a user's credentials */ + const ACTION_CHANGE = 'change'; + /** Remove a user's credentials */ + const ACTION_REMOVE = 'remove'; + /** Like ACTION_REMOVE but for linking providers only */ + const ACTION_UNLINK = 'unlink'; + + /** Security-sensitive operations are ok. */ + const SEC_OK = 'ok'; + /** Security-sensitive operations should re-authenticate. */ + const SEC_REAUTH = 'reauth'; + /** Security-sensitive should not be performed. */ + const SEC_FAIL = 'fail'; + + /** Auto-creation is due to SessionManager */ + const AUTOCREATE_SOURCE_SESSION = \MediaWiki\Session\SessionManager::class; + + /** @var AuthManager|null */ + private static $instance = null; + + /** @var WebRequest */ + private $request; + + /** @var Config */ + private $config; + + /** @var LoggerInterface */ + private $logger; + + /** @var AuthenticationProvider[] */ + private $allAuthenticationProviders = []; + + /** @var PreAuthenticationProvider[] */ + private $preAuthenticationProviders = null; + + /** @var PrimaryAuthenticationProvider[] */ + private $primaryAuthenticationProviders = null; + + /** @var SecondaryAuthenticationProvider[] */ + private $secondaryAuthenticationProviders = null; + + /** @var CreatedAccountAuthenticationRequest[] */ + private $createdAccountAuthenticationRequests = []; + + /** + * Get the global AuthManager + * @return AuthManager + */ + public static function singleton() { + if ( self::$instance === null ) { + self::$instance = new self( + \RequestContext::getMain()->getRequest(), + MediaWikiServices::getInstance()->getMainConfig() + ); + } + return self::$instance; + } + + /** + * @param WebRequest $request + * @param Config $config + */ + public function __construct( WebRequest $request, Config $config ) { + $this->request = $request; + $this->config = $config; + $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ) ); + } + + /** + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * @return WebRequest + */ + public function getRequest() { + return $this->request; + } + + /** + * Force certain PrimaryAuthenticationProviders + * @deprecated For backwards compatibility only + * @param PrimaryAuthenticationProvider[] $providers + * @param string $why + */ + public function forcePrimaryAuthenticationProviders( array $providers, $why ) { + $this->logger->warning( "Overriding AuthManager primary authn because $why" ); + + if ( $this->primaryAuthenticationProviders !== null ) { + $this->logger->warning( + 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.' + ); + + $this->allAuthenticationProviders = array_diff_key( + $this->allAuthenticationProviders, + $this->primaryAuthenticationProviders + ); + $session = $this->request->getSession(); + $session->remove( 'AuthManager::authnState' ); + $session->remove( 'AuthManager::accountCreationState' ); + $session->remove( 'AuthManager::accountLinkState' ); + $this->createdAccountAuthenticationRequests = []; + } + + $this->primaryAuthenticationProviders = []; + foreach ( $providers as $provider ) { + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + throw new \RuntimeException( + 'Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got ' . + get_class( $provider ) + ); + } + $provider->setLogger( $this->logger ); + $provider->setManager( $this ); + $provider->setConfig( $this->config ); + $id = $provider->getUniqueId(); + if ( isset( $this->allAuthenticationProviders[$id] ) ) { + throw new \RuntimeException( + "Duplicate specifications for id $id (classes " . + get_class( $provider ) . ' and ' . + get_class( $this->allAuthenticationProviders[$id] ) . ')' + ); + } + $this->allAuthenticationProviders[$id] = $provider; + $this->primaryAuthenticationProviders[$id] = $provider; + } + } + + /** + * Call a legacy AuthPlugin method, if necessary + * @codeCoverageIgnore + * @deprecated For backwards compatibility only, should be avoided in new code + * @param string $method AuthPlugin method to call + * @param array $params Parameters to pass + * @param mixed $return Return value if AuthPlugin wasn't called + * @return mixed Return value from the AuthPlugin method, or $return + */ + public static function callLegacyAuthPlugin( $method, array $params, $return = null ) { + global $wgAuth; + + if ( $wgAuth && !$wgAuth instanceof AuthManagerAuthPlugin ) { + return call_user_func_array( [ $wgAuth, $method ], $params ); + } else { + return $return; + } + } + + /** + * @name Authentication + * @{ + */ + + /** + * Indicate whether user authentication is possible + * + * It may not be if the session is provided by something like OAuth + * for which each individual request includes authentication data. + * + * @return bool + */ + public function canAuthenticateNow() { + return $this->request->getSession()->canSetUser(); + } + + /** + * Start an authentication flow + * + * In addition to the AuthenticationRequests returned by + * $this->getAuthenticationRequests(), a client might include a + * CreateFromLoginAuthenticationRequest from a previous login attempt to + * preserve state. + * + * Instead of the AuthenticationRequests returned by + * $this->getAuthenticationRequests(), a client might pass a + * CreatedAccountAuthenticationRequest from an account creation that just + * succeeded to log in to the just-created account. + * + * @param AuthenticationRequest[] $reqs + * @param string $returnToUrl Url that REDIRECT responses should eventually + * return to. + * @return AuthenticationResponse See self::continueAuthentication() + */ + public function beginAuthentication( array $reqs, $returnToUrl ) { + $session = $this->request->getSession(); + if ( !$session->canSetUser() ) { + // Caller should have called canAuthenticateNow() + $session->remove( 'AuthManager::authnState' ); + throw new \LogicException( 'Authentication is not possible now' ); + } + + $guessUserName = null; + foreach ( $reqs as $req ) { + $req->returnToUrl = $returnToUrl; + // @codeCoverageIgnoreStart + if ( $req->username !== null && $req->username !== '' ) { + if ( $guessUserName === null ) { + $guessUserName = $req->username; + } elseif ( $guessUserName !== $req->username ) { + $guessUserName = null; + break; + } + } + // @codeCoverageIgnoreEnd + } + + // Check for special-case login of a just-created account + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreatedAccountAuthenticationRequest::class + ); + if ( $req ) { + if ( !in_array( $req, $this->createdAccountAuthenticationRequests, true ) ) { + throw new \LogicException( + 'CreatedAccountAuthenticationRequests are only valid on ' . + 'the same AuthManager that created the account' + ); + } + + $user = User::newFromName( $req->username ); + // @codeCoverageIgnoreStart + if ( !$user ) { + throw new \UnexpectedValueException( + "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\"" + ); + } elseif ( $user->getId() != $req->id ) { + throw new \UnexpectedValueException( + "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}" + ); + } + // @codeCoverageIgnoreEnd + + $this->logger->info( 'Logging in {user} after account creation', [ + 'user' => $user->getName(), + ] ); + $ret = AuthenticationResponse::newPass( $user->getName() ); + $this->setSessionDataForUser( $user ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] ); + return $ret; + } + + $this->removeAuthenticationSessionData( null ); + + foreach ( $this->getPreAuthenticationProviders() as $provider ) { + $status = $provider->testForAuthentication( $reqs ); + if ( !$status->isGood() ) { + $this->logger->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() ); + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage() + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, null, $guessUserName ] ); + return $ret; + } + } + + $state = [ + 'reqs' => $reqs, + 'returnToUrl' => $returnToUrl, + 'guessUserName' => $guessUserName, + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'maybeLink' => [], + 'continueRequests' => [], + ]; + + // Preserve state from a previous failed login + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreateFromLoginAuthenticationRequest::class + ); + if ( $req ) { + $state['maybeLink'] = $req->maybeLink; + } + + $session = $this->request->getSession(); + $session->setSecret( 'AuthManager::authnState', $state ); + $session->persist(); + + return $this->continueAuthentication( $reqs ); + } + + /** + * Continue an authentication flow + * + * Return values are interpreted as follows: + * - status FAIL: Authentication failed. If $response->createRequest is + * set, that may be passed to self::beginAuthentication() or to + * self::beginAccountCreation() to preserve state. + * - status REDIRECT: The client should be redirected to the contained URL, + * new AuthenticationRequests should be made (if any), then + * AuthManager::continueAuthentication() should be called. + * - status UI: The client should be presented with a user interface for + * the fields in the specified AuthenticationRequests, then new + * AuthenticationRequests should be made, then + * AuthManager::continueAuthentication() should be called. + * - status RESTART: The user logged in successfully with a third-party + * service, but the third-party credentials aren't attached to any local + * account. This could be treated as a UI or a FAIL. + * - status PASS: Authentication was successful. + * + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + public function continueAuthentication( array $reqs ) { + $session = $this->request->getSession(); + try { + if ( !$session->canSetUser() ) { + // Caller should have called canAuthenticateNow() + // @codeCoverageIgnoreStart + throw new \LogicException( 'Authentication is not possible now' ); + // @codeCoverageIgnoreEnd + } + + $state = $session->getSecret( 'AuthManager::authnState' ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-not-in-progress' ) + ); + } + $state['continueRequests'] = []; + + $guessUserName = $state['guessUserName']; + + foreach ( $reqs as $req ) { + $req->returnToUrl = $state['returnToUrl']; + } + + // Step 1: Choose an primary authentication provider, and call it until it succeeds. + + if ( $state['primary'] === null ) { + // We haven't picked a PrimaryAuthenticationProvider yet + // @codeCoverageIgnoreStart + $guessUserName = null; + foreach ( $reqs as $req ) { + if ( $req->username !== null && $req->username !== '' ) { + if ( $guessUserName === null ) { + $guessUserName = $req->username; + } elseif ( $guessUserName !== $req->username ) { + $guessUserName = null; + break; + } + } + } + $state['guessUserName'] = $guessUserName; + // @codeCoverageIgnoreEnd + $state['reqs'] = $reqs; + + foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) { + $res = $provider->beginPrimaryAuthentication( $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $state['primary'] = $id; + $state['primaryResponse'] = $res; + $this->logger->debug( "Primary login with $id succeeded" ); + break 2; + case AuthenticationResponse::FAIL; + $this->logger->debug( "Login failed in primary authentication by $id" ); + if ( $res->createRequest || $state['maybeLink'] ) { + $res->createRequest = new CreateFromLoginAuthenticationRequest( + $res->createRequest, $state['maybeLink'] + ); + } + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $res ] + ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName ] ); + return $res; + case AuthenticationResponse::ABSTAIN; + // Continue loop + break; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( "Primary login with $id returned $res->status" ); + $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName ); + $state['primary'] = $id; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::authnState', $state ); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + if ( $state['primary'] === null ) { + $this->logger->debug( 'Login failed in primary authentication because no provider accepted' ); + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-no-primary' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + $session->remove( 'AuthManager::authnState' ); + return $ret; + } + } elseif ( $state['primaryResponse'] === null ) { + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-not-in-progress' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + $session->remove( 'AuthManager::authnState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $id = $provider->getUniqueId(); + $res = $provider->continuePrimaryAuthentication( $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $state['primaryResponse'] = $res; + $this->logger->debug( "Primary login with $id succeeded" ); + break; + case AuthenticationResponse::FAIL; + $this->logger->debug( "Login failed in primary authentication by $id" ); + if ( $res->createRequest || $state['maybeLink'] ) { + $res->createRequest = new CreateFromLoginAuthenticationRequest( + $res->createRequest, $state['maybeLink'] + ); + } + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $res ] + ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName ] ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( "Primary login with $id returned $res->status" ); + $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName ); + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::authnState', $state ); + return $res; + default: + throw new \DomainException( + get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status" + ); + } + } + + $res = $state['primaryResponse']; + if ( $res->username === null ) { + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-not-in-progress' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + $session->remove( 'AuthManager::authnState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + + if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK && + $res->linkRequest && + // don't confuse the user with an incorrect message if linking is disabled + $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider::class ) + ) { + $state['maybeLink'][$res->linkRequest->getUniqueId()] = $res->linkRequest; + $msg = 'authmanager-authn-no-local-user-link'; + } else { + $msg = 'authmanager-authn-no-local-user'; + } + $this->logger->debug( + "Primary login with {$provider->getUniqueId()} succeeded, but returned no user" + ); + $ret = AuthenticationResponse::newRestart( wfMessage( $msg ) ); + $ret->neededRequests = $this->getAuthenticationRequestsInternal( + self::ACTION_LOGIN, + [], + $this->getPrimaryAuthenticationProviders() + $this->getSecondaryAuthenticationProviders() + ); + if ( $res->createRequest || $state['maybeLink'] ) { + $ret->createRequest = new CreateFromLoginAuthenticationRequest( + $res->createRequest, $state['maybeLink'] + ); + $ret->neededRequests[] = $ret->createRequest; + } + $this->fillRequests( $ret->neededRequests, self::ACTION_LOGIN, null, true ); + $session->setSecret( 'AuthManager::authnState', [ + 'reqs' => [], // Will be filled in later + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'continueRequests' => $ret->neededRequests, + ] + $state ); + return $ret; + } + + // Step 2: Primary authentication succeeded, create the User object + // (and add the user locally if necessary) + + $user = User::newFromName( $res->username, 'usable' ); + if ( !$user ) { + $provider = $this->getAuthenticationProvider( $state['primary'] ); + throw new \DomainException( + get_class( $provider ) . " returned an invalid username: {$res->username}" + ); + } + if ( $user->getId() === 0 ) { + // User doesn't exist locally. Create it. + $this->logger->info( 'Auto-creating {user} on login', [ + 'user' => $user->getName(), + ] ); + $status = $this->autoCreateUser( $user, $state['primary'], false ); + if ( !$status->isGood() ) { + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] ); + return $ret; + } + } + + // Step 3: Iterate over all the secondary authentication providers. + + $beginReqs = $state['reqs']; + + foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) { + if ( !isset( $state['secondary'][$id] ) ) { + // This provider isn't started yet, so we pass it the set + // of reqs from beginAuthentication instead of whatever + // might have been used by a previous provider in line. + $func = 'beginSecondaryAuthentication'; + $res = $provider->beginSecondaryAuthentication( $user, $beginReqs ); + } elseif ( !$state['secondary'][$id] ) { + $func = 'continueSecondaryAuthentication'; + $res = $provider->continueSecondaryAuthentication( $user, $reqs ); + } else { + continue; + } + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( "Secondary login with $id succeeded" ); + // fall through + case AuthenticationResponse::ABSTAIN; + $state['secondary'][$id] = true; + break; + case AuthenticationResponse::FAIL; + $this->logger->debug( "Login failed in secondary authentication by $id" ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $res ] ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, $user, $user->getName() ] ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( "Secondary login with $id returned " . $res->status ); + $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $user->getName() ); + $state['secondary'][$id] = false; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::authnState', $state ); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::{$func}() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + + // Step 4: Authentication complete! Set the user in the session and + // clean up. + + $this->logger->info( 'Login for {user} succeeded from {clientip}', [ + 'user' => $user->getName(), + 'clientip' => $this->request->getIP(), + ] ); + /** @var RememberMeAuthenticationRequest $req */ + $req = AuthenticationRequest::getRequestByClass( + $beginReqs, RememberMeAuthenticationRequest::class + ); + $this->setSessionDataForUser( $user, $req && $req->rememberMe ); + $ret = AuthenticationResponse::newPass( $user->getName() ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] ); + $session->remove( 'AuthManager::authnState' ); + $this->removeAuthenticationSessionData( null ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] ); + return $ret; + } catch ( \Exception $ex ) { + $session->remove( 'AuthManager::authnState' ); + throw $ex; + } + } + + /** + * Whether security-sensitive operations should proceed. + * + * A "security-sensitive operation" is something like a password or email + * change, that would normally have a "reenter your password to confirm" + * box if we only supported password-based authentication. + * + * @param string $operation Operation being checked. This should be a + * message-key-like string such as 'change-password' or 'change-email'. + * @return string One of the SEC_* constants. + */ + public function securitySensitiveOperationStatus( $operation ) { + $status = self::SEC_OK; + + $this->logger->debug( __METHOD__ . ": Checking $operation" ); + + $session = $this->request->getSession(); + $aId = $session->getUser()->getId(); + if ( $aId === 0 ) { + // User isn't authenticated. DWIM? + $status = $this->canAuthenticateNow() ? self::SEC_REAUTH : self::SEC_FAIL; + $this->logger->info( __METHOD__ . ": Not logged in! $operation is $status" ); + return $status; + } + + if ( $session->canSetUser() ) { + $id = $session->get( 'AuthManager:lastAuthId' ); + $last = $session->get( 'AuthManager:lastAuthTimestamp' ); + if ( $id !== $aId || $last === null ) { + $timeSinceLogin = PHP_INT_MAX; // Forever ago + } else { + $timeSinceLogin = max( 0, time() - $last ); + } + + $thresholds = $this->config->get( 'ReauthenticateTime' ); + if ( isset( $thresholds[$operation] ) ) { + $threshold = $thresholds[$operation]; + } elseif ( isset( $thresholds['default'] ) ) { + $threshold = $thresholds['default']; + } else { + throw new \UnexpectedValueException( '$wgReauthenticateTime lacks a default' ); + } + + if ( $threshold >= 0 && $timeSinceLogin > $threshold ) { + $status = self::SEC_REAUTH; + } + } else { + $timeSinceLogin = -1; + + $pass = $this->config->get( 'AllowSecuritySensitiveOperationIfCannotReauthenticate' ); + if ( isset( $pass[$operation] ) ) { + $status = $pass[$operation] ? self::SEC_OK : self::SEC_FAIL; + } elseif ( isset( $pass['default'] ) ) { + $status = $pass['default'] ? self::SEC_OK : self::SEC_FAIL; + } else { + throw new \UnexpectedValueException( + '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default' + ); + } + } + + \Hooks::run( 'SecuritySensitiveOperationStatus', [ + &$status, $operation, $session, $timeSinceLogin + ] ); + + // If authentication is not possible, downgrade from "REAUTH" to "FAIL". + if ( !$this->canAuthenticateNow() && $status === self::SEC_REAUTH ) { + $status = self::SEC_FAIL; + } + + $this->logger->info( __METHOD__ . ": $operation is $status" ); + + return $status; + } + + /** + * Determine whether a username can authenticate + * + * This is mainly for internal purposes and only takes authentication data into account, + * not things like blocks that can change without the authentication system being aware. + * + * @param string $username MediaWiki username + * @return bool + */ + public function userCanAuthenticate( $username ) { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + if ( $provider->testUserCanAuthenticate( $username ) ) { + return true; + } + } + return false; + } + + /** + * Provide normalized versions of the username for security checks + * + * Since different providers can normalize the input in different ways, + * this returns an array of all the different ways the name might be + * normalized for authentication. + * + * The returned strings should not be revealed to the user, as that might + * leak private information (e.g. an email address might be normalized to a + * username). + * + * @param string $username + * @return string[] + */ + public function normalizeUsername( $username ) { + $ret = []; + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + $normalized = $provider->providerNormalizeUsername( $username ); + if ( $normalized !== null ) { + $ret[$normalized] = true; + } + } + return array_keys( $ret ); + } + + /**@}*/ + + /** + * @name Authentication data changing + * @{ + */ + + /** + * Revoke any authentication credentials for a user + * + * After this, the user should no longer be able to log in. + * + * @param string $username + */ + public function revokeAccessForUser( $username ) { + $this->logger->info( 'Revoking access for {user}', [ + 'user' => $username, + ] ); + $this->callMethodOnProviders( 6, 'providerRevokeAccessForUser', [ $username ] ); + } + + /** + * Validate a change of authentication data (e.g. passwords) + * @param AuthenticationRequest $req + * @param bool $checkData If false, $req hasn't been loaded from the + * submission so checks on user-submitted fields should be skipped. $req->username is + * considered user-submitted for this purpose, even if it cannot be changed via + * $req->loadFromSubmission. + * @return Status + */ + public function allowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) { + $any = false; + $providers = $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData ); + if ( !$status->isGood() ) { + return Status::wrap( $status ); + } + $any = $any || $status->value !== 'ignored'; + } + if ( !$any ) { + $status = Status::newGood( 'ignored' ); + $status->warning( 'authmanager-change-not-supported' ); + return $status; + } + return Status::newGood(); + } + + /** + * Change authentication data (e.g. passwords) + * + * If $req was returned for AuthManager::ACTION_CHANGE, using $req should + * result in a successful login in the future. + * + * If $req was returned for AuthManager::ACTION_REMOVE, using $req should + * no longer result in a successful login. + * + * This method should only be called if allowsAuthenticationDataChange( $req, true ) + * returned success. + * + * @param AuthenticationRequest $req + */ + public function changeAuthenticationData( AuthenticationRequest $req ) { + $this->logger->info( 'Changing authentication data for {user} class {what}', [ + 'user' => is_string( $req->username ) ? $req->username : '<no name>', + 'what' => get_class( $req ), + ] ); + + $this->callMethodOnProviders( 6, 'providerChangeAuthenticationData', [ $req ] ); + + // When the main account's authentication data is changed, invalidate + // all BotPasswords too. + \BotPassword::invalidateAllPasswordsForUser( $req->username ); + } + + /**@}*/ + + /** + * @name Account creation + * @{ + */ + + /** + * Determine whether accounts can be created + * @return bool + */ + public function canCreateAccounts() { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + switch ( $provider->accountCreationType() ) { + case PrimaryAuthenticationProvider::TYPE_CREATE: + case PrimaryAuthenticationProvider::TYPE_LINK: + return true; + } + } + return false; + } + + /** + * Determine whether a particular account can be created + * @param string $username MediaWiki username + * @param array $options + * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL + * - creating: (bool) For internal use only. Never specify this. + * @return Status + */ + public function canCreateAccount( $username, $options = [] ) { + // Back compat + if ( is_int( $options ) ) { + $options = [ 'flags' => $options ]; + } + $options += [ + 'flags' => User::READ_NORMAL, + 'creating' => false, + ]; + $flags = $options['flags']; + + if ( !$this->canCreateAccounts() ) { + return Status::newFatal( 'authmanager-create-disabled' ); + } + + if ( $this->userExists( $username, $flags ) ) { + return Status::newFatal( 'userexists' ); + } + + $user = User::newFromName( $username, 'creatable' ); + if ( !is_object( $user ) ) { + return Status::newFatal( 'noname' ); + } else { + $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL + if ( $user->getId() !== 0 ) { + return Status::newFatal( 'userexists' ); + } + } + + // Denied by providers? + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + $status = $provider->testUserForCreation( $user, false, $options ); + if ( !$status->isGood() ) { + return Status::wrap( $status ); + } + } + + return Status::newGood(); + } + + /** + * Basic permissions checks on whether a user can create accounts + * @param User $creator User doing the account creation + * @return Status + */ + public function checkAccountCreatePermissions( User $creator ) { + // Wiki is read-only? + if ( wfReadOnly() ) { + return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) ); + } + + // This is awful, this permission check really shouldn't go through Title. + $permErrors = \SpecialPage::getTitleFor( 'CreateAccount' ) + ->getUserPermissionsErrors( 'createaccount', $creator, 'secure' ); + if ( $permErrors ) { + $status = Status::newGood(); + foreach ( $permErrors as $args ) { + call_user_func_array( [ $status, 'fatal' ], $args ); + } + return $status; + } + + $block = $creator->isBlockedFromCreateAccount(); + if ( $block ) { + $errorParams = [ + $block->getTarget(), + $block->mReason ?: wfMessage( 'blockednoreason' )->text(), + $block->getByName() + ]; + + if ( $block->getType() === \Block::TYPE_RANGE ) { + $errorMessage = 'cantcreateaccount-range-text'; + $errorParams[] = $this->getRequest()->getIP(); + } else { + $errorMessage = 'cantcreateaccount-text'; + } + + return Status::newFatal( wfMessage( $errorMessage, $errorParams ) ); + } + + $ip = $this->getRequest()->getIP(); + if ( $creator->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) { + return Status::newFatal( 'sorbs_create_account_reason' ); + } + + return Status::newGood(); + } + + /** + * Start an account creation flow + * + * In addition to the AuthenticationRequests returned by + * $this->getAuthenticationRequests(), a client might include a + * CreateFromLoginAuthenticationRequest from a previous login attempt. If + * <code> + * $createFromLoginAuthenticationRequest->hasPrimaryStateForAction( AuthManager::ACTION_CREATE ) + * </code> + * returns true, any AuthenticationRequest::PRIMARY_REQUIRED requests + * should be omitted. If the CreateFromLoginAuthenticationRequest has a + * username set, that username must be used for all other requests. + * + * @param User $creator User doing the account creation + * @param AuthenticationRequest[] $reqs + * @param string $returnToUrl Url that REDIRECT responses should eventually + * return to. + * @return AuthenticationResponse + */ + public function beginAccountCreation( User $creator, array $reqs, $returnToUrl ) { + $session = $this->request->getSession(); + if ( !$this->canCreateAccounts() ) { + // Caller should have called canCreateAccounts() + $session->remove( 'AuthManager::accountCreationState' ); + throw new \LogicException( 'Account creation is not possible' ); + } + + try { + $username = AuthenticationRequest::getUsernameFromRequests( $reqs ); + } catch ( \UnexpectedValueException $ex ) { + $username = null; + } + if ( $username === null ) { + $this->logger->debug( __METHOD__ . ': No username provided' ); + return AuthenticationResponse::newFail( wfMessage( 'noname' ) ); + } + + // Permissions check + $status = $this->checkAccountCreatePermissions( $creator ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [ + 'user' => $username, + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ) + ] ); + return AuthenticationResponse::newFail( $status->getMessage() ); + } + + $status = $this->canCreateAccount( + $username, [ 'flags' => User::READ_LOCKING, 'creating' => true ] + ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ': {user} cannot be created: {reason}', [ + 'user' => $username, + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ) + ] ); + return AuthenticationResponse::newFail( $status->getMessage() ); + } + + $user = User::newFromName( $username, 'creatable' ); + foreach ( $reqs as $req ) { + $req->username = $username; + $req->returnToUrl = $returnToUrl; + if ( $req instanceof UserDataAuthenticationRequest ) { + $status = $req->populateUser( $user ); + if ( !$status->isGood() ) { + $status = Status::wrap( $status ); + $session->remove( 'AuthManager::accountCreationState' ); + $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ), + ] ); + return AuthenticationResponse::newFail( $status->getMessage() ); + } + } + } + + $this->removeAuthenticationSessionData( null ); + + $state = [ + 'username' => $username, + 'userid' => 0, + 'creatorid' => $creator->getId(), + 'creatorname' => $creator->getName(), + 'reqs' => $reqs, + 'returnToUrl' => $returnToUrl, + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'continueRequests' => [], + 'maybeLink' => [], + 'ranPreTests' => false, + ]; + + // Special case: converting a login to an account creation + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreateFromLoginAuthenticationRequest::class + ); + if ( $req ) { + $state['maybeLink'] = $req->maybeLink; + + if ( $req->createRequest ) { + $reqs[] = $req->createRequest; + $state['reqs'][] = $req->createRequest; + } + } + + $session->setSecret( 'AuthManager::accountCreationState', $state ); + $session->persist(); + + return $this->continueAccountCreation( $reqs ); + } + + /** + * Continue an account creation flow + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + public function continueAccountCreation( array $reqs ) { + $session = $this->request->getSession(); + try { + if ( !$this->canCreateAccounts() ) { + // Caller should have called canCreateAccounts() + $session->remove( 'AuthManager::accountCreationState' ); + throw new \LogicException( 'Account creation is not possible' ); + } + + $state = $session->getSecret( 'AuthManager::accountCreationState' ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newFail( + wfMessage( 'authmanager-create-not-in-progress' ) + ); + } + $state['continueRequests'] = []; + + // Step 0: Prepare and validate the input + + $user = User::newFromName( $state['username'], 'creatable' ); + if ( !is_object( $user ) ) { + $session->remove( 'AuthManager::accountCreationState' ); + $this->logger->debug( __METHOD__ . ': Invalid username', [ + 'user' => $state['username'], + ] ); + return AuthenticationResponse::newFail( wfMessage( 'noname' ) ); + } + + if ( $state['creatorid'] ) { + $creator = User::newFromId( $state['creatorid'] ); + } else { + $creator = new User; + $creator->setName( $state['creatorname'] ); + } + + // Avoid account creation races on double submissions + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) ); + if ( !$lock ) { + // Don't clear AuthManager::accountCreationState for this code + // path because the process that won the race owns it. + $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + return AuthenticationResponse::newFail( wfMessage( 'usernameinprogress' ) ); + } + + // Permissions check + $status = $this->checkAccountCreatePermissions( $creator ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ) + ] ); + $ret = AuthenticationResponse::newFail( $status->getMessage() ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + + // Load from master for existence check + $user->load( User::READ_LOCKING ); + + if ( $state['userid'] === 0 ) { + if ( $user->getId() != 0 ) { + $this->logger->debug( __METHOD__ . ': User exists locally', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $ret = AuthenticationResponse::newFail( wfMessage( 'userexists' ) ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } else { + if ( $user->getId() == 0 ) { + $this->logger->debug( __METHOD__ . ': User does not exist locally when it should', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'expected_id' => $state['userid'], + ] ); + throw new \UnexpectedValueException( + "User \"{$state['username']}\" should exist now, but doesn't!" + ); + } + if ( $user->getId() != $state['userid'] ) { + $this->logger->debug( __METHOD__ . ': User ID/name mismatch', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'expected_id' => $state['userid'], + 'actual_id' => $user->getId(), + ] ); + throw new \UnexpectedValueException( + "User \"{$state['username']}\" exists, but " . + "ID {$user->getId()} != {$state['userid']}!" + ); + } + } + foreach ( $state['reqs'] as $req ) { + if ( $req instanceof UserDataAuthenticationRequest ) { + $status = $req->populateUser( $user ); + if ( !$status->isGood() ) { + // This should never happen... + $status = Status::wrap( $status ); + $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ), + ] ); + $ret = AuthenticationResponse::newFail( $status->getMessage() ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } + } + + foreach ( $reqs as $req ) { + $req->returnToUrl = $state['returnToUrl']; + $req->username = $state['username']; + } + + // Run pre-creation tests, if we haven't already + if ( !$state['ranPreTests'] ) { + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $id => $provider ) { + $status = $provider->testForAccountCreation( $user, $creator, $reqs ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ": Fail in pre-authentication by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage() + ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } + + $state['ranPreTests'] = true; + } + + // Step 1: Choose a primary authentication provider and call it until it succeeds. + + if ( $state['primary'] === null ) { + // We haven't picked a PrimaryAuthenticationProvider yet + foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) { + if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_NONE ) { + continue; + } + $res = $provider->beginPrimaryAccountCreation( $user, $creator, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $state['primary'] = $id; + $state['primaryResponse'] = $res; + break 2; + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $res; + case AuthenticationResponse::ABSTAIN; + // Continue loop + break; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null ); + $state['primary'] = $id; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::beginPrimaryAccountCreation() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + if ( $state['primary'] === null ) { + $this->logger->debug( __METHOD__ . ': Primary creation failed because no provider accepted', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-create-no-primary' ) + ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } elseif ( $state['primaryResponse'] === null ) { + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-create-not-in-progress' ) + ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $id = $provider->getUniqueId(); + $res = $provider->continuePrimaryAccountCreation( $user, $creator, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $state['primaryResponse'] = $res; + break; + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null ); + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + return $res; + default: + throw new \DomainException( + get_class( $provider ) . "::continuePrimaryAccountCreation() returned $res->status" + ); + } + } + + // Step 2: Primary authentication succeeded, create the User object + // and add the user locally. + + if ( $state['userid'] === 0 ) { + $this->logger->info( 'Creating user {user} during account creation', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $status = $user->addToDatabase(); + if ( !$status->isOK() ) { + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( $status->getMessage() ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $this->setDefaultUserOptions( $user, $creator->isAnon() ); + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + $user->saveSettings(); + $state['userid'] = $user->getId(); + + // Update user count + \DeferredUpdates::addUpdate( \SiteStatsUpdate::factory( [ 'users' => 1 ] ) ); + + // Watch user's userpage and talk page + $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS ); + + // Inform the provider + $logSubtype = $provider->finishAccountCreation( $user, $creator, $state['primaryResponse'] ); + + // Log the creation + if ( $this->config->get( 'NewUserLog' ) ) { + $isAnon = $creator->isAnon(); + $logEntry = new \ManualLogEntry( + 'newusers', + $logSubtype ?: ( $isAnon ? 'create' : 'create2' ) + ); + $logEntry->setPerformer( $isAnon ? $user : $creator ); + $logEntry->setTarget( $user->getUserPage() ); + /** @var CreationReasonAuthenticationRequest $req */ + $req = AuthenticationRequest::getRequestByClass( + $state['reqs'], CreationReasonAuthenticationRequest::class + ); + $logEntry->setComment( $req ? $req->reason : '' ); + $logEntry->setParameters( [ + '4::userid' => $user->getId(), + ] ); + $logid = $logEntry->insert(); + $logEntry->publish( $logid ); + } + } + + // Step 3: Iterate over all the secondary authentication providers. + + $beginReqs = $state['reqs']; + + foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) { + if ( !isset( $state['secondary'][$id] ) ) { + // This provider isn't started yet, so we pass it the set + // of reqs from beginAuthentication instead of whatever + // might have been used by a previous provider in line. + $func = 'beginSecondaryAccountCreation'; + $res = $provider->beginSecondaryAccountCreation( $user, $creator, $beginReqs ); + } elseif ( !$state['secondary'][$id] ) { + $func = 'continueSecondaryAccountCreation'; + $res = $provider->continueSecondaryAccountCreation( $user, $creator, $reqs ); + } else { + continue; + } + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( __METHOD__ . ": Secondary creation passed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + // fall through + case AuthenticationResponse::ABSTAIN; + $state['secondary'][$id] = true; + break; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Secondary creation $res->status by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null ); + $state['secondary'][$id] = false; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + return $res; + case AuthenticationResponse::FAIL; + throw new \DomainException( + get_class( $provider ) . "::{$func}() returned $res->status." . + ' Secondary providers are not allowed to fail account creation, that' . + ' should have been done via testForAccountCreation().' + ); + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::{$func}() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + + $id = $user->getId(); + $name = $user->getName(); + $req = new CreatedAccountAuthenticationRequest( $id, $name ); + $ret = AuthenticationResponse::newPass( $name ); + $ret->loginRequest = $req; + $this->createdAccountAuthenticationRequests[] = $req; + + $this->logger->info( __METHOD__ . ': Account creation succeeded for {user}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + $this->removeAuthenticationSessionData( null ); + return $ret; + } catch ( \Exception $ex ) { + $session->remove( 'AuthManager::accountCreationState' ); + throw $ex; + } + } + + /** + * Auto-create an account, and log into that account + * + * PrimaryAuthenticationProviders can invoke this method by returning a PASS from + * beginPrimaryAuthentication/continuePrimaryAuthentication with the username of a + * non-existing user. SessionProviders can invoke it by returning a SessionInfo with + * the username of a non-existing user from provideSessionInfo(). Calling this method + * explicitly (e.g. from a maintenance script) is also fine. + * + * @param User $user User to auto-create + * @param string $source What caused the auto-creation? This must be the ID + * of a PrimaryAuthenticationProvider or the constant self::AUTOCREATE_SOURCE_SESSION. + * @param bool $login Whether to also log the user in + * @return Status Good if user was created, Ok if user already existed, otherwise Fatal + */ + public function autoCreateUser( User $user, $source, $login = true ) { + if ( $source !== self::AUTOCREATE_SOURCE_SESSION && + !$this->getAuthenticationProvider( $source ) instanceof PrimaryAuthenticationProvider + ) { + throw new \InvalidArgumentException( "Unknown auto-creation source: $source" ); + } + + $username = $user->getName(); + + // Try the local user from the replica DB + $localId = User::idFromName( $username ); + $flags = User::READ_NORMAL; + + // Fetch the user ID from the master, so that we don't try to create the user + // when they already exist, due to replication lag + // @codeCoverageIgnoreStart + if ( + !$localId && + MediaWikiServices::getInstance()->getDBLoadBalancer()->getReaderIndex() != 0 + ) { + $localId = User::idFromName( $username, User::READ_LATEST ); + $flags = User::READ_LATEST; + } + // @codeCoverageIgnoreEnd + + if ( $localId ) { + $this->logger->debug( __METHOD__ . ': {username} already exists locally', [ + 'username' => $username, + ] ); + $user->setId( $localId ); + $user->loadFromId( $flags ); + if ( $login ) { + $this->setSessionDataForUser( $user ); + } + $status = Status::newGood(); + $status->warning( 'userexists' ); + return $status; + } + + // Wiki is read-only? + if ( wfReadOnly() ) { + $this->logger->debug( __METHOD__ . ': denied by wfReadOnly(): {reason}', [ + 'username' => $username, + 'reason' => wfReadOnlyReason(), + ] ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) ); + } + + // Check the session, if we tried to create this user already there's + // no point in retrying. + $session = $this->request->getSession(); + if ( $session->get( 'AuthManager::AutoCreateBlacklist' ) ) { + $this->logger->debug( __METHOD__ . ': blacklisted in session {sessionid}', [ + 'username' => $username, + 'sessionid' => $session->getId(), + ] ); + $user->setId( 0 ); + $user->loadFromId(); + $reason = $session->get( 'AuthManager::AutoCreateBlacklist' ); + if ( $reason instanceof StatusValue ) { + return Status::wrap( $reason ); + } else { + return Status::newFatal( $reason ); + } + } + + // Is the username creatable? + if ( !User::isCreatableName( $username ) ) { + $this->logger->debug( __METHOD__ . ': name "{username}" is not creatable', [ + 'username' => $username, + ] ); + $session->set( 'AuthManager::AutoCreateBlacklist', 'noname' ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'noname' ); + } + + // Is the IP user able to create accounts? + $anon = new User; + if ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' ) ) { + $this->logger->debug( __METHOD__ . ': IP lacks the ability to create or autocreate accounts', [ + 'username' => $username, + 'ip' => $anon->getName(), + ] ); + $session->set( 'AuthManager::AutoCreateBlacklist', 'authmanager-autocreate-noperm' ); + $session->persist(); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'authmanager-autocreate-noperm' ); + } + + // Avoid account creation races on double submissions + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + if ( !$lock ) { + $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [ + 'user' => $username, + ] ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'usernameinprogress' ); + } + + // Denied by providers? + $options = [ + 'flags' => User::READ_LATEST, + 'creating' => true, + ]; + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + $status = $provider->testUserForCreation( $user, $source, $options ); + if ( !$status->isGood() ) { + $ret = Status::wrap( $status ); + $this->logger->debug( __METHOD__ . ': Provider denied creation of {username}: {reason}', [ + 'username' => $username, + 'reason' => $ret->getWikiText( null, null, 'en' ), + ] ); + $session->set( 'AuthManager::AutoCreateBlacklist', $status ); + $user->setId( 0 ); + $user->loadFromId(); + return $ret; + } + } + + $backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + if ( $cache->get( $backoffKey ) ) { + $this->logger->debug( __METHOD__ . ': {username} denied by prior creation attempt failures', [ + 'username' => $username, + ] ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'authmanager-autocreate-exception' ); + } + + // Checks passed, create the user... + $from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI'; + $this->logger->info( __METHOD__ . ': creating new user ({username}) - from: {from}', [ + 'username' => $username, + 'from' => $from, + ] ); + + // Ignore warnings about master connections/writes...hard to avoid here + $trxProfiler = \Profiler::instance()->getTransactionProfiler(); + $old = $trxProfiler->setSilenced( true ); + try { + $status = $user->addToDatabase(); + if ( !$status->isOK() ) { + // Double-check for a race condition (T70012). We make use of the fact that when + // addToDatabase fails due to the user already existing, the user object gets loaded. + if ( $user->getId() ) { + $this->logger->info( __METHOD__ . ': {username} already exists locally (race)', [ + 'username' => $username, + ] ); + if ( $login ) { + $this->setSessionDataForUser( $user ); + } + $status = Status::newGood(); + $status->warning( 'userexists' ); + } else { + $this->logger->error( __METHOD__ . ': {username} failed with message {msg}', [ + 'username' => $username, + 'msg' => $status->getWikiText( null, null, 'en' ) + ] ); + $user->setId( 0 ); + $user->loadFromId(); + } + return $status; + } + } catch ( \Exception $ex ) { + $trxProfiler->setSilenced( $old ); + $this->logger->error( __METHOD__ . ': {username} failed with exception {exception}', [ + 'username' => $username, + 'exception' => $ex, + ] ); + // Do not keep throwing errors for a while + $cache->set( $backoffKey, 1, 600 ); + // Bubble up error; which should normally trigger DB rollbacks + throw $ex; + } + + $this->setDefaultUserOptions( $user, false ); + + // Inform the providers + $this->callMethodOnProviders( 6, 'autoCreatedAccount', [ $user, $source ] ); + + \Hooks::run( 'AuthPluginAutoCreate', [ $user ], '1.27' ); + \Hooks::run( 'LocalUserCreated', [ $user, true ] ); + $user->saveSettings(); + + // Update user count + \DeferredUpdates::addUpdate( \SiteStatsUpdate::factory( [ 'users' => 1 ] ) ); + // Watch user's userpage and talk page + \DeferredUpdates::addCallableUpdate( function () use ( $user ) { + $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS ); + } ); + + // Log the creation + if ( $this->config->get( 'NewUserLog' ) ) { + $logEntry = new \ManualLogEntry( 'newusers', 'autocreate' ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $user->getUserPage() ); + $logEntry->setComment( '' ); + $logEntry->setParameters( [ + '4::userid' => $user->getId(), + ] ); + $logEntry->insert(); + } + + $trxProfiler->setSilenced( $old ); + + if ( $login ) { + $this->setSessionDataForUser( $user ); + } + + return Status::newGood(); + } + + /**@}*/ + + /** + * @name Account linking + * @{ + */ + + /** + * Determine whether accounts can be linked + * @return bool + */ + public function canLinkAccounts() { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) { + return true; + } + } + return false; + } + + /** + * Start an account linking flow + * + * @param User $user User being linked + * @param AuthenticationRequest[] $reqs + * @param string $returnToUrl Url that REDIRECT responses should eventually + * return to. + * @return AuthenticationResponse + */ + public function beginAccountLink( User $user, array $reqs, $returnToUrl ) { + $session = $this->request->getSession(); + $session->remove( 'AuthManager::accountLinkState' ); + + if ( !$this->canLinkAccounts() ) { + // Caller should have called canLinkAccounts() + throw new \LogicException( 'Account linking is not possible' ); + } + + if ( $user->getId() === 0 ) { + if ( !User::isUsableName( $user->getName() ) ) { + $msg = wfMessage( 'noname' ); + } else { + $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() ); + } + return AuthenticationResponse::newFail( $msg ); + } + foreach ( $reqs as $req ) { + $req->username = $user->getName(); + $req->returnToUrl = $returnToUrl; + } + + $this->removeAuthenticationSessionData( null ); + + $providers = $this->getPreAuthenticationProviders(); + foreach ( $providers as $id => $provider ) { + $status = $provider->testForAccountLink( $user ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ": Account linking pre-check failed by $id", [ + 'user' => $user->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage() + ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] ); + return $ret; + } + } + + $state = [ + 'username' => $user->getName(), + 'userid' => $user->getId(), + 'returnToUrl' => $returnToUrl, + 'primary' => null, + 'continueRequests' => [], + ]; + + $providers = $this->getPrimaryAuthenticationProviders(); + foreach ( $providers as $id => $provider ) { + if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider::TYPE_LINK ) { + continue; + } + + $res = $provider->beginPrimaryAccountLink( $user, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->info( "Account linked to {user} by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + return $res; + + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + return $res; + + case AuthenticationResponse::ABSTAIN; + // Continue loop + break; + + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [ + 'user' => $user->getName(), + ] ); + $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() ); + $state['primary'] = $id; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountLinkState', $state ); + $session->persist(); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + + $this->logger->debug( __METHOD__ . ': Account linking failed because no provider accepted', [ + 'user' => $user->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-link-no-primary' ) + ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] ); + return $ret; + } + + /** + * Continue an account linking flow + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + public function continueAccountLink( array $reqs ) { + $session = $this->request->getSession(); + try { + if ( !$this->canLinkAccounts() ) { + // Caller should have called canLinkAccounts() + $session->remove( 'AuthManager::accountLinkState' ); + throw new \LogicException( 'Account linking is not possible' ); + } + + $state = $session->getSecret( 'AuthManager::accountLinkState' ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newFail( + wfMessage( 'authmanager-link-not-in-progress' ) + ); + } + $state['continueRequests'] = []; + + // Step 0: Prepare and validate the input + + $user = User::newFromName( $state['username'], 'usable' ); + if ( !is_object( $user ) ) { + $session->remove( 'AuthManager::accountLinkState' ); + return AuthenticationResponse::newFail( wfMessage( 'noname' ) ); + } + if ( $user->getId() != $state['userid'] ) { + throw new \UnexpectedValueException( + "User \"{$state['username']}\" is valid, but " . + "ID {$user->getId()} != {$state['userid']}!" + ); + } + + foreach ( $reqs as $req ) { + $req->username = $state['username']; + $req->returnToUrl = $state['returnToUrl']; + } + + // Step 1: Call the primary again until it succeeds + + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-link-not-in-progress' ) + ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] ); + $session->remove( 'AuthManager::accountLinkState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $id = $provider->getUniqueId(); + $res = $provider->continuePrimaryAccountLink( $user, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->info( "Account linked to {user} by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + $session->remove( 'AuthManager::accountLinkState' ); + return $res; + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + $session->remove( 'AuthManager::accountLinkState' ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [ + 'user' => $user->getName(), + ] ); + $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() ); + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountLinkState', $state ); + return $res; + default: + throw new \DomainException( + get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status" + ); + } + } catch ( \Exception $ex ) { + $session->remove( 'AuthManager::accountLinkState' ); + throw $ex; + } + } + + /**@}*/ + + /** + * @name Information methods + * @{ + */ + + /** + * Return the applicable list of AuthenticationRequests + * + * Possible values for $action: + * - ACTION_LOGIN: Valid for passing to beginAuthentication + * - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state + * - ACTION_CREATE: Valid for passing to beginAccountCreation + * - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state + * - ACTION_LINK: Valid for passing to beginAccountLink + * - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state + * - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials + * - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials. + * - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts. + * + * @param string $action One of the AuthManager::ACTION_* constants + * @param User|null $user User being acted on, instead of the current user. + * @return AuthenticationRequest[] + */ + public function getAuthenticationRequests( $action, User $user = null ) { + $options = []; + $providerAction = $action; + + // Figure out which providers to query + switch ( $action ) { + case self::ACTION_LOGIN: + case self::ACTION_CREATE: + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + break; + + case self::ACTION_LOGIN_CONTINUE: + $state = $this->request->getSession()->getSecret( 'AuthManager::authnState' ); + return is_array( $state ) ? $state['continueRequests'] : []; + + case self::ACTION_CREATE_CONTINUE: + $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ); + return is_array( $state ) ? $state['continueRequests'] : []; + + case self::ACTION_LINK: + $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) { + return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK; + } ); + break; + + case self::ACTION_UNLINK: + $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) { + return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK; + } ); + + // To providers, unlink and remove are identical. + $providerAction = self::ACTION_REMOVE; + break; + + case self::ACTION_LINK_CONTINUE: + $state = $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ); + return is_array( $state ) ? $state['continueRequests'] : []; + + case self::ACTION_CHANGE: + case self::ACTION_REMOVE: + $providers = $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + break; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( __METHOD__ . ": Invalid action \"$action\"" ); + } + // @codeCoverageIgnoreEnd + + return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user ); + } + + /** + * Internal request lookup for self::getAuthenticationRequests + * + * @param string $providerAction Action to pass to providers + * @param array $options Options to pass to providers + * @param AuthenticationProvider[] $providers + * @param User|null $user + * @return AuthenticationRequest[] + */ + private function getAuthenticationRequestsInternal( + $providerAction, array $options, array $providers, User $user = null + ) { + $user = $user ?: \RequestContext::getMain()->getUser(); + $options['username'] = $user->isAnon() ? null : $user->getName(); + + // Query them and merge results + $reqs = []; + foreach ( $providers as $provider ) { + $isPrimary = $provider instanceof PrimaryAuthenticationProvider; + foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) { + $id = $req->getUniqueId(); + + // If a required request if from a Primary, mark it as "primary-required" instead + if ( $isPrimary ) { + if ( $req->required ) { + $req->required = AuthenticationRequest::PRIMARY_REQUIRED; + } + } + + if ( + !isset( $reqs[$id] ) + || $req->required === AuthenticationRequest::REQUIRED + || $reqs[$id] === AuthenticationRequest::OPTIONAL + ) { + $reqs[$id] = $req; + } + } + } + + // AuthManager has its own req for some actions + switch ( $providerAction ) { + case self::ACTION_LOGIN: + $reqs[] = new RememberMeAuthenticationRequest; + break; + + case self::ACTION_CREATE: + $reqs[] = new UsernameAuthenticationRequest; + $reqs[] = new UserDataAuthenticationRequest; + if ( $options['username'] !== null ) { + $reqs[] = new CreationReasonAuthenticationRequest; + $options['username'] = null; // Don't fill in the username below + } + break; + } + + // Fill in reqs data + $this->fillRequests( $reqs, $providerAction, $options['username'], true ); + + // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing + if ( $providerAction === self::ACTION_CHANGE || $providerAction === self::ACTION_REMOVE ) { + $reqs = array_filter( $reqs, function ( $req ) { + return $this->allowsAuthenticationDataChange( $req, false )->isGood(); + } ); + } + + return array_values( $reqs ); + } + + /** + * Set values in an array of requests + * @param AuthenticationRequest[] &$reqs + * @param string $action + * @param string|null $username + * @param bool $forceAction + */ + private function fillRequests( array &$reqs, $action, $username, $forceAction = false ) { + foreach ( $reqs as $req ) { + if ( !$req->action || $forceAction ) { + $req->action = $action; + } + if ( $req->username === null ) { + $req->username = $username; + } + } + } + + /** + * Determine whether a username exists + * @param string $username + * @param int $flags Bitfield of User:READ_* constants + * @return bool + */ + public function userExists( $username, $flags = User::READ_NORMAL ) { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + if ( $provider->testUserExists( $username, $flags ) ) { + return true; + } + } + + return false; + } + + /** + * Determine whether a user property should be allowed to be changed. + * + * Supported properties are: + * - emailaddress + * - realname + * - nickname + * + * @param string $property + * @return bool + */ + public function allowsPropertyChange( $property ) { + $providers = $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + if ( !$provider->providerAllowsPropertyChange( $property ) ) { + return false; + } + } + return true; + } + + /** + * Get a provider by ID + * @note This is public so extensions can check whether their own provider + * is installed and so they can read its configuration if necessary. + * Other uses are not recommended. + * @param string $id + * @return AuthenticationProvider|null + */ + public function getAuthenticationProvider( $id ) { + // Fast version + if ( isset( $this->allAuthenticationProviders[$id] ) ) { + return $this->allAuthenticationProviders[$id]; + } + + // Slow version: instantiate each kind and check + $providers = $this->getPrimaryAuthenticationProviders(); + if ( isset( $providers[$id] ) ) { + return $providers[$id]; + } + $providers = $this->getSecondaryAuthenticationProviders(); + if ( isset( $providers[$id] ) ) { + return $providers[$id]; + } + $providers = $this->getPreAuthenticationProviders(); + if ( isset( $providers[$id] ) ) { + return $providers[$id]; + } + + return null; + } + + /**@}*/ + + /** + * @name Internal methods + * @{ + */ + + /** + * Store authentication in the current session + * @protected For use by AuthenticationProviders + * @param string $key + * @param mixed $data Must be serializable + */ + public function setAuthenticationSessionData( $key, $data ) { + $session = $this->request->getSession(); + $arr = $session->getSecret( 'authData' ); + if ( !is_array( $arr ) ) { + $arr = []; + } + $arr[$key] = $data; + $session->setSecret( 'authData', $arr ); + } + + /** + * Fetch authentication data from the current session + * @protected For use by AuthenticationProviders + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getAuthenticationSessionData( $key, $default = null ) { + $arr = $this->request->getSession()->getSecret( 'authData' ); + if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) { + return $arr[$key]; + } else { + return $default; + } + } + + /** + * Remove authentication data + * @protected For use by AuthenticationProviders + * @param string|null $key If null, all data is removed + */ + public function removeAuthenticationSessionData( $key ) { + $session = $this->request->getSession(); + if ( $key === null ) { + $session->remove( 'authData' ); + } else { + $arr = $session->getSecret( 'authData' ); + if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) { + unset( $arr[$key] ); + $session->setSecret( 'authData', $arr ); + } + } + } + + /** + * Create an array of AuthenticationProviders from an array of ObjectFactory specs + * @param string $class + * @param array[] $specs + * @return AuthenticationProvider[] + */ + protected function providerArrayFromSpecs( $class, array $specs ) { + $i = 0; + foreach ( $specs as &$spec ) { + $spec = [ 'sort2' => $i++ ] + $spec + [ 'sort' => 0 ]; + } + unset( $spec ); + usort( $specs, function ( $a, $b ) { + return ( (int)$a['sort'] ) - ( (int)$b['sort'] ) + ?: $a['sort2'] - $b['sort2']; + } ); + + $ret = []; + foreach ( $specs as $spec ) { + $provider = ObjectFactory::getObjectFromSpec( $spec ); + if ( !$provider instanceof $class ) { + throw new \RuntimeException( + "Expected instance of $class, got " . get_class( $provider ) + ); + } + $provider->setLogger( $this->logger ); + $provider->setManager( $this ); + $provider->setConfig( $this->config ); + $id = $provider->getUniqueId(); + if ( isset( $this->allAuthenticationProviders[$id] ) ) { + throw new \RuntimeException( + "Duplicate specifications for id $id (classes " . + get_class( $provider ) . ' and ' . + get_class( $this->allAuthenticationProviders[$id] ) . ')' + ); + } + $this->allAuthenticationProviders[$id] = $provider; + $ret[$id] = $provider; + } + return $ret; + } + + /** + * Get the configuration + * @return array + */ + private function getConfiguration() { + return $this->config->get( 'AuthManagerConfig' ) ?: $this->config->get( 'AuthManagerAutoConfig' ); + } + + /** + * Get the list of PreAuthenticationProviders + * @return PreAuthenticationProvider[] + */ + protected function getPreAuthenticationProviders() { + if ( $this->preAuthenticationProviders === null ) { + $conf = $this->getConfiguration(); + $this->preAuthenticationProviders = $this->providerArrayFromSpecs( + PreAuthenticationProvider::class, $conf['preauth'] + ); + } + return $this->preAuthenticationProviders; + } + + /** + * Get the list of PrimaryAuthenticationProviders + * @return PrimaryAuthenticationProvider[] + */ + protected function getPrimaryAuthenticationProviders() { + if ( $this->primaryAuthenticationProviders === null ) { + $conf = $this->getConfiguration(); + $this->primaryAuthenticationProviders = $this->providerArrayFromSpecs( + PrimaryAuthenticationProvider::class, $conf['primaryauth'] + ); + } + return $this->primaryAuthenticationProviders; + } + + /** + * Get the list of SecondaryAuthenticationProviders + * @return SecondaryAuthenticationProvider[] + */ + protected function getSecondaryAuthenticationProviders() { + if ( $this->secondaryAuthenticationProviders === null ) { + $conf = $this->getConfiguration(); + $this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs( + SecondaryAuthenticationProvider::class, $conf['secondaryauth'] + ); + } + return $this->secondaryAuthenticationProviders; + } + + /** + * Log the user in + * @param User $user + * @param bool|null $remember + */ + private function setSessionDataForUser( $user, $remember = null ) { + $session = $this->request->getSession(); + $delay = $session->delaySave(); + + $session->resetId(); + $session->resetAllTokens(); + if ( $session->canSetUser() ) { + $session->setUser( $user ); + } + if ( $remember !== null ) { + $session->setRememberUser( $remember ); + } + $session->set( 'AuthManager:lastAuthId', $user->getId() ); + $session->set( 'AuthManager:lastAuthTimestamp', time() ); + $session->persist(); + + \Wikimedia\ScopedCallback::consume( $delay ); + + \Hooks::run( 'UserLoggedIn', [ $user ] ); + } + + /** + * @param User $user + * @param bool $useContextLang Use 'uselang' to set the user's language + */ + private function setDefaultUserOptions( User $user, $useContextLang ) { + global $wgContLang; + + $user->setToken(); + + $lang = $useContextLang ? \RequestContext::getMain()->getLanguage() : $wgContLang; + $user->setOption( 'language', $lang->getPreferredVariant() ); + + if ( $wgContLang->hasVariants() ) { + $user->setOption( 'variant', $wgContLang->getPreferredVariant() ); + } + } + + /** + * @param int $which Bitmask: 1 = pre, 2 = primary, 4 = secondary + * @param string $method + * @param array $args + */ + private function callMethodOnProviders( $which, $method, array $args ) { + $providers = []; + if ( $which & 1 ) { + $providers += $this->getPreAuthenticationProviders(); + } + if ( $which & 2 ) { + $providers += $this->getPrimaryAuthenticationProviders(); + } + if ( $which & 4 ) { + $providers += $this->getSecondaryAuthenticationProviders(); + } + foreach ( $providers as $provider ) { + call_user_func_array( [ $provider, $method ], $args ); + } + } + + /** + * Reset the internal caching for unit testing + * @protected Unit tests only + */ + public static function resetCache() { + if ( !defined( 'MW_PHPUNIT_TEST' ) ) { + // @codeCoverageIgnoreStart + throw new \MWException( __METHOD__ . ' may only be called from unit tests!' ); + // @codeCoverageIgnoreEnd + } + + self::$instance = null; + } + + /**@}*/ + +} + +/** + * For really cool vim folding this needs to be at the end: + * vim: foldmarker=@{,@} foldmethod=marker + */ diff --git a/www/wiki/includes/auth/AuthManagerAuthPlugin.php b/www/wiki/includes/auth/AuthManagerAuthPlugin.php new file mode 100644 index 00000000..4f84b4c6 --- /dev/null +++ b/www/wiki/includes/auth/AuthManagerAuthPlugin.php @@ -0,0 +1,231 @@ +<?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 + */ + +namespace MediaWiki\Auth; + +use Psr\Log\LoggerInterface; +use User; + +/** + * Backwards-compatibility wrapper for AuthManager via $wgAuth + * @since 1.27 + * @deprecated since 1.27 + */ +class AuthManagerAuthPlugin extends \AuthPlugin { + /** @var string|null */ + protected $domain = null; + + /** @var LoggerInterface */ + protected $logger = null; + + public function __construct() { + $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ); + } + + public function userExists( $name ) { + return AuthManager::singleton()->userExists( $name ); + } + + public function authenticate( $username, $password ) { + $data = [ + 'username' => $username, + 'password' => $password, + ]; + if ( $this->domain !== null && $this->domain !== '' ) { + $data['domain'] = $this->domain; + } + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + + $res = AuthManager::singleton()->beginAuthentication( $reqs, 'null:' ); + switch ( $res->status ) { + case AuthenticationResponse::PASS: + return true; + case AuthenticationResponse::FAIL: + // Hope it's not a PreAuthenticationProvider that failed... + $msg = $res->message instanceof \Message ? $res->message : new \Message( $res->message ); + $this->logger->info( __METHOD__ . ': Authentication failed: ' . $msg->plain() ); + return false; + default: + throw new \BadMethodCallException( + 'AuthManager does not support such simplified authentication' + ); + } + } + + public function modifyUITemplate( &$template, &$type ) { + // AuthManager does not support direct UI screwing-around-with + } + + public function setDomain( $domain ) { + $this->domain = $domain; + } + + public function getDomain() { + if ( isset( $this->domain ) ) { + return $this->domain; + } else { + return 'invaliddomain'; + } + } + + public function validDomain( $domain ) { + $domainList = $this->domainList(); + return $domainList ? in_array( $domain, $domainList, true ) : $domain === ''; + } + + public function updateUser( &$user ) { + \Hooks::run( 'UserLoggedIn', [ $user ] ); + return true; + } + + public function autoCreate() { + return true; + } + + public function allowPropChange( $prop = '' ) { + return AuthManager::singleton()->allowsPropertyChange( $prop ); + } + + public function allowPasswordChange() { + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CHANGE ); + foreach ( $reqs as $req ) { + if ( $req instanceof PasswordAuthenticationRequest ) { + return true; + } + } + + return false; + } + + public function allowSetLocalPassword() { + // There should be a PrimaryAuthenticationProvider that does this, if necessary + return false; + } + + public function setPassword( $user, $password ) { + $data = [ + 'username' => $user->getName(), + 'password' => $password, + ]; + if ( $this->domain !== null && $this->domain !== '' ) { + $data['domain'] = $this->domain; + } + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CHANGE ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + foreach ( $reqs as $req ) { + $status = AuthManager::singleton()->allowsAuthenticationDataChange( $req ); + if ( !$status->isGood() ) { + $this->logger->info( __METHOD__ . ': Password change rejected: {reason}', [ + 'username' => $data['username'], + 'reason' => $status->getWikiText( null, null, 'en' ), + ] ); + return false; + } + } + foreach ( $reqs as $req ) { + AuthManager::singleton()->changeAuthenticationData( $req ); + } + return true; + } + + public function updateExternalDB( $user ) { + // This fires the necessary hook + $user->saveSettings(); + return true; + } + + public function updateExternalDBGroups( $user, $addgroups, $delgroups = [] ) { + throw new \BadMethodCallException( + 'Update of user groups via AuthPlugin is not supported with AuthManager.' + ); + } + + public function canCreateAccounts() { + return AuthManager::singleton()->canCreateAccounts(); + } + + public function addUser( $user, $password, $email = '', $realname = '' ) { + throw new \BadMethodCallException( + 'Creation of users via AuthPlugin is not supported with ' + . 'AuthManager. Generally, user creation should be left to either ' + . 'Special:CreateAccount, auto-creation when triggered by a ' + . 'SessionProvider or PrimaryAuthenticationProvider, or ' + . 'User::newSystemUser().' + ); + } + + public function strict() { + // There should be a PrimaryAuthenticationProvider that does this, if necessary + return true; + } + + public function strictUserAuth( $username ) { + // There should be a PrimaryAuthenticationProvider that does this, if necessary + return true; + } + + public function initUser( &$user, $autocreate = false ) { + \Hooks::run( 'LocalUserCreated', [ $user, $autocreate ] ); + } + + public function getCanonicalName( $username ) { + // AuthManager doesn't support restrictions beyond MediaWiki's + return $username; + } + + public function getUserInstance( User &$user ) { + return new AuthManagerAuthPluginUser( $user ); + } + + public function domainList() { + return []; + } +} + +/** + * @since 1.27 + * @deprecated since 1.27 + */ +class AuthManagerAuthPluginUser extends \AuthPluginUser { + /** @var User */ + private $user; + + function __construct( $user ) { + $this->user = $user; + } + + public function getId() { + return $this->user->getId(); + } + + public function isLocked() { + return $this->user->isLocked(); + } + + public function isHidden() { + return $this->user->isHidden(); + } + + public function resetAuthToken() { + \MediaWiki\Session\SessionManager::singleton()->invalidateSessionsForUser( $this->user ); + return true; + } +} diff --git a/www/wiki/includes/auth/AuthPluginPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/AuthPluginPrimaryAuthenticationProvider.php new file mode 100644 index 00000000..cd0734d8 --- /dev/null +++ b/www/wiki/includes/auth/AuthPluginPrimaryAuthenticationProvider.php @@ -0,0 +1,429 @@ +<?php +/** + * Primary authentication provider wrapper for AuthPlugin + * + * 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 AuthPlugin; +use User; + +/** + * Primary authentication provider wrapper for AuthPlugin + * @warning If anything depends on the wrapped AuthPlugin being $wgAuth, it won't work with this! + * @ingroup Auth + * @since 1.27 + * @deprecated since 1.27 + */ +class AuthPluginPrimaryAuthenticationProvider + extends AbstractPasswordPrimaryAuthenticationProvider +{ + private $auth; + private $hasDomain; + private $requestType = null; + + /** + * @param AuthPlugin $auth AuthPlugin to wrap + * @param string|null $requestType Class name of the + * PasswordAuthenticationRequest to use. If $auth->domainList() returns + * more than one domain, this must be a PasswordDomainAuthenticationRequest. + */ + public function __construct( AuthPlugin $auth, $requestType = null ) { + parent::__construct(); + + if ( $auth instanceof AuthManagerAuthPlugin ) { + throw new \InvalidArgumentException( + 'Trying to wrap AuthManagerAuthPlugin in AuthPluginPrimaryAuthenticationProvider ' . + 'makes no sense.' + ); + } + + $need = count( $auth->domainList() ) > 1 + ? PasswordDomainAuthenticationRequest::class + : PasswordAuthenticationRequest::class; + if ( $requestType === null ) { + $requestType = $need; + } elseif ( $requestType !== $need && !is_subclass_of( $requestType, $need ) ) { + throw new \InvalidArgumentException( "$requestType is not a $need" ); + } + + $this->auth = $auth; + $this->requestType = $requestType; + $this->hasDomain = ( + $requestType === PasswordDomainAuthenticationRequest::class || + is_subclass_of( $requestType, PasswordDomainAuthenticationRequest::class ) + ); + $this->authoritative = $auth->strict(); + + // Registering hooks from core is unusual, but is needed here to be + // able to call the AuthPlugin methods those hooks replace. + \Hooks::register( 'UserSaveSettings', [ $this, 'onUserSaveSettings' ] ); + \Hooks::register( 'UserGroupsChanged', [ $this, 'onUserGroupsChanged' ] ); + \Hooks::register( 'UserLoggedIn', [ $this, 'onUserLoggedIn' ] ); + \Hooks::register( 'LocalUserCreated', [ $this, 'onLocalUserCreated' ] ); + } + + /** + * Create an appropriate AuthenticationRequest + * @return PasswordAuthenticationRequest + */ + protected function makeAuthReq() { + $class = $this->requestType; + if ( $this->hasDomain ) { + return new $class( $this->auth->domainList() ); + } else { + return new $class(); + } + } + + /** + * Call $this->auth->setDomain() + * @param PasswordAuthenticationRequest $req + */ + protected function setDomain( $req ) { + if ( $this->hasDomain ) { + $domain = $req->domain; + } else { + // Just grab the first one. + $domainList = $this->auth->domainList(); + $domain = reset( $domainList ); + } + + // Special:UserLogin does this. Strange. + if ( !$this->auth->validDomain( $domain ) ) { + $domain = $this->auth->getDomain(); + } + $this->auth->setDomain( $domain ); + } + + /** + * Hook function to call AuthPlugin::updateExternalDB() + * @param User $user + * @codeCoverageIgnore + */ + public function onUserSaveSettings( $user ) { + // No way to know the domain, just hope the provider handles that. + $this->auth->updateExternalDB( $user ); + } + + /** + * Hook function to call AuthPlugin::updateExternalDBGroups() + * @param User $user + * @param array $added + * @param array $removed + */ + public function onUserGroupsChanged( $user, $added, $removed ) { + // No way to know the domain, just hope the provider handles that. + $this->auth->updateExternalDBGroups( $user, $added, $removed ); + } + + /** + * Hook function to call AuthPlugin::updateUser() + * @param User $user + */ + public function onUserLoggedIn( $user ) { + $hookUser = $user; + // No way to know the domain, just hope the provider handles that. + $this->auth->updateUser( $hookUser ); + if ( $hookUser !== $user ) { + throw new \UnexpectedValueException( + get_class( $this->auth ) . '::updateUser() tried to replace $user!' + ); + } + } + + /** + * Hook function to call AuthPlugin::initUser() + * @param User $user + * @param bool $autocreated + */ + public function onLocalUserCreated( $user, $autocreated ) { + // For $autocreated, see self::autoCreatedAccount() + if ( !$autocreated ) { + $hookUser = $user; + // No way to know the domain, just hope the provider handles that. + $this->auth->initUser( $hookUser, $autocreated ); + if ( $hookUser !== $user ) { + throw new \UnexpectedValueException( + get_class( $this->auth ) . '::initUser() tried to replace $user!' + ); + } + } + } + + public function getUniqueId() { + return parent::getUniqueId() . ':' . get_class( $this->auth ); + } + + public function getAuthenticationRequests( $action, array $options ) { + switch ( $action ) { + case AuthManager::ACTION_LOGIN: + case AuthManager::ACTION_CREATE: + return [ $this->makeAuthReq() ]; + + case AuthManager::ACTION_CHANGE: + case AuthManager::ACTION_REMOVE: + // No way to know the domain, just hope the provider handles that. + return $this->auth->allowPasswordChange() ? [ $this->makeAuthReq() ] : []; + + default: + return []; + } + } + + public function beginPrimaryAuthentication( array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, $this->requestType ); + if ( !$req || $req->username === null || $req->password === null || + ( $this->hasDomain && $req->domain === null ) + ) { + return AuthenticationResponse::newAbstain(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return AuthenticationResponse::newAbstain(); + } + + $this->setDomain( $req ); + if ( $this->testUserCanAuthenticateInternal( User::newFromName( $username ) ) && + $this->auth->authenticate( $username, $req->password ) + ) { + return AuthenticationResponse::newPass( $username ); + } else { + $this->authoritative = $this->auth->strict() || $this->auth->strictUserAuth( $username ); + return $this->failResponse( $req ); + } + } + + public function testUserCanAuthenticate( $username ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + // We have to check every domain, because at least LdapAuthentication + // interprets AuthPlugin::userExists() as applying only to the current + // domain. + $curDomain = $this->auth->getDomain(); + $domains = $this->auth->domainList() ?: [ '' ]; + foreach ( $domains as $domain ) { + $this->auth->setDomain( $domain ); + if ( $this->testUserCanAuthenticateInternal( User::newFromName( $username ) ) ) { + $this->auth->setDomain( $curDomain ); + return true; + } + } + $this->auth->setDomain( $curDomain ); + return false; + } + + /** + * @see self::testUserCanAuthenticate + * @note The caller is responsible for calling $this->auth->setDomain() + * @param User $user + * @return bool + */ + private function testUserCanAuthenticateInternal( $user ) { + if ( $this->auth->userExists( $user->getName() ) ) { + return !$this->auth->getUserInstance( $user )->isLocked(); + } else { + return false; + } + } + + public function providerRevokeAccessForUser( $username ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return; + } + $user = User::newFromName( $username ); + if ( $user ) { + // Reset the password on every domain. + $curDomain = $this->auth->getDomain(); + $domains = $this->auth->domainList() ?: [ '' ]; + $failed = []; + foreach ( $domains as $domain ) { + $this->auth->setDomain( $domain ); + if ( $this->testUserCanAuthenticateInternal( $user ) && + !$this->auth->setPassword( $user, null ) + ) { + $failed[] = $domain === '' ? '(default)' : $domain; + } + } + $this->auth->setDomain( $curDomain ); + if ( $failed ) { + throw new \UnexpectedValueException( + "AuthPlugin failed to reset password for $username in the following domains: " + . implode( ' ', $failed ) + ); + } + } + } + + public function testUserExists( $username, $flags = User::READ_NORMAL ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + // We have to check every domain, because at least LdapAuthentication + // interprets AuthPlugin::userExists() as applying only to the current + // domain. + $curDomain = $this->auth->getDomain(); + $domains = $this->auth->domainList() ?: [ '' ]; + foreach ( $domains as $domain ) { + $this->auth->setDomain( $domain ); + if ( $this->auth->userExists( $username ) ) { + $this->auth->setDomain( $curDomain ); + return true; + } + } + $this->auth->setDomain( $curDomain ); + return false; + } + + public function providerAllowsPropertyChange( $property ) { + // No way to know the domain, just hope the provider handles that. + return $this->auth->allowPropChange( $property ); + } + + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ) { + if ( get_class( $req ) !== $this->requestType ) { + return \StatusValue::newGood( 'ignored' ); + } + + // Hope it works, AuthPlugin gives us no way to do this. + $curDomain = $this->auth->getDomain(); + $this->setDomain( $req ); + try { + // If !$checkData the domain might be wrong. Nothing we can do about that. + if ( !$this->auth->allowPasswordChange() ) { + return \StatusValue::newFatal( 'authmanager-authplugin-setpass-denied' ); + } + + if ( !$checkData ) { + return \StatusValue::newGood(); + } + + if ( $this->hasDomain ) { + if ( $req->domain === null ) { + return \StatusValue::newGood( 'ignored' ); + } + if ( !$this->auth->validDomain( $req->domain ) ) { + return \StatusValue::newFatal( 'authmanager-authplugin-setpass-bad-domain' ); + } + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username !== false ) { + $sv = \StatusValue::newGood(); + if ( $req->password !== null ) { + if ( $req->password !== $req->retype ) { + $sv->fatal( 'badretype' ); + } else { + $sv->merge( $this->checkPasswordValidity( $username, $req->password ) ); + } + } + return $sv; + } else { + return \StatusValue::newGood( 'ignored' ); + } + } finally { + $this->auth->setDomain( $curDomain ); + } + } + + public function providerChangeAuthenticationData( AuthenticationRequest $req ) { + if ( get_class( $req ) === $this->requestType ) { + $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false; + if ( $username === false ) { + return; + } + + if ( $this->hasDomain && $req->domain === null ) { + return; + } + + $this->setDomain( $req ); + $user = User::newFromName( $username ); + if ( !$this->auth->setPassword( $user, $req->password ) ) { + // This is totally unfriendly and leaves other + // AuthenticationProviders in an uncertain state, but what else + // can we do? + throw new \ErrorPageError( + 'authmanager-authplugin-setpass-failed-title', + 'authmanager-authplugin-setpass-failed-message' + ); + } + } + } + + public function accountCreationType() { + // No way to know the domain, just hope the provider handles that. + return $this->auth->canCreateAccounts() ? self::TYPE_CREATE : self::TYPE_NONE; + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + return \StatusValue::newGood(); + } + + public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) { + if ( $this->accountCreationType() === self::TYPE_NONE ) { + throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, $this->requestType ); + if ( !$req || $req->username === null || $req->password === null || + ( $this->hasDomain && $req->domain === null ) + ) { + return AuthenticationResponse::newAbstain(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return AuthenticationResponse::newAbstain(); + } + + $this->setDomain( $req ); + if ( $this->auth->addUser( + $user, $req->password, $user->getEmail(), $user->getRealName() + ) ) { + return AuthenticationResponse::newPass(); + } else { + return AuthenticationResponse::newFail( + new \Message( 'authmanager-authplugin-create-fail' ) + ); + } + } + + public function autoCreatedAccount( $user, $source ) { + $hookUser = $user; + // No way to know the domain, just hope the provider handles that. + $this->auth->initUser( $hookUser, true ); + if ( $hookUser !== $user ) { + throw new \UnexpectedValueException( + get_class( $this->auth ) . '::initUser() tried to replace $user!' + ); + } + } +} diff --git a/www/wiki/includes/auth/AuthenticationProvider.php b/www/wiki/includes/auth/AuthenticationProvider.php new file mode 100644 index 00000000..11f3e226 --- /dev/null +++ b/www/wiki/includes/auth/AuthenticationProvider.php @@ -0,0 +1,98 @@ +<?php +/** + * Authentication provider interface + * + * 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 Config; +use Psr\Log\LoggerAwareInterface; + +/** + * An AuthenticationProvider is used by AuthManager when authenticating users. + * + * This interface should not be implemented directly; use one of its children. + * + * Authentication providers can be registered via $wgAuthManagerAutoConfig. + * + * @ingroup Auth + * @since 1.27 + */ +interface AuthenticationProvider extends LoggerAwareInterface { + + /** + * Set AuthManager + * @param AuthManager $manager + */ + public function setManager( AuthManager $manager ); + + /** + * Set configuration + * @param Config $config + */ + public function setConfig( Config $config ); + + /** + * Return a unique identifier for this instance + * + * This must be the same across requests. If multiple instances return the + * same ID, exceptions will be thrown from AuthManager. + * + * @return string + */ + public function getUniqueId(); + + /** + * Return the applicable list of AuthenticationRequests + * + * Possible values for $action depend on whether the implementing class is + * also a PreAuthenticationProvider, PrimaryAuthenticationProvider, or + * SecondaryAuthenticationProvider. + * - ACTION_LOGIN: Valid for passing to beginAuthentication. Called on all + * providers. + * - ACTION_CREATE: Valid for passing to beginAccountCreation. Called on + * all providers. + * - ACTION_LINK: Valid for passing to beginAccountLink. Called on linking + * primary providers only. + * - ACTION_CHANGE: Valid for passing to AuthManager::changeAuthenticationData + * to change credentials. Called on primary and secondary providers. + * - ACTION_REMOVE: Valid for passing to AuthManager::changeAuthenticationData + * to remove credentials. Must work without additional user input (i.e. + * without calling loadFromSubmission). Called on primary and secondary + * providers. + * + * @see AuthManager::getAuthenticationRequests() + * @param string $action + * @param array $options Options are: + * - username: User name related to the action, or null/unset if anon. + * - ACTION_LOGIN: The currently logged-in user, if any. + * - ACTION_CREATE: The account creator, if non-anonymous. + * - ACTION_LINK: The local user being linked to. + * - ACTION_CHANGE: The user having data changed. + * - ACTION_REMOVE: The user having data removed. + * If you leave the username property of the returned requests empty, this + * will automatically be copied there (except for ACTION_CREATE where it + * wouldn't really make sense). + * @return AuthenticationRequest[] + */ + public function getAuthenticationRequests( $action, array $options ); + +} diff --git a/www/wiki/includes/auth/AuthenticationRequest.php b/www/wiki/includes/auth/AuthenticationRequest.php new file mode 100644 index 00000000..7fc362a2 --- /dev/null +++ b/www/wiki/includes/auth/AuthenticationRequest.php @@ -0,0 +1,379 @@ +<?php +/** + * Authentication request value object + * + * 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 Message; + +/** + * This is a value object for authentication requests. + * + * An AuthenticationRequest represents a set of form fields that are needed on + * and provided from a login, account creation, password change or similar form. + * + * @ingroup Auth + * @since 1.27 + */ +abstract class AuthenticationRequest { + + /** Indicates that the request is not required for authentication to proceed. */ + const OPTIONAL = 0; + + /** Indicates that the request is required for authentication to proceed. + * This will only be used for UI purposes; it is the authentication providers' + * responsibility to verify that all required requests are present. + */ + const REQUIRED = 1; + + /** Indicates that the request is required by a primary authentication + * provider. Since the user can choose which primary to authenticate with, + * the request might or might not end up being actually required. */ + const PRIMARY_REQUIRED = 2; + + /** @var string|null The AuthManager::ACTION_* constant this request was + * created to be used for. The *_CONTINUE constants are not used here, the + * corresponding "begin" constant is used instead. + */ + public $action = null; + + /** @var int For login, continue, and link actions, one of self::OPTIONAL, + * self::REQUIRED, or self::PRIMARY_REQUIRED */ + public $required = self::REQUIRED; + + /** @var string|null Return-to URL, in case of redirect */ + public $returnToUrl = null; + + /** @var string|null Username. See AuthenticationProvider::getAuthenticationRequests() + * for details of what this means and how it behaves. */ + public $username = null; + + /** + * Supply a unique key for deduplication + * + * When the AuthenticationRequests instances returned by the providers are + * merged, the value returned here is used for keeping only one copy of + * duplicate requests. + * + * Subclasses should override this if multiple distinct instances would + * make sense, i.e. the request class has internal state of some sort. + * + * This value might be exposed to the user in web forms so it should not + * contain private information. + * + * @return string + */ + public function getUniqueId() { + return get_called_class(); + } + + /** + * Fetch input field info + * + * The field info is an associative array mapping field names to info + * arrays. The info arrays have the following keys: + * - type: (string) Type of input. Types and equivalent HTML widgets are: + * - string: <input type="text"> + * - password: <input type="password"> + * - select: <select> + * - checkbox: <input type="checkbox"> + * - multiselect: More a grid of checkboxes than <select multi> + * - button: <input type="submit"> (uses 'label' as button text) + * - hidden: Not visible to the user, but needs to be preserved for the next request + * - null: No widget, just display the 'label' message. + * - options: (array) Maps option values to Messages for the + * 'select' and 'multiselect' types. + * - value: (string) Value (for 'null' and 'hidden') or default value (for other types). + * - label: (Message) Text suitable for a label in an HTML form + * - help: (Message) Text suitable as a description of what the field is + * - optional: (bool) If set and truthy, the field may be left empty + * - sensitive: (bool) If set and truthy, the field is considered sensitive. Code using the + * request should avoid exposing the value of the field. + * - skippable: (bool) If set and truthy, the client is free to hide this + * field from the user to streamline the workflow. If all fields are + * skippable (except possibly a single button), no user interaction is + * required at all. + * + * All AuthenticationRequests are populated from the same data, so most of the time you'll + * want to prefix fields names with something unique to the extension/provider (although + * in some cases sharing the field with other requests is the right thing to do, e.g. for + * a 'password' field). + * + * @return array As above + */ + abstract public function getFieldInfo(); + + /** + * Returns metadata about this request. + * + * This is mainly for the benefit of API clients which need more detailed render hints + * than what's available through getFieldInfo(). Semantics are unspecified and left to the + * individual subclasses, but the contents of the array should be primitive types so that they + * can be transformed into JSON or similar formats. + * + * @return array A (possibly nested) array with primitive types + */ + public function getMetadata() { + return []; + } + + /** + * Initialize form submitted form data. + * + * The default behavior is to to check for each key of self::getFieldInfo() + * in the submitted data, and copy the value - after type-appropriate transformations - + * to $this->$key. Most subclasses won't need to override this; if you do override it, + * make sure to always return false if self::getFieldInfo() returns an empty array. + * + * @param array $data Submitted data as an associative array (keys will correspond + * to getFieldInfo()) + * @return bool Whether the request data was successfully loaded + */ + public function loadFromSubmission( array $data ) { + $fields = array_filter( $this->getFieldInfo(), function ( $info ) { + return $info['type'] !== 'null'; + } ); + if ( !$fields ) { + return false; + } + + foreach ( $fields as $field => $info ) { + // Checkboxes and buttons are special. Depending on the method used + // to populate $data, they might be unset meaning false or they + // might be boolean. Further, image buttons might submit the + // coordinates of the click rather than the expected value. + if ( $info['type'] === 'checkbox' || $info['type'] === 'button' ) { + $this->$field = isset( $data[$field] ) && $data[$field] !== false + || isset( $data["{$field}_x"] ) && $data["{$field}_x"] !== false; + if ( !$this->$field && empty( $info['optional'] ) ) { + return false; + } + continue; + } + + // Multiselect are too, slightly + if ( !isset( $data[$field] ) && $info['type'] === 'multiselect' ) { + $data[$field] = []; + } + + if ( !isset( $data[$field] ) ) { + return false; + } + if ( $data[$field] === '' || $data[$field] === [] ) { + if ( empty( $info['optional'] ) ) { + return false; + } + } else { + switch ( $info['type'] ) { + case 'select': + if ( !isset( $info['options'][$data[$field]] ) ) { + return false; + } + break; + + case 'multiselect': + $data[$field] = (array)$data[$field]; + $allowed = array_keys( $info['options'] ); + if ( array_diff( $data[$field], $allowed ) !== [] ) { + return false; + } + break; + } + } + + $this->$field = $data[$field]; + } + + return true; + } + + /** + * Describe the credentials represented by this request + * + * This is used on requests returned by + * AuthenticationProvider::getAuthenticationRequests() for ACTION_LINK + * and ACTION_REMOVE and for requests returned in + * AuthenticationResponse::$linkRequest to create useful user interfaces. + * + * @return Message[] with the following keys: + * - provider: A Message identifying the service that provides + * the credentials, e.g. the name of the third party authentication + * service. + * - account: A Message identifying the credentials themselves, + * e.g. the email address used with the third party authentication + * service. + */ + public function describeCredentials() { + return [ + 'provider' => new \RawMessage( '$1', [ get_called_class() ] ), + 'account' => new \RawMessage( '$1', [ $this->getUniqueId() ] ), + ]; + } + + /** + * Update a set of requests with form submit data, discarding ones that fail + * @param AuthenticationRequest[] $reqs + * @param array $data + * @return AuthenticationRequest[] + */ + public static function loadRequestsFromSubmission( array $reqs, array $data ) { + return array_values( array_filter( $reqs, function ( $req ) use ( $data ) { + return $req->loadFromSubmission( $data ); + } ) ); + } + + /** + * Select a request by class name. + * @param AuthenticationRequest[] $reqs + * @param string $class Class name + * @param bool $allowSubclasses If true, also returns any request that's a subclass of the given + * class. + * @return AuthenticationRequest|null Returns null if there is not exactly + * one matching request. + */ + public static function getRequestByClass( array $reqs, $class, $allowSubclasses = false ) { + $requests = array_filter( $reqs, function ( $req ) use ( $class, $allowSubclasses ) { + if ( $allowSubclasses ) { + return is_a( $req, $class, false ); + } else { + return get_class( $req ) === $class; + } + } ); + return count( $requests ) === 1 ? reset( $requests ) : null; + } + + /** + * Get the username from the set of requests + * + * Only considers requests that have a "username" field. + * + * @param AuthenticationRequest[] $reqs + * @return string|null + * @throws \UnexpectedValueException If multiple different usernames are present. + */ + public static function getUsernameFromRequests( array $reqs ) { + $username = null; + $otherClass = null; + foreach ( $reqs as $req ) { + $info = $req->getFieldInfo(); + if ( $info && array_key_exists( 'username', $info ) && $req->username !== null ) { + if ( $username === null ) { + $username = $req->username; + $otherClass = get_class( $req ); + } elseif ( $username !== $req->username ) { + $requestClass = get_class( $req ); + throw new \UnexpectedValueException( "Conflicting username fields: \"{$req->username}\" from " + . "$requestClass::\$username vs. \"$username\" from $otherClass::\$username" ); + } + } + } + return $username; + } + + /** + * Merge the output of multiple AuthenticationRequest::getFieldInfo() calls. + * @param AuthenticationRequest[] $reqs + * @return array + * @throws \UnexpectedValueException If fields cannot be merged + */ + public static function mergeFieldInfo( array $reqs ) { + $merged = []; + + // fields that are required by some primary providers but not others are not actually required + $primaryRequests = array_filter( $reqs, function ( $req ) { + return $req->required === AuthenticationRequest::PRIMARY_REQUIRED; + } ); + $sharedRequiredPrimaryFields = array_reduce( $primaryRequests, function ( $shared, $req ) { + $required = array_keys( array_filter( $req->getFieldInfo(), function ( $options ) { + return empty( $options['optional'] ); + } ) ); + if ( $shared === null ) { + return $required; + } else { + return array_intersect( $shared, $required ); + } + }, null ); + + foreach ( $reqs as $req ) { + $info = $req->getFieldInfo(); + if ( !$info ) { + continue; + } + + foreach ( $info as $name => $options ) { + if ( + // If the request isn't required, its fields aren't required either. + $req->required === self::OPTIONAL + // If there is a primary not requiring this field, no matter how many others do, + // authentication can proceed without it. + || $req->required === self::PRIMARY_REQUIRED + && !in_array( $name, $sharedRequiredPrimaryFields, true ) + ) { + $options['optional'] = true; + } else { + $options['optional'] = !empty( $options['optional'] ); + } + + $options['sensitive'] = !empty( $options['sensitive'] ); + + if ( !array_key_exists( $name, $merged ) ) { + $merged[$name] = $options; + } elseif ( $merged[$name]['type'] !== $options['type'] ) { + throw new \UnexpectedValueException( "Field type conflict for \"$name\", " . + "\"{$merged[$name]['type']}\" vs \"{$options['type']}\"" + ); + } else { + if ( isset( $options['options'] ) ) { + if ( isset( $merged[$name]['options'] ) ) { + $merged[$name]['options'] += $options['options']; + } else { + // @codeCoverageIgnoreStart + $merged[$name]['options'] = $options['options']; + // @codeCoverageIgnoreEnd + } + } + + $merged[$name]['optional'] = $merged[$name]['optional'] && $options['optional']; + $merged[$name]['sensitive'] = $merged[$name]['sensitive'] || $options['sensitive']; + + // No way to merge 'value', 'image', 'help', or 'label', so just use + // the value from the first request. + } + } + } + + return $merged; + } + + /** + * Implementing this mainly for use from the unit tests. + * @param array $data + * @return AuthenticationRequest + */ + public static function __set_state( $data ) { + $ret = new static(); + foreach ( $data as $k => $v ) { + $ret->$k = $v; + } + return $ret; + } +} diff --git a/www/wiki/includes/auth/AuthenticationResponse.php b/www/wiki/includes/auth/AuthenticationResponse.php new file mode 100644 index 00000000..956c9850 --- /dev/null +++ b/www/wiki/includes/auth/AuthenticationResponse.php @@ -0,0 +1,219 @@ +<?php +/** + * Authentication response value object + * + * 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 Message; + +/** + * This is a value object to hold authentication response data + * + * An AuthenticationResponse represents both the status of the authentication + * (success, failure, in progress) and it its state (what data is needed to continue). + * + * @ingroup Auth + * @since 1.27 + */ +class AuthenticationResponse { + /** Indicates that the authentication succeeded. */ + const PASS = 'PASS'; + + /** Indicates that the authentication failed. */ + const FAIL = 'FAIL'; + + /** Indicates that third-party authentication succeeded but no user exists. + * Either treat this like a UI response or pass $this->createRequest to + * AuthManager::beginCreateAccount(). For use by AuthManager only (providers + * should just return a PASS with no username). + */ + const RESTART = 'RESTART'; + + /** Indicates that the authentication provider does not handle this request. */ + const ABSTAIN = 'ABSTAIN'; + + /** Indicates that the authentication needs further user input of some sort. */ + const UI = 'UI'; + + /** Indicates that the authentication needs to be redirected to a third party to proceed. */ + const REDIRECT = 'REDIRECT'; + + /** @var string One of the constants above */ + public $status; + + /** @var string|null URL to redirect to for a REDIRECT response */ + public $redirectTarget = null; + + /** + * @var mixed Data for a REDIRECT response that a client might use to + * query the remote site via its API rather than by following $redirectTarget. + * Value must be something acceptable to ApiResult::addValue(). + */ + public $redirectApiData = null; + + /** + * @var AuthenticationRequest[] Needed AuthenticationRequests to continue + * after a UI or REDIRECT response. This plays the same role when continuing + * authentication as AuthManager::getAuthenticationRequests() does when + * beginning it. + */ + public $neededRequests = []; + + /** @var Message|null I18n message to display in case of UI or FAIL */ + public $message = null; + + /** @var string Whether the $message is an error or warning message, for styling reasons */ + public $messageType = 'warning'; + + /** + * @var string|null Local user name from authentication. + * May be null if the authentication passed but no local user is known. + */ + public $username = null; + + /** + * @var AuthenticationRequest|null + * + * Returned with a PrimaryAuthenticationProvider login FAIL or a PASS with + * no username, this can be set to a request that should result in a PASS when + * passed to that provider's PrimaryAuthenticationProvider::beginPrimaryAccountCreation(). + * The client will be able to send that back for expedited account creation where only + * the username needs to be filled. + * + * Returned with an AuthManager login FAIL or RESTART, this holds a + * CreateFromLoginAuthenticationRequest that may be passed to + * AuthManager::beginCreateAccount(), possibly in place of any + * "primary-required" requests. It may also be passed to + * AuthManager::beginAuthentication() to preserve the list of + * accounts which can be linked after success (see $linkRequest). + */ + public $createRequest = null; + + /** + * @var AuthenticationRequest|null When returned with a PrimaryAuthenticationProvider + * login PASS with no username, the request this holds will be passed to + * AuthManager::changeAuthenticationData() once the local user has been determined and the + * user has confirmed the account ownership (by reviewing the information given by + * $linkRequest->describeCredentials()). The provider should handle that + * changeAuthenticationData() call by doing the actual linking. + */ + public $linkRequest = null; + + /** + * @var AuthenticationRequest|null Returned with an AuthManager account + * creation PASS, this holds a request to pass to AuthManager::beginAuthentication() + * to immediately log into the created account. All provider methods except + * postAuthentication will be skipped. + */ + public $loginRequest = null; + + /** + * @param string|null $username Local username + * @return AuthenticationResponse + * @see AuthenticationResponse::PASS + */ + public static function newPass( $username = null ) { + $ret = new AuthenticationResponse; + $ret->status = self::PASS; + $ret->username = $username; + return $ret; + } + + /** + * @param Message $msg + * @return AuthenticationResponse + * @see AuthenticationResponse::FAIL + */ + public static function newFail( Message $msg ) { + $ret = new AuthenticationResponse; + $ret->status = self::FAIL; + $ret->message = $msg; + $ret->messageType = 'error'; + return $ret; + } + + /** + * @param Message $msg + * @return AuthenticationResponse + * @see AuthenticationResponse::RESTART + */ + public static function newRestart( Message $msg ) { + $ret = new AuthenticationResponse; + $ret->status = self::RESTART; + $ret->message = $msg; + return $ret; + } + + /** + * @return AuthenticationResponse + * @see AuthenticationResponse::ABSTAIN + */ + public static function newAbstain() { + $ret = new AuthenticationResponse; + $ret->status = self::ABSTAIN; + return $ret; + } + + /** + * @param AuthenticationRequest[] $reqs AuthenticationRequests needed to continue + * @param Message $msg + * @param string $msgtype + * @return AuthenticationResponse + * @see AuthenticationResponse::UI + */ + public static function newUI( array $reqs, Message $msg, $msgtype = 'warning' ) { + if ( !$reqs ) { + throw new \InvalidArgumentException( '$reqs may not be empty' ); + } + if ( $msgtype !== 'warning' && $msgtype !== 'error' ) { + throw new \InvalidArgumentException( $msgtype . ' is not a valid message type.' ); + } + + $ret = new AuthenticationResponse; + $ret->status = self::UI; + $ret->neededRequests = $reqs; + $ret->message = $msg; + $ret->messageType = $msgtype; + return $ret; + } + + /** + * @param AuthenticationRequest[] $reqs AuthenticationRequests needed to continue + * @param string $redirectTarget URL + * @param mixed $redirectApiData Data suitable for adding to an ApiResult + * @return AuthenticationResponse + * @see AuthenticationResponse::REDIRECT + */ + public static function newRedirect( array $reqs, $redirectTarget, $redirectApiData = null ) { + if ( !$reqs ) { + throw new \InvalidArgumentException( '$reqs may not be empty' ); + } + + $ret = new AuthenticationResponse; + $ret->status = self::REDIRECT; + $ret->neededRequests = $reqs; + $ret->redirectTarget = $redirectTarget; + $ret->redirectApiData = $redirectApiData; + return $ret; + } + +} diff --git a/www/wiki/includes/auth/ButtonAuthenticationRequest.php b/www/wiki/includes/auth/ButtonAuthenticationRequest.php new file mode 100644 index 00000000..d274e18f --- /dev/null +++ b/www/wiki/includes/auth/ButtonAuthenticationRequest.php @@ -0,0 +1,108 @@ +<?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 Message; + +/** + * This is an authentication request that just implements a simple button. + * @ingroup Auth + * @since 1.27 + */ +class ButtonAuthenticationRequest extends AuthenticationRequest { + /** @var string */ + protected $name; + + /** @var Message */ + protected $label; + + /** @var Message */ + protected $help; + + /** + * @param string $name Button name + * @param Message $label Button label + * @param Message $help Button help + * @param bool $required The button is required for authentication to proceed. + */ + public function __construct( $name, Message $label, Message $help, $required = false ) { + $this->name = $name; + $this->label = $label; + $this->help = $help; + $this->required = $required ? self::REQUIRED : self::OPTIONAL; + } + + public function getUniqueId() { + return parent::getUniqueId() . ':' . $this->name; + } + + public function getFieldInfo() { + return [ + $this->name => [ + 'type' => 'button', + 'label' => $this->label, + 'help' => $this->help, + ] + ]; + } + + /** + * Fetch a ButtonAuthenticationRequest or subclass by name + * @param AuthenticationRequest[] $reqs Requests to search + * @param string $name Name to look for + * @return ButtonAuthenticationRequest|null Returns null if there is not + * exactly one matching request. + */ + public static function getRequestByName( array $reqs, $name ) { + $requests = array_filter( $reqs, function ( $req ) use ( $name ) { + return $req instanceof ButtonAuthenticationRequest && $req->name === $name; + } ); + return count( $requests ) === 1 ? reset( $requests ) : null; + } + + /** + * @codeCoverageIgnore + * @param array $data + * @return AuthenticationRequest|static + */ + public static function __set_state( $data ) { + if ( !isset( $data['label'] ) ) { + $data['label'] = new \RawMessage( '$1', $data['name'] ); + } elseif ( is_string( $data['label'] ) ) { + $data['label'] = new \Message( $data['label'] ); + } elseif ( is_array( $data['label'] ) ) { + $data['label'] = call_user_func_array( 'Message::newFromKey', $data['label'] ); + } + if ( !isset( $data['help'] ) ) { + $data['help'] = new \RawMessage( '$1', $data['name'] ); + } elseif ( is_string( $data['help'] ) ) { + $data['help'] = new \Message( $data['help'] ); + } elseif ( is_array( $data['help'] ) ) { + $data['help'] = call_user_func_array( 'Message::newFromKey', $data['help'] ); + } + $ret = new static( $data['name'], $data['label'], $data['help'] ); + foreach ( $data as $k => $v ) { + $ret->$k = $v; + } + return $ret; + } +} diff --git a/www/wiki/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php new file mode 100644 index 00000000..7488fbaa --- /dev/null +++ b/www/wiki/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php @@ -0,0 +1,111 @@ +<?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 Config; +use StatusValue; + +/** + * Check if the user is blocked, and prevent authentication if so. + * + * @ingroup Auth + * @since 1.27 + */ +class CheckBlocksSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider { + + /** @var bool */ + protected $blockDisablesLogin = null; + + /** + * @param array $params + * - blockDisablesLogin: (bool) Whether blocked accounts can log in, + * defaults to $wgBlockDisablesLogin + */ + public function __construct( $params = [] ) { + if ( isset( $params['blockDisablesLogin'] ) ) { + $this->blockDisablesLogin = (bool)$params['blockDisablesLogin']; + } + } + + public function setConfig( Config $config ) { + parent::setConfig( $config ); + + if ( $this->blockDisablesLogin === null ) { + $this->blockDisablesLogin = $this->config->get( 'BlockDisablesLogin' ); + } + } + + public function getAuthenticationRequests( $action, array $options ) { + return []; + } + + public function beginSecondaryAuthentication( $user, array $reqs ) { + if ( !$this->blockDisablesLogin ) { + return AuthenticationResponse::newAbstain(); + } elseif ( $user->isBlocked() ) { + return AuthenticationResponse::newFail( + new \Message( 'login-userblocked', [ $user->getName() ] ) + ); + } else { + return AuthenticationResponse::newPass(); + } + } + + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) { + return AuthenticationResponse::newAbstain(); + } + + public function testUserForCreation( $user, $autocreate, array $options = [] ) { + $block = $user->isBlockedFromCreateAccount(); + if ( $block ) { + if ( $block->mReason ) { + $reason = $block->mReason; + } else { + $msg = \Message::newFromKey( 'blockednoreason' ); + if ( !\RequestContext::getMain()->getUser()->isSafeToLoad() ) { + $msg->inContentLanguage(); + } + $reason = $msg->text(); + } + + $errorParams = [ + $block->getTarget(), + $reason, + $block->getByName() + ]; + + if ( $block->getType() === \Block::TYPE_RANGE ) { + $errorMessage = 'cantcreateaccount-range-text'; + $errorParams[] = $this->manager->getRequest()->getIP(); + } else { + $errorMessage = 'cantcreateaccount-text'; + } + + return StatusValue::newFatal( + new \Message( $errorMessage, $errorParams ) + ); + } else { + return StatusValue::newGood(); + } + } + +} diff --git a/www/wiki/includes/auth/ConfirmLinkAuthenticationRequest.php b/www/wiki/includes/auth/ConfirmLinkAuthenticationRequest.php new file mode 100644 index 00000000..b82914f5 --- /dev/null +++ b/www/wiki/includes/auth/ConfirmLinkAuthenticationRequest.php @@ -0,0 +1,80 @@ +<?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; + +class ConfirmLinkAuthenticationRequest extends AuthenticationRequest { + /** @var AuthenticationRequest[] */ + protected $linkRequests; + + /** @var string[] List of unique IDs of the confirmed accounts. */ + public $confirmedLinkIDs = []; + + /** + * @param AuthenticationRequest[] $linkRequests A list of autolink requests + * which need to be confirmed. + */ + public function __construct( array $linkRequests ) { + if ( !$linkRequests ) { + throw new \InvalidArgumentException( '$linkRequests must not be empty' ); + } + $this->linkRequests = $linkRequests; + } + + public function getFieldInfo() { + $options = []; + foreach ( $this->linkRequests as $req ) { + $description = $req->describeCredentials(); + $options[$req->getUniqueId()] = wfMessage( + 'authprovider-confirmlink-option', + $description['provider']->text(), $description['account']->text() + ); + } + return [ + 'confirmedLinkIDs' => [ + 'type' => 'multiselect', + 'options' => $options, + 'label' => wfMessage( 'authprovider-confirmlink-request-label' ), + 'help' => wfMessage( 'authprovider-confirmlink-request-help' ), + 'optional' => true, + ] + ]; + } + + public function getUniqueId() { + return parent::getUniqueId() . ':' . implode( '|', array_map( function ( $req ) { + return $req->getUniqueId(); + }, $this->linkRequests ) ); + } + + /** + * Implementing this mainly for use from the unit tests. + * @param array $data + * @return AuthenticationRequest + */ + public static function __set_state( $data ) { + $ret = new static( $data['linkRequests'] ); + foreach ( $data as $k => $v ) { + $ret->$k = $v; + } + return $ret; + } +} diff --git a/www/wiki/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php new file mode 100644 index 00000000..7f121cde --- /dev/null +++ b/www/wiki/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php @@ -0,0 +1,158 @@ +<?php + +namespace MediaWiki\Auth; + +use User; + +/** + * Links third-party authentication to the user's account + * + * If the user logged into linking provider accounts that aren't linked to a + * local user, this provider will prompt the user to link them after a + * successful login or account creation. + * + * To avoid confusing behavior, this provider should be later in the + * configuration list than any provider that can abort the authentication + * process, so that it is only invoked for successful authentication. + */ +class ConfirmLinkSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider { + + public function getAuthenticationRequests( $action, array $options ) { + return []; + } + + public function beginSecondaryAuthentication( $user, array $reqs ) { + return $this->beginLinkAttempt( $user, 'AuthManager::authnState' ); + } + + public function continueSecondaryAuthentication( $user, array $reqs ) { + return $this->continueLinkAttempt( $user, 'AuthManager::authnState', $reqs ); + } + + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->beginLinkAttempt( $user, 'AuthManager::accountCreationState' ); + } + + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->continueLinkAttempt( $user, 'AuthManager::accountCreationState', $reqs ); + } + + /** + * Begin the link attempt + * @param User $user + * @param string $key Session key to look in + * @return AuthenticationResponse + */ + protected function beginLinkAttempt( $user, $key ) { + $session = $this->manager->getRequest()->getSession(); + $state = $session->getSecret( $key ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newAbstain(); + } + + $maybeLink = array_filter( $state['maybeLink'], function ( $req ) use ( $user ) { + if ( !$req->action ) { + $req->action = AuthManager::ACTION_CHANGE; + } + $req->username = $user->getName(); + return $this->manager->allowsAuthenticationDataChange( $req )->isGood(); + } ); + if ( !$maybeLink ) { + return AuthenticationResponse::newAbstain(); + } + + $req = new ConfirmLinkAuthenticationRequest( $maybeLink ); + return AuthenticationResponse::newUI( + [ $req ], + wfMessage( 'authprovider-confirmlink-message' ), + 'warning' + ); + } + + /** + * Continue the link attempt + * @param User $user + * @param string $key Session key to look in + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + protected function continueLinkAttempt( $user, $key, array $reqs ) { + $req = ButtonAuthenticationRequest::getRequestByName( $reqs, 'linkOk' ); + if ( $req ) { + return AuthenticationResponse::newPass(); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, ConfirmLinkAuthenticationRequest::class ); + if ( !$req ) { + // WTF? Retry. + return $this->beginLinkAttempt( $user, $key ); + } + + $session = $this->manager->getRequest()->getSession(); + $state = $session->getSecret( $key ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newAbstain(); + } + + $maybeLink = []; + foreach ( $state['maybeLink'] as $linkReq ) { + $maybeLink[$linkReq->getUniqueId()] = $linkReq; + } + if ( !$maybeLink ) { + return AuthenticationResponse::newAbstain(); + } + + $state['maybeLink'] = []; + $session->setSecret( $key, $state ); + + $statuses = []; + $anyFailed = false; + foreach ( $req->confirmedLinkIDs as $id ) { + if ( isset( $maybeLink[$id] ) ) { + $req = $maybeLink[$id]; + $req->username = $user->getName(); + if ( !$req->action ) { + // Make sure the action is set, but don't override it if + // the provider filled it in. + $req->action = AuthManager::ACTION_CHANGE; + } + $status = $this->manager->allowsAuthenticationDataChange( $req ); + $statuses[] = [ $req, $status ]; + if ( $status->isGood() ) { + $this->manager->changeAuthenticationData( $req ); + } else { + $anyFailed = true; + } + } + } + if ( !$anyFailed ) { + return AuthenticationResponse::newPass(); + } + + $combinedStatus = \Status::newGood(); + foreach ( $statuses as $data ) { + list( $req, $status ) = $data; + $descriptionInfo = $req->describeCredentials(); + $description = wfMessage( + 'authprovider-confirmlink-option', + $descriptionInfo['provider']->text(), $descriptionInfo['account']->text() + )->text(); + if ( $status->isGood() ) { + $combinedStatus->error( wfMessage( 'authprovider-confirmlink-success-line', $description ) ); + } else { + $combinedStatus->error( wfMessage( + 'authprovider-confirmlink-failed-line', $description, $status->getMessage()->text() + ) ); + } + } + return AuthenticationResponse::newUI( + [ + new ButtonAuthenticationRequest( + 'linkOk', wfMessage( 'ok' ), wfMessage( 'authprovider-confirmlink-ok-help' ) + ) + ], + $combinedStatus->getMessage( 'authprovider-confirmlink-failed' ), + 'error' + ); + } +} diff --git a/www/wiki/includes/auth/CreateFromLoginAuthenticationRequest.php b/www/wiki/includes/auth/CreateFromLoginAuthenticationRequest.php new file mode 100644 index 00000000..db827972 --- /dev/null +++ b/www/wiki/includes/auth/CreateFromLoginAuthenticationRequest.php @@ -0,0 +1,96 @@ +<?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; + +/** + * This transfers state between the login and account creation flows. + * + * AuthManager::getAuthenticationRequests() won't return this type, but it + * may be passed to AuthManager::beginAuthentication() or + * AuthManager::beginAccountCreation() anyway. + * + * @ingroup Auth + * @since 1.27 + */ +class CreateFromLoginAuthenticationRequest extends AuthenticationRequest { + public $required = self::OPTIONAL; + + /** @var AuthenticationRequest|null */ + public $createRequest; + + /** @var AuthenticationRequest[] */ + public $maybeLink = []; + + /** + * @param AuthenticationRequest|null $createRequest A request to use to + * begin creating the account + * @param AuthenticationRequest[] $maybeLink Additional accounts to link + * after creation. + */ + public function __construct( + AuthenticationRequest $createRequest = null, array $maybeLink = [] + ) { + $this->createRequest = $createRequest; + $this->maybeLink = $maybeLink; + $this->username = $createRequest ? $createRequest->username : null; + } + + public function getFieldInfo() { + return []; + } + + public function loadFromSubmission( array $data ) { + return true; + } + + /** + * Indicate whether this request contains any state for the specified + * action. + * @param string $action One of the AuthManager::ACTION_* constants + * @return bool + */ + public function hasStateForAction( $action ) { + switch ( $action ) { + case AuthManager::ACTION_LOGIN: + return (bool)$this->maybeLink; + case AuthManager::ACTION_CREATE: + return $this->maybeLink || $this->createRequest; + default: + return false; + } + } + + /** + * Indicate whether this request contains state for the specified + * action sufficient to replace other primary-required requests. + * @param string $action One of the AuthManager::ACTION_* constants + * @return bool + */ + public function hasPrimaryStateForAction( $action ) { + switch ( $action ) { + case AuthManager::ACTION_CREATE: + return (bool)$this->createRequest; + default: + return false; + } + } +} diff --git a/www/wiki/includes/auth/CreatedAccountAuthenticationRequest.php b/www/wiki/includes/auth/CreatedAccountAuthenticationRequest.php new file mode 100644 index 00000000..48a6e1d3 --- /dev/null +++ b/www/wiki/includes/auth/CreatedAccountAuthenticationRequest.php @@ -0,0 +1,48 @@ +<?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; + +/** + * Returned from account creation to allow for logging into the created account + * @ingroup Auth + * @since 1.27 + */ +class CreatedAccountAuthenticationRequest extends AuthenticationRequest { + + public $required = self::OPTIONAL; + + /** @var int User id */ + public $id; + + public function getFieldInfo() { + return []; + } + + /** + * @param int $id User id + * @param string $name Username + */ + public function __construct( $id, $name ) { + $this->id = $id; + $this->username = $name; + } +} diff --git a/www/wiki/includes/auth/CreationReasonAuthenticationRequest.php b/www/wiki/includes/auth/CreationReasonAuthenticationRequest.php new file mode 100644 index 00000000..146470ed --- /dev/null +++ b/www/wiki/includes/auth/CreationReasonAuthenticationRequest.php @@ -0,0 +1,24 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * Authentication request for the reason given for account creation. + * Used in logs and for notification. + */ +class CreationReasonAuthenticationRequest extends AuthenticationRequest { + /** @var string Account creation reason (only used when creating for someone else) */ + public $reason; + + public $required = self::OPTIONAL; + + public function getFieldInfo() { + return [ + 'reason' => [ + 'type' => 'string', + 'label' => wfMessage( 'createacct-reason' ), + 'help' => wfMessage( 'createacct-reason-help' ), + ], + ]; + } +} diff --git a/www/wiki/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php new file mode 100644 index 00000000..a4855318 --- /dev/null +++ b/www/wiki/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php @@ -0,0 +1,70 @@ +<?php + +namespace MediaWiki\Auth; + +use Config; + +/** + * Handles email notification / email address confirmation for account creation. + * + * Set 'no-email' to true (via AuthManager::setAuthenticationSessionData) to skip this provider. + * Primary providers doing so are expected to take care of email address confirmation. + */ +class EmailNotificationSecondaryAuthenticationProvider + extends AbstractSecondaryAuthenticationProvider +{ + /** @var bool */ + protected $sendConfirmationEmail; + + /** + * @param array $params + * - sendConfirmationEmail: (bool) send an email asking the user to confirm their email + * address after a successful registration + */ + public function __construct( $params = [] ) { + if ( isset( $params['sendConfirmationEmail'] ) ) { + $this->sendConfirmationEmail = (bool)$params['sendConfirmationEmail']; + } + } + + public function setConfig( Config $config ) { + parent::setConfig( $config ); + + if ( $this->sendConfirmationEmail === null ) { + $this->sendConfirmationEmail = $this->config->get( 'EnableEmail' ) + && $this->config->get( 'EmailAuthentication' ); + } + } + + public function getAuthenticationRequests( $action, array $options ) { + return []; + } + + public function beginSecondaryAuthentication( $user, array $reqs ) { + return AuthenticationResponse::newAbstain(); + } + + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) { + if ( + $this->sendConfirmationEmail + && $user->getEmail() + && !$this->manager->getAuthenticationSessionData( 'no-email' ) + ) { + // TODO show 'confirmemail_oncreate'/'confirmemail_sendfailed' message + wfGetDB( DB_MASTER )->onTransactionIdle( + function () use ( $user ) { + $user = $user->getInstanceForUpdate(); + $status = $user->sendConfirmationMail(); + $user->saveSettings(); + if ( !$status->isGood() ) { + $this->logger->warning( 'Could not send confirmation email: ' . + $status->getWikiText( false, false, 'en' ) ); + } + }, + __METHOD__ + ); + } + + return AuthenticationResponse::newPass(); + } +} diff --git a/www/wiki/includes/auth/LegacyHookPreAuthenticationProvider.php b/www/wiki/includes/auth/LegacyHookPreAuthenticationProvider.php new file mode 100644 index 00000000..95fe3ab8 --- /dev/null +++ b/www/wiki/includes/auth/LegacyHookPreAuthenticationProvider.php @@ -0,0 +1,180 @@ +<?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 LoginForm; +use StatusValue; +use User; + +/** + * A pre-authentication provider to call some legacy hooks. + * @ingroup Auth + * @since 1.27 + * @deprecated since 1.27 + */ +class LegacyHookPreAuthenticationProvider extends AbstractPreAuthenticationProvider { + + public function testForAuthentication( array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + if ( $req ) { + $user = User::newFromName( $req->username ); + $password = $req->password; + } else { + $user = null; + foreach ( $reqs as $req ) { + if ( $req->username !== null ) { + $user = User::newFromName( $req->username ); + break; + } + } + if ( !$user ) { + $this->logger->debug( __METHOD__ . ': No username in $reqs, skipping hooks' ); + return StatusValue::newGood(); + } + + // Something random for the 'AbortLogin' hook. + $password = wfRandomString( 32 ); + } + + $msg = null; + if ( !\Hooks::run( 'LoginUserMigrated', [ $user, &$msg ] ) ) { + return $this->makeFailResponse( + $user, LoginForm::USER_MIGRATED, $msg, 'LoginUserMigrated' + ); + } + + $abort = LoginForm::ABORTED; + $msg = null; + if ( !\Hooks::run( 'AbortLogin', [ $user, $password, &$abort, &$msg ] ) ) { + return $this->makeFailResponse( $user, $abort, $msg, 'AbortLogin' ); + } + + return StatusValue::newGood(); + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + $abortError = ''; + $abortStatus = null; + if ( !\Hooks::run( 'AbortNewAccount', [ $user, &$abortError, &$abortStatus ] ) ) { + // Hook point to add extra creation throttles and blocks + $this->logger->debug( __METHOD__ . ': a hook blocked creation' ); + if ( $abortStatus === null ) { + // Report back the old string as a raw message status. + // This will report the error back as 'createaccount-hook-aborted' + // with the given string as the message. + // To return a different error code, return a StatusValue object. + $msg = wfMessage( 'createaccount-hook-aborted' )->rawParams( $abortError ); + return StatusValue::newFatal( $msg ); + } else { + // For MediaWiki 1.23+ and updated hooks, return the Status object + // returned from the hook. + $ret = StatusValue::newGood(); + $ret->merge( $abortStatus ); + return $ret; + } + } + + return StatusValue::newGood(); + } + + public function testUserForCreation( $user, $autocreate, array $options = [] ) { + if ( $autocreate !== false ) { + $abortError = ''; + if ( !\Hooks::run( 'AbortAutoAccount', [ $user, &$abortError ] ) ) { + // Hook point to add extra creation throttles and blocks + $this->logger->debug( __METHOD__ . ": a hook blocked auto-creation: $abortError\n" ); + return $this->makeFailResponse( + $user, LoginForm::ABORTED, $abortError, 'AbortAutoAccount' + ); + } + } + + return StatusValue::newGood(); + } + + /** + * Construct an appropriate failure response + * @param User $user + * @param int $constant One of the LoginForm::… constants + * @param string|null $msg Optional message key, will be derived from $constant otherwise + * @param string $hook Name of the hook for error logging and exception messages + * @return StatusValue + */ + private function makeFailResponse( User $user, $constant, $msg, $hook ) { + switch ( $constant ) { + case LoginForm::SUCCESS: + // WTF? + $this->logger->debug( "$hook is SUCCESS?!" ); + return StatusValue::newGood(); + + case LoginForm::NEED_TOKEN: + return StatusValue::newFatal( $msg ?: 'nocookiesforlogin' ); + + case LoginForm::WRONG_TOKEN: + return StatusValue::newFatal( $msg ?: 'sessionfailure' ); + + case LoginForm::NO_NAME: + case LoginForm::ILLEGAL: + return StatusValue::newFatal( $msg ?: 'noname' ); + + case LoginForm::WRONG_PLUGIN_PASS: + case LoginForm::WRONG_PASS: + return StatusValue::newFatal( $msg ?: 'wrongpassword' ); + + case LoginForm::NOT_EXISTS: + return StatusValue::newFatal( $msg ?: 'nosuchusershort', wfEscapeWikiText( $user->getName() ) ); + + case LoginForm::EMPTY_PASS: + return StatusValue::newFatal( $msg ?: 'wrongpasswordempty' ); + + case LoginForm::RESET_PASS: + return StatusValue::newFatal( $msg ?: 'resetpass_announce' ); + + case LoginForm::THROTTLED: + $throttle = $this->config->get( 'PasswordAttemptThrottle' ); + return StatusValue::newFatal( + $msg ?: 'login-throttled', + \Message::durationParam( $throttle['seconds'] ) + ); + + case LoginForm::USER_BLOCKED: + return StatusValue::newFatal( + $msg ?: 'login-userblocked', wfEscapeWikiText( $user->getName() ) + ); + + case LoginForm::ABORTED: + return StatusValue::newFatal( + $msg ?: 'login-abort-generic', wfEscapeWikiText( $user->getName() ) + ); + + case LoginForm::USER_MIGRATED: + $error = $msg ?: 'login-migrated-generic'; + return call_user_func_array( 'StatusValue::newFatal', (array)$error ); + + // @codeCoverageIgnoreStart + case LoginForm::CREATE_BLOCKED: // Can never happen + default: + throw new \DomainException( __METHOD__ . ": Unhandled case value from $hook" ); + } + // @codeCoverageIgnoreEnd + } +} diff --git a/www/wiki/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 00000000..86a6aae0 --- /dev/null +++ b/www/wiki/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,324 @@ +<?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 User; + +/** + * A primary authentication provider that uses the password field in the 'user' table. + * @ingroup Auth + * @since 1.27 + */ +class LocalPasswordPrimaryAuthenticationProvider + extends AbstractPasswordPrimaryAuthenticationProvider +{ + + /** @var bool If true, this instance is for legacy logins only. */ + protected $loginOnly = false; + + /** + * @param array $params Settings + * - loginOnly: If true, the local passwords are for legacy logins only: + * the local password will be invalidated when authentication is changed + * and new users will not have a valid local password set. + */ + public function __construct( $params = [] ) { + parent::__construct( $params ); + $this->loginOnly = !empty( $params['loginOnly'] ); + } + + protected function getPasswordResetData( $username, $row ) { + $now = wfTimestamp(); + $expiration = wfTimestampOrNull( TS_UNIX, $row->user_password_expires ); + if ( $expiration === null || $expiration >= $now ) { + return null; + } + + $grace = $this->config->get( 'PasswordExpireGrace' ); + if ( $expiration + $grace < $now ) { + $data = [ + 'hard' => true, + 'msg' => \Status::newFatal( 'resetpass-expired' )->getMessage(), + ]; + } else { + $data = [ + 'hard' => false, + 'msg' => \Status::newFatal( 'resetpass-expired-soft' )->getMessage(), + ]; + } + + return (object)$data; + } + + public function beginPrimaryAuthentication( array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + if ( !$req ) { + return AuthenticationResponse::newAbstain(); + } + + if ( $req->username === null || $req->password === null ) { + return AuthenticationResponse::newAbstain(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return AuthenticationResponse::newAbstain(); + } + + $fields = [ + 'user_id', 'user_password', 'user_password_expires', + ]; + + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'user', + $fields, + [ 'user_name' => $username ], + __METHOD__ + ); + if ( !$row ) { + // Do not reveal whether its bad username or + // bad password to prevent username enumeration + // on private wikis. (T134100) + return $this->failResponse( $req ); + } + + $oldRow = clone $row; + // Check for *really* old password hashes that don't even have a type + // The old hash format was just an md5 hex hash, with no type information + if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { + if ( $this->config->get( 'PasswordSalt' ) ) { + $row->user_password = ":B:{$row->user_id}:{$row->user_password}"; + } else { + $row->user_password = ":A:{$row->user_password}"; + } + } + + $status = $this->checkPasswordValidity( $username, $req->password ); + if ( !$status->isOK() ) { + // Fatal, can't log in + return AuthenticationResponse::newFail( $status->getMessage() ); + } + + $pwhash = $this->getPassword( $row->user_password ); + if ( !$pwhash->equals( $req->password ) ) { + if ( $this->config->get( 'LegacyEncoding' ) ) { + // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted + // Check for this with iconv + $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $req->password ); + if ( $cp1252Password === $req->password || !$pwhash->equals( $cp1252Password ) ) { + return $this->failResponse( $req ); + } + } else { + return $this->failResponse( $req ); + } + } + + // @codeCoverageIgnoreStart + if ( $this->getPasswordFactory()->needsUpdate( $pwhash ) ) { + $newHash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); + \DeferredUpdates::addCallableUpdate( function () use ( $newHash, $oldRow ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_password' => $newHash->toString() ], + [ + 'user_id' => $oldRow->user_id, + 'user_password' => $oldRow->user_password + ], + __METHOD__ + ); + } ); + } + // @codeCoverageIgnoreEnd + + $this->setPasswordResetFlag( $username, $status, $row ); + + 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_password' ], + [ 'user_name' => $username ], + __METHOD__ + ); + if ( !$row ) { + return false; + } + + // Check for *really* old password hashes that don't even have a type + // The old hash format was just an md5 hex hash, with no type information + if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { + return true; + } + + return !$this->getPassword( $row->user_password ) instanceof \InvalidPassword; + } + + 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 + ) { + // We only want to blank the password if something else will accept the + // new authentication data, so return 'ignore' here. + if ( $this->loginOnly ) { + return \StatusValue::newGood( 'ignored' ); + } + + if ( get_class( $req ) === PasswordAuthenticationRequest::class ) { + if ( !$checkData ) { + return \StatusValue::newGood(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username !== false ) { + $row = wfGetDB( DB_MASTER )->selectRow( + 'user', + [ 'user_id' ], + [ 'user_name' => $username ], + __METHOD__ + ); + if ( $row ) { + $sv = \StatusValue::newGood(); + if ( $req->password !== null ) { + if ( $req->password !== $req->retype ) { + $sv->fatal( 'badretype' ); + } else { + $sv->merge( $this->checkPasswordValidity( $username, $req->password ) ); + } + } + return $sv; + } + } + } + + return \StatusValue::newGood( 'ignored' ); + } + + public function providerChangeAuthenticationData( AuthenticationRequest $req ) { + $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false; + if ( $username === false ) { + return; + } + + $pwhash = null; + + if ( get_class( $req ) === PasswordAuthenticationRequest::class ) { + if ( $this->loginOnly ) { + $pwhash = $this->getPasswordFactory()->newFromCiphertext( null ); + $expiry = null; + } else { + $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); + $expiry = $this->getNewPasswordExpiry( $username ); + } + } + + if ( $pwhash ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ + 'user_password' => $pwhash->toString(), + 'user_password_expires' => $dbw->timestampOrNull( $expiry ), + ], + [ 'user_name' => $username ], + __METHOD__ + ); + } + } + + public function accountCreationType() { + return $this->loginOnly ? self::TYPE_NONE : self::TYPE_CREATE; + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + + $ret = \StatusValue::newGood(); + if ( !$this->loginOnly && $req && $req->username !== null && $req->password !== null ) { + if ( $req->password !== $req->retype ) { + $ret->fatal( 'badretype' ); + } else { + $ret->merge( + $this->checkPasswordValidity( $user->getName(), $req->password ) + ); + } + } + return $ret; + } + + public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) { + if ( $this->accountCreationType() === self::TYPE_NONE ) { + throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + if ( $req ) { + if ( $req->username !== null && $req->password !== null ) { + // Nothing we can do besides claim it, because the user isn't in + // the DB yet + if ( $req->username !== $user->getName() ) { + $req = clone $req; + $req->username = $user->getName(); + } + $ret = AuthenticationResponse::newPass( $req->username ); + $ret->createRequest = $req; + return $ret; + } + } + return AuthenticationResponse::newAbstain(); + } + + public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) { + if ( $this->accountCreationType() === self::TYPE_NONE ) { + throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); + } + + // Now that the user is in the DB, set the password on it. + $this->providerChangeAuthenticationData( $res->createRequest ); + + return null; + } +} diff --git a/www/wiki/includes/auth/PasswordAuthenticationRequest.php b/www/wiki/includes/auth/PasswordAuthenticationRequest.php new file mode 100644 index 00000000..8550f3e2 --- /dev/null +++ b/www/wiki/includes/auth/PasswordAuthenticationRequest.php @@ -0,0 +1,85 @@ +<?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; + +/** + * This is a value object for authentication requests with a username and password + * @ingroup Auth + * @since 1.27 + */ +class PasswordAuthenticationRequest extends AuthenticationRequest { + /** @var string Password */ + public $password = null; + + /** @var string Password, again */ + public $retype = null; + + public function getFieldInfo() { + if ( $this->action === AuthManager::ACTION_REMOVE ) { + return []; + } + + // for password change it's nice to make extra clear that we are asking for the new password + $forNewPassword = $this->action === AuthManager::ACTION_CHANGE; + $passwordLabel = $forNewPassword ? 'newpassword' : 'userlogin-yourpassword'; + $retypeLabel = $forNewPassword ? 'retypenew' : 'yourpasswordagain'; + + $ret = [ + 'username' => [ + 'type' => 'string', + 'label' => wfMessage( 'userlogin-yourname' ), + 'help' => wfMessage( 'authmanager-username-help' ), + ], + 'password' => [ + 'type' => 'password', + 'label' => wfMessage( $passwordLabel ), + 'help' => wfMessage( 'authmanager-password-help' ), + 'sensitive' => true, + ], + ]; + + switch ( $this->action ) { + case AuthManager::ACTION_CHANGE: + case AuthManager::ACTION_REMOVE: + unset( $ret['username'] ); + break; + } + + if ( $this->action !== AuthManager::ACTION_LOGIN ) { + $ret['retype'] = [ + 'type' => 'password', + 'label' => wfMessage( $retypeLabel ), + 'help' => wfMessage( 'authmanager-retype-help' ), + 'sensitive' => true, + ]; + } + + return $ret; + } + + public function describeCredentials() { + return [ + 'provider' => wfMessage( 'authmanager-provider-password' ), + 'account' => new \RawMessage( '$1', [ $this->username ] ), + ]; + } +} diff --git a/www/wiki/includes/auth/PasswordDomainAuthenticationRequest.php b/www/wiki/includes/auth/PasswordDomainAuthenticationRequest.php new file mode 100644 index 00000000..3db7e212 --- /dev/null +++ b/www/wiki/includes/auth/PasswordDomainAuthenticationRequest.php @@ -0,0 +1,85 @@ +<?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; + +/** + * This is a value object for authentication requests with a username, password, and domain + * @ingroup Auth + * @since 1.27 + */ +class PasswordDomainAuthenticationRequest extends PasswordAuthenticationRequest { + /** @var string[] Domains available */ + private $domainList; + + /** @var string Domain */ + public $domain = null; + + /** + * @param string[] $domainList List of available domains + */ + public function __construct( array $domainList ) { + $this->domainList = $domainList; + } + + public function getFieldInfo() { + $ret = parent::getFieldInfo(); + + // Only add a domain field if we have the username field included + if ( isset( $ret['username'] ) ) { + $ret['domain'] = [ + 'type' => 'select', + 'options' => [], + 'label' => wfMessage( 'yourdomainname' ), + 'help' => wfMessage( 'authmanager-domain-help' ), + ]; + foreach ( $this->domainList as $domain ) { + $ret['domain']['options'][$domain] = new \RawMessage( '$1', [ $domain ] ); + } + } + + return $ret; + } + + public function describeCredentials() { + return [ + 'provider' => wfMessage( 'authmanager-provider-password-domain' ), + 'account' => wfMessage( + 'authmanager-account-password-domain', [ $this->username, $this->domain ] + ), + ]; + } + + /** + * @codeCoverageIgnore + * @param array $data + * @return AuthenticationRequest|static + */ + public static function __set_state( $data ) { + $ret = new static( $data['domainList'] ); + foreach ( $data as $k => $v ) { + if ( $k !== 'domainList' ) { + $ret->$k = $v; + } + } + return $ret; + } +} diff --git a/www/wiki/includes/auth/PreAuthenticationProvider.php b/www/wiki/includes/auth/PreAuthenticationProvider.php new file mode 100644 index 00000000..8590cbd1 --- /dev/null +++ b/www/wiki/includes/auth/PreAuthenticationProvider.php @@ -0,0 +1,148 @@ +<?php +/** + * Pre-authentication provider interface + * + * 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 StatusValue; +use User; + +/** + * A pre-authentication provider can prevent authentication early on. + * + * A PreAuthenticationProvider is used to supply arbitrary checks to be + * performed before the PrimaryAuthenticationProviders are consulted during the + * login / account creation / account linking process. Possible uses include + * checking that a per-IP throttle has not been reached or that a captcha has been solved. + * + * This interface also provides callbacks that are invoked after login / account creation + * / account linking succeeded or failed. + * + * @ingroup Auth + * @since 1.27 + * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager + */ +interface PreAuthenticationProvider extends AuthenticationProvider { + + /** + * Determine whether an authentication may begin + * + * Called from AuthManager::beginAuthentication() + * + * @param AuthenticationRequest[] $reqs + * @return StatusValue + */ + public function testForAuthentication( array $reqs ); + + /** + * Post-login callback + * + * This will be called at the end of a login attempt. It will not be called for unfinished + * login attempts that fail by the session timing out. + * + * @note Under certain circumstances, this can be called even when testForAuthentication + * was not; see AuthenticationRequest::$loginRequest. + * @param User|null $user User that was attempted to be logged in, if known. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAuthentication( $user, AuthenticationResponse $response ); + + /** + * Determine whether an account creation may begin + * + * Called from AuthManager::beginAccountCreation() + * + * @note No need to test if the account exists, AuthManager checks that + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return StatusValue + */ + public function testForAccountCreation( $user, $creator, array $reqs ); + + /** + * Determine whether an account may be created + * + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param bool|string $autocreate False if this is not an auto-creation, or + * the source of the auto-creation passed to AuthManager::autoCreateUser(). + * @param array $options + * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL + * - creating: (bool) If false (or missing), this call is only testing if + * a user could be created. If set, this (non-autocreation) is for + * actually creating an account and will be followed by a call to + * testForAccountCreation(). In this case, the provider might return + * StatusValue::newGood() here and let the later call to + * testForAccountCreation() do a more thorough test. + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate, array $options = [] ); + + /** + * Post-creation callback + * + * This will be called at the end of an account creation attempt. It will not be called if + * the account creation process results in a session timeout (possibly after a successful + * user creation, while a secondary provider is waiting for a response). + * + * @param User $user User that was attempted to be created. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ); + + /** + * Determine whether an account may linked to another authentication method + * + * @param User $user User being linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @return StatusValue + */ + public function testForAccountLink( $user ); + + /** + * Post-link callback + * + * This will be called at the end of an account linking attempt. + * + * @param User $user User that was attempted to be linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAccountLink( $user, AuthenticationResponse $response ); + +} diff --git a/www/wiki/includes/auth/PrimaryAuthenticationProvider.php b/www/wiki/includes/auth/PrimaryAuthenticationProvider.php new file mode 100644 index 00000000..5d82f899 --- /dev/null +++ b/www/wiki/includes/auth/PrimaryAuthenticationProvider.php @@ -0,0 +1,400 @@ +<?php +/** + * Primary authentication provider interface + * + * 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 StatusValue; +use User; + +/** + * A primary authentication provider is responsible for associating the submitted + * authentication data with a MediaWiki account. + * + * When multiple primary authentication providers are configured for a site, they + * act as alternatives; the first one that recognizes the data will handle it, + * and further primary providers are not called (although they all get a chance + * to prevent actions). + * + * For login, the PrimaryAuthenticationProvider takes form data and determines + * which authenticated user (if any) corresponds to that form data. It might + * do this on the basis of a username and password in that data, or by + * interacting with an external authentication service (e.g. using OpenID), + * or by some other mechanism. + * + * (A PrimaryAuthenticationProvider would not be appropriate for something like + * HTTP authentication, OAuth, or SSL client certificates where each HTTP + * request contains all the information needed to identify the user. In that + * case you'll want to be looking at a \MediaWiki\Session\SessionProvider + * instead.) + * + * For account creation, the PrimaryAuthenticationProvider takes form data and + * stores some authentication details which will allow it to verify a login by + * that user in the future. This might for example involve saving it in the + * database in a table that can be joined to the user table, or sending it to + * some external service for account creation, or authenticating the user with + * some remote service and then recording that the remote identity is linked to + * the local account. + * The creation of the local user (i.e. calling User::addToDatabase()) is handled + * by AuthManager once the primary authentication provider returns a PASS + * from begin/continueAccountCreation; do not try to do it yourself. + * + * For account linking, the PrimaryAuthenticationProvider verifies the user's + * identity at some external service (typically by redirecting the user and + * asking the external service to verify) and then records which local account + * is linked to which remote accounts. It should keep track of this and be able + * to enumerate linked accounts via getAuthenticationRequests(ACTION_REMOVE). + * + * This interface also provides methods for changing authentication data such + * as passwords, and callbacks that are invoked after login / account creation + * / account linking succeeded or failed. + * + * @ingroup Auth + * @since 1.27 + * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager + */ +interface PrimaryAuthenticationProvider extends AuthenticationProvider { + /** Provider can create accounts */ + const TYPE_CREATE = 'create'; + /** Provider can link to existing accounts elsewhere */ + const TYPE_LINK = 'link'; + /** Provider cannot create or link to accounts */ + const TYPE_NONE = 'none'; + + /** + * @inheritDoc + * + * Of the requests returned by this method, exactly one should have + * {@link AuthenticationRequest::$required} set to REQUIRED. + */ + public function getAuthenticationRequests( $action, array $options ); + + /** + * Start an authentication flow + * + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is authenticated. Secondary providers will now run. + * - FAIL: The user is not authenticated. Fail the authentication process. + * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it. + * - UI: The $reqs are accepted, no other primary provider will run. + * Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: The $reqs are accepted, no other primary provider will run. + * Redirection to a third party is needed to complete the process. + */ + public function beginPrimaryAuthentication( array $reqs ); + + /** + * Continue an authentication flow + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is authenticated. Secondary providers will now run. + * - FAIL: The user is not authenticated. Fail the authentication process. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continuePrimaryAuthentication( array $reqs ); + + /** + * Post-login callback + * + * This will be called at the end of any login attempt, regardless of whether this provider was + * the one that handled it. It will not be called for unfinished login attempts that fail by + * the session timing out. + * + * @param User|null $user User that was attempted to be logged in, if known. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAuthentication( $user, AuthenticationResponse $response ); + + /** + * Test whether the named user exists + * + * Single-sign-on providers can use this to reserve a username for autocreation. + * + * @param string $username MediaWiki username + * @param int $flags Bitfield of User:READ_* constants + * @return bool + */ + public function testUserExists( $username, $flags = User::READ_NORMAL ); + + /** + * Test whether the named user can authenticate with this provider + * + * Should return true if the provider has any data for this user which can be used to + * authenticate it, even if the user is temporarily prevented from authentication somehow. + * + * @param string $username MediaWiki username + * @return bool + */ + public function testUserCanAuthenticate( $username ); + + /** + * Normalize the username for authentication + * + * Any two inputs that would result in the same user being authenticated + * should return the same string here, while inputs that would result in + * different users should return different strings. + * + * If possible, the best thing to do here is to return the canonicalized + * name of the local user account that would be used. If not, return + * something that would be invalid as a local username (e.g. wrap an email + * address in "<>", or append "#servicename" to the username passed to a + * third-party service). + * + * If the provider doesn't use a username at all in its + * AuthenticationRequests, return null. If the name is syntactically + * invalid, it's probably best to return null. + * + * @param string $username + * @return string|null + */ + public function providerNormalizeUsername( $username ); + + /** + * Revoke the user's credentials + * + * This may cause the user to no longer exist for the provider, or the user + * may continue to exist in a "disabled" state. + * + * The intention is that the named account will never again be usable for + * normal login (i.e. there is no way to undo the revocation of access). + * + * @param string $username + */ + public function providerRevokeAccessForUser( $username ); + + /** + * Determine whether a property can change + * @see AuthManager::allowsPropertyChange() + * @param string $property + * @return bool + */ + public function providerAllowsPropertyChange( $property ); + + /** + * Validate a change of authentication data (e.g. passwords) + * + * Return StatusValue::newGood( 'ignored' ) if you don't support this + * AuthenticationRequest type. + * + * @param AuthenticationRequest $req + * @param bool $checkData If false, $req hasn't been loaded from the + * submission so checks on user-submitted fields should be skipped. + * $req->username is considered user-submitted for this purpose, even + * if it cannot be changed via $req->loadFromSubmission. + * @return StatusValue + */ + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ); + + /** + * Change or remove authentication data (e.g. passwords) + * + * If $req was returned for AuthManager::ACTION_CHANGE, the corresponding + * credentials should result in a successful login in the future. + * + * If $req was returned for AuthManager::ACTION_REMOVE, the corresponding + * credentials should no longer result in a successful login. + * + * It can be assumed that providerAllowsAuthenticationDataChange with $checkData === true + * was called before this, and passed. This method should never fail (other than throwing an + * exception). + * + * @param AuthenticationRequest $req + */ + public function providerChangeAuthenticationData( AuthenticationRequest $req ); + + /** + * Fetch the account-creation type + * @return string One of the TYPE_* constants + */ + public function accountCreationType(); + + /** + * Determine whether an account creation may begin + * + * Called from AuthManager::beginAccountCreation() + * + * @note No need to test if the account exists, AuthManager checks that + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return StatusValue + */ + public function testForAccountCreation( $user, $creator, array $reqs ); + + /** + * Start an account creation flow + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user may be created. Secondary providers will now run. + * - FAIL: The user may not be created. Fail the creation process. + * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it. + * - UI: The $reqs are accepted, no other primary provider will run. + * Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: The $reqs are accepted, no other primary provider will run. + * Redirection to a third party is needed to complete the process. + */ + public function beginPrimaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Continue an account creation flow + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user may be created. Secondary providers will now run. + * - FAIL: The user may not be created. Fail the creation process. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continuePrimaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Post-creation callback + * + * Called after the user is added to the database, before secondary + * authentication providers are run. Only called if this provider was the one that issued + * a PASS. + * + * @param User $user User being created (has been added to the database now). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationResponse $response PASS response returned earlier + * @return string|null 'newusers' log subtype to use for logging the + * account creation. If null, either 'create' or 'create2' will be used + * depending on $creator. + */ + public function finishAccountCreation( $user, $creator, AuthenticationResponse $response ); + + /** + * Post-creation callback + * + * This will be called at the end of any account creation attempt, regardless of whether this + * provider was the one that handled it. It will not be called if the account creation process + * results in a session timeout (possibly after a successful user creation, while a secondary + * provider is waiting for a response). + * + * @param User $user User that was attempted to be created. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ); + + /** + * Determine whether an account may be created + * + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param bool|string $autocreate False if this is not an auto-creation, or + * the source of the auto-creation passed to AuthManager::autoCreateUser(). + * @param array $options + * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL + * - creating: (bool) If false (or missing), this call is only testing if + * a user could be created. If set, this (non-autocreation) is for + * actually creating an account and will be followed by a call to + * testForAccountCreation(). In this case, the provider might return + * StatusValue::newGood() here and let the later call to + * testForAccountCreation() do a more thorough test. + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate, array $options = [] ); + + /** + * Post-auto-creation callback + * @param User $user User being created (has been added to the database now). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param string $source The source of the auto-creation passed to + * AuthManager::autoCreateUser(). + */ + public function autoCreatedAccount( $user, $source ); + + /** + * Start linking an account to an existing user + * @param User $user User being linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is linked. + * - FAIL: The user is not linked. Fail the linking process. + * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it. + * - UI: The $reqs are accepted, no other primary provider will run. + * Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: The $reqs are accepted, no other primary provider will run. + * Redirection to a third party is needed to complete the process. + */ + public function beginPrimaryAccountLink( $user, array $reqs ); + + /** + * Continue linking an account to an existing user + * @param User $user User being linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is linked. + * - FAIL: The user is not linked. Fail the linking process. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continuePrimaryAccountLink( $user, array $reqs ); + + /** + * Post-link callback + * + * This will be called at the end of any account linking attempt, regardless of whether this + * provider was the one that handled it. + * + * @param User $user User that was attempted to be linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAccountLink( $user, AuthenticationResponse $response ); + +} diff --git a/www/wiki/includes/auth/RememberMeAuthenticationRequest.php b/www/wiki/includes/auth/RememberMeAuthenticationRequest.php new file mode 100644 index 00000000..06060b16 --- /dev/null +++ b/www/wiki/includes/auth/RememberMeAuthenticationRequest.php @@ -0,0 +1,65 @@ +<?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 MediaWiki\Session\SessionManager; +use MediaWiki\Session\SessionProvider; + +/** + * This is an authentication request added by AuthManager to show a "remember + * me" checkbox. When checked, it will take more time for the authenticated session to expire. + * @ingroup Auth + * @since 1.27 + */ +class RememberMeAuthenticationRequest extends AuthenticationRequest { + + public $required = self::OPTIONAL; + + /** @var int How long the user will be remembered, in seconds */ + protected $expiration = null; + + /** @var bool */ + public $rememberMe = false; + + public function __construct() { + /** @var SessionProvider $provider */ + $provider = SessionManager::getGlobalSession()->getProvider(); + $this->expiration = $provider->getRememberUserDuration(); + } + + public function getFieldInfo() { + if ( !$this->expiration ) { + return []; + } + + $expirationDays = ceil( $this->expiration / ( 3600 * 24 ) ); + return [ + 'rememberMe' => [ + 'type' => 'checkbox', + 'label' => wfMessage( 'userlogin-remembermypassword' )->numParams( $expirationDays ), + 'help' => wfMessage( 'authmanager-userlogin-remembermypassword-help' ), + 'optional' => true, + 'skippable' => true, + ] + ]; + } +} diff --git a/www/wiki/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php new file mode 100644 index 00000000..45ac3aa0 --- /dev/null +++ b/www/wiki/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php @@ -0,0 +1,133 @@ +<?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; + +/** + * Reset the local password, if signalled via $this->manager->setAuthenticationSessionData() + * + * The authentication data key is 'reset-pass'; the data is an object with the + * following properties: + * - msg: Message object to display to the user + * - hard: Boolean, if true the reset cannot be skipped. + * - req: Optional PasswordAuthenticationRequest to use to actually reset the + * password. Won't be displayed to the user. + * + * @ingroup Auth + * @since 1.27 + */ +class ResetPasswordSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider { + + public function getAuthenticationRequests( $action, array $options ) { + return []; + } + + public function beginSecondaryAuthentication( $user, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + public function continueSecondaryAuthentication( $user, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + /** + * Try to reset the password + * @param \User $user + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + protected function tryReset( \User $user, array $reqs ) { + $data = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + if ( !$data ) { + return AuthenticationResponse::newAbstain(); + } + + if ( is_array( $data ) ) { + $data = (object)$data; + } + if ( !is_object( $data ) ) { + throw new \UnexpectedValueException( 'reset-pass is not valid' ); + } + + if ( !isset( $data->msg ) ) { + throw new \UnexpectedValueException( 'reset-pass msg is missing' ); + } elseif ( !$data->msg instanceof \Message ) { + throw new \UnexpectedValueException( 'reset-pass msg is not valid' ); + } elseif ( !isset( $data->hard ) ) { + throw new \UnexpectedValueException( 'reset-pass hard is missing' ); + } elseif ( isset( $data->req ) && ( + !$data->req instanceof PasswordAuthenticationRequest || + !array_key_exists( 'retype', $data->req->getFieldInfo() ) + ) ) { + throw new \UnexpectedValueException( 'reset-pass req is not valid' ); + } + + if ( !$data->hard ) { + $req = ButtonAuthenticationRequest::getRequestByName( $reqs, 'skipReset' ); + if ( $req ) { + $this->manager->removeAuthenticationSessionData( 'reset-pass' ); + return AuthenticationResponse::newPass(); + } + } + + $needReq = isset( $data->req ) ? $data->req : new PasswordAuthenticationRequest(); + if ( !$needReq->action ) { + $needReq->action = AuthManager::ACTION_CHANGE; + } + $needReq->required = $data->hard ? AuthenticationRequest::REQUIRED + : AuthenticationRequest::OPTIONAL; + $needReqs = [ $needReq ]; + if ( !$data->hard ) { + $needReqs[] = new ButtonAuthenticationRequest( + 'skipReset', + wfMessage( 'authprovider-resetpass-skip-label' ), + wfMessage( 'authprovider-resetpass-skip-help' ) + ); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, get_class( $needReq ) ); + if ( !$req || !array_key_exists( 'retype', $req->getFieldInfo() ) ) { + return AuthenticationResponse::newUI( $needReqs, $data->msg, 'warning' ); + } + + if ( $req->password !== $req->retype ) { + return AuthenticationResponse::newUI( $needReqs, new \Message( 'badretype' ), 'error' ); + } + + $req->username = $user->getName(); + $status = $this->manager->allowsAuthenticationDataChange( $req ); + if ( !$status->isGood() ) { + return AuthenticationResponse::newUI( $needReqs, $status->getMessage(), 'error' ); + } + $this->manager->changeAuthenticationData( $req ); + + $this->manager->removeAuthenticationSessionData( 'reset-pass' ); + return AuthenticationResponse::newPass(); + } +} diff --git a/www/wiki/includes/auth/SecondaryAuthenticationProvider.php b/www/wiki/includes/auth/SecondaryAuthenticationProvider.php new file mode 100644 index 00000000..c55e65d5 --- /dev/null +++ b/www/wiki/includes/auth/SecondaryAuthenticationProvider.php @@ -0,0 +1,258 @@ +<?php +/** + * Secondary authentication provider interface + * + * 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 StatusValue; +use User; + +/** + * A secondary provider mostly acts when the submitted authentication data has + * already been associated to a MediaWiki user account. + * + * For login, a secondary provider performs additional authentication steps + * after a PrimaryAuthenticationProvider has identified which MediaWiki user is + * trying to log in. For example, it might implement a password reset, request + * the second factor for two-factor auth, or prevent the login if the account is blocked. + * + * For account creation, a secondary provider performs optional extra steps after + * a PrimaryAuthenticationProvider has created the user; for example, it can collect + * further user information such as a biography. + * + * (For account linking, secondary providers are not involved.) + * + * This interface also provides methods for changing authentication data such + * as a second-factor token, and callbacks that are invoked after login / account creation + * succeeded or failed. + * + * @ingroup Auth + * @since 1.27 + * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager + */ +interface SecondaryAuthenticationProvider extends AuthenticationProvider { + + /** + * Start an authentication flow + * + * Note that this may be called for a user even if + * beginSecondaryAccountCreation() was never called. The module should take + * the opportunity to do any necessary setup in that case. + * + * @param User $user User being authenticated. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is authenticated. Additional secondary providers may run. + * - FAIL: The user is not authenticated. Fail the authentication process. + * - ABSTAIN: Additional secondary providers may run. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function beginSecondaryAuthentication( $user, array $reqs ); + + /** + * Continue an authentication flow + * @param User $user User being authenticated. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is authenticated. Additional secondary providers may run. + * - FAIL: The user is not authenticated. Fail the authentication process. + * - ABSTAIN: Additional secondary providers may run. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continueSecondaryAuthentication( $user, array $reqs ); + + /** + * Post-login callback + * + * This will be called at the end of a login attempt. It will not be called for unfinished + * login attempts that fail by the session timing out. + * + * @param User|null $user User that was attempted to be logged in, if known. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAuthentication( $user, AuthenticationResponse $response ); + + /** + * Revoke the user's credentials + * + * This may cause the user to no longer exist for the provider, or the user + * may continue to exist in a "disabled" state. + * + * The intention is that the named account will never again be usable for + * normal login (i.e. there is no way to undo the revocation of access). + * + * @param string $username + */ + public function providerRevokeAccessForUser( $username ); + + /** + * Determine whether a property can change + * @see AuthManager::allowsPropertyChange() + * @param string $property + * @return bool + */ + public function providerAllowsPropertyChange( $property ); + + /** + * Validate a change of authentication data (e.g. passwords) + * + * Return StatusValue::newGood( 'ignored' ) if you don't support this + * AuthenticationRequest type. + * + * @param AuthenticationRequest $req + * @param bool $checkData If false, $req hasn't been loaded from the + * submission so checks on user-submitted fields should be skipped. + * $req->username is considered user-submitted for this purpose, even + * if it cannot be changed via $req->loadFromSubmission. + * @return StatusValue + */ + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ); + + /** + * Change or remove authentication data (e.g. passwords) + * + * If $req was returned for AuthManager::ACTION_CHANGE, the corresponding + * credentials should result in a successful login in the future. + * + * If $req was returned for AuthManager::ACTION_REMOVE, the corresponding + * credentials should no longer result in a successful login. + * + * It can be assumed that providerAllowsAuthenticationDataChange with $checkData === true + * was called before this, and passed. This method should never fail (other than throwing an + * exception). + * + * @param AuthenticationRequest $req + */ + public function providerChangeAuthenticationData( AuthenticationRequest $req ); + + /** + * Determine whether an account creation may begin + * + * Called from AuthManager::beginAccountCreation() + * + * @note No need to test if the account exists, AuthManager checks that + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return StatusValue + */ + public function testForAccountCreation( $user, $creator, array $reqs ); + + /** + * Start an account creation flow + * + * @note There is no guarantee this will be called in a successful account + * creation process as the user can just abandon the process at any time + * after the primary provider has issued a PASS and still have a valid + * account. Be prepared to handle any database inconsistencies that result + * from this or continueSecondaryAccountCreation() not being called. + * @param User $user User being created (has been added to the database). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user creation is ok. Additional secondary providers may run. + * - ABSTAIN: Additional secondary providers may run. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Continue an authentication flow + * + * @param User $user User being created (has been added to the database). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user creation is ok. Additional secondary providers may run. + * - ABSTAIN: Additional secondary providers may run. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Post-creation callback + * + * This will be called at the end of an account creation attempt. It will not be called if + * the account creation process results in a session timeout (possibly after a successful + * user creation, while a secondary provider is waiting for a response). + * + * @param User $user User that was attempted to be created. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationResponse $response Authentication response that will be returned + * (PASS or FAIL) + */ + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ); + + /** + * Determine whether an account may be created + * + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param bool|string $autocreate False if this is not an auto-creation, or + * the source of the auto-creation passed to AuthManager::autoCreateUser(). + * @param array $options + * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL + * - creating: (bool) If false (or missing), this call is only testing if + * a user could be created. If set, this (non-autocreation) is for + * actually creating an account and will be followed by a call to + * testForAccountCreation(). In this case, the provider might return + * StatusValue::newGood() here and let the later call to + * testForAccountCreation() do a more thorough test. + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate, array $options = [] ); + + /** + * Post-auto-creation callback + * @param User $user User being created (has been added to the database now). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param string $source The source of the auto-creation passed to + * AuthManager::autoCreateUser(). + */ + public function autoCreatedAccount( $user, $source ); + +} diff --git a/www/wiki/includes/auth/TemporaryPasswordAuthenticationRequest.php b/www/wiki/includes/auth/TemporaryPasswordAuthenticationRequest.php new file mode 100644 index 00000000..bc7c779d --- /dev/null +++ b/www/wiki/includes/auth/TemporaryPasswordAuthenticationRequest.php @@ -0,0 +1,101 @@ +<?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 MediaWiki\MediaWikiServices; + +/** + * This represents the intention to set a temporary password for the user. + * @ingroup Auth + * @since 1.27 + */ +class TemporaryPasswordAuthenticationRequest extends AuthenticationRequest { + /** @var string|null Temporary password */ + public $password; + + /** @var bool Email password to the user. */ + public $mailpassword = false; + + /** @var string Username or IP address of the caller */ + public $caller; + + public function getFieldInfo() { + return [ + 'mailpassword' => [ + 'type' => 'checkbox', + 'label' => wfMessage( 'createaccountmail' ), + 'help' => wfMessage( 'createaccountmail-help' ), + ], + ]; + } + + /** + * @param string|null $password + */ + public function __construct( $password = null ) { + $this->password = $password; + if ( $password ) { + $this->mailpassword = true; + } + } + + /** + * Return an instance with a new, random password + * @return TemporaryPasswordAuthenticationRequest + */ + public static function newRandom() { + $config = MediaWikiServices::getInstance()->getMainConfig(); + + // get the min password length + $minLength = $config->get( 'MinimalPasswordLength' ); + $policy = $config->get( 'PasswordPolicy' ); + foreach ( $policy['policies'] as $p ) { + if ( isset( $p['MinimalPasswordLength'] ) ) { + $minLength = max( $minLength, $p['MinimalPasswordLength'] ); + } + if ( isset( $p['MinimalPasswordLengthToLogin'] ) ) { + $minLength = max( $minLength, $p['MinimalPasswordLengthToLogin'] ); + } + } + + $password = \PasswordFactory::generateRandomPasswordString( $minLength ); + + return new self( $password ); + } + + /** + * Return an instance with an invalid password + * @return TemporaryPasswordAuthenticationRequest + */ + public static function newInvalid() { + $request = new self( null ); + return $request; + } + + public function describeCredentials() { + return [ + 'provider' => wfMessage( 'authmanager-provider-temporarypassword' ), + 'account' => new \RawMessage( '$1', [ $this->username ] ), + ] + parent::describeCredentials(); + } + +} diff --git a/www/wiki/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 00000000..4a2d0094 --- /dev/null +++ b/www/wiki/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,477 @@ +<?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 User; + +/** + * 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; + + /** + * @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']; + } + } + + 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' ); + } + } + + 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->equals( $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() < 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 ( !\IP::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->onTransactionIdle( + 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 ) { + if ( $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 )->onTransactionIdle( + 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 = 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 + + \Hooks::run( 'User::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 = \IP::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 ); + $emailMessage->params( $callerName, $passwordMessage->text(), 1, + '<' . \Title::newMainPage()->getCanonicalURL() . '>', + round( $this->newPasswordExpiry / 86400 ) ); + $emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage ); + return $user->sendMail( $emailTitle->text(), $emailMessage->text() ); + } +} diff --git a/www/wiki/includes/auth/ThrottlePreAuthenticationProvider.php b/www/wiki/includes/auth/ThrottlePreAuthenticationProvider.php new file mode 100644 index 00000000..ae0bc6bb --- /dev/null +++ b/www/wiki/includes/auth/ThrottlePreAuthenticationProvider.php @@ -0,0 +1,180 @@ +<?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 BagOStuff; +use Config; + +/** + * A pre-authentication provider to throttle authentication actions. + * + * Adding this provider will throttle account creations and primary authentication attempts + * (more specifically, any authentication that returns FAIL on failure). Secondary authentication + * cannot be easily throttled on a framework level (since it would typically return UI on failure); + * secondary providers are expected to do their own throttling. + * @ingroup Auth + * @since 1.27 + */ +class ThrottlePreAuthenticationProvider extends AbstractPreAuthenticationProvider { + /** @var array */ + protected $throttleSettings; + + /** @var Throttler */ + protected $accountCreationThrottle; + + /** @var Throttler */ + protected $passwordAttemptThrottle; + + /** @var BagOStuff */ + protected $cache; + + /** + * @param array $params + * - accountCreationThrottle: (array) Condition array for the account creation throttle; an array + * of arrays in a format like $wgPasswordAttemptThrottle, passed to the Throttler constructor. + * - passwordAttemptThrottle: (array) Condition array for the password attempt throttle, in the + * same format as accountCreationThrottle. + * - cache: (BagOStuff) Where to store the throttle, defaults to the local cluster instance. + */ + public function __construct( $params = [] ) { + $this->throttleSettings = array_intersect_key( $params, + [ 'accountCreationThrottle' => true, 'passwordAttemptThrottle' => true ] ); + $this->cache = isset( $params['cache'] ) ? $params['cache'] : + \ObjectCache::getLocalClusterInstance(); + } + + public function setConfig( Config $config ) { + parent::setConfig( $config ); + + $accountCreationThrottle = $this->config->get( 'AccountCreationThrottle' ); + // Handle old $wgAccountCreationThrottle format (number of attempts per 24 hours) + if ( !is_array( $accountCreationThrottle ) ) { + $accountCreationThrottle = [ [ + 'count' => $accountCreationThrottle, + 'seconds' => 86400, + ] ]; + } + + // @codeCoverageIgnoreStart + $this->throttleSettings += [ + // @codeCoverageIgnoreEnd + 'accountCreationThrottle' => $accountCreationThrottle, + 'passwordAttemptThrottle' => $this->config->get( 'PasswordAttemptThrottle' ), + ]; + + if ( !empty( $this->throttleSettings['accountCreationThrottle'] ) ) { + $this->accountCreationThrottle = new Throttler( + $this->throttleSettings['accountCreationThrottle'], [ + 'type' => 'acctcreate', + 'cache' => $this->cache, + ] + ); + } + if ( !empty( $this->throttleSettings['passwordAttemptThrottle'] ) ) { + $this->passwordAttemptThrottle = new Throttler( + $this->throttleSettings['passwordAttemptThrottle'], [ + 'type' => 'password', + 'cache' => $this->cache, + ] + ); + } + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + if ( !$this->accountCreationThrottle || !$creator->isPingLimitable() ) { + return \StatusValue::newGood(); + } + + $ip = $this->manager->getRequest()->getIP(); + + if ( !\Hooks::run( 'ExemptFromAccountCreationThrottle', [ $ip ] ) ) { + $this->logger->debug( __METHOD__ . ": a hook allowed account creation w/o throttle\n" ); + return \StatusValue::newGood(); + } + + $result = $this->accountCreationThrottle->increase( null, $ip, __METHOD__ ); + if ( $result ) { + $message = wfMessage( 'acct_creation_throttle_hit' )->params( $result['count'] ) + ->durationParams( $result['wait'] ); + return \StatusValue::newFatal( $message ); + } + + return \StatusValue::newGood(); + } + + public function testForAuthentication( array $reqs ) { + if ( !$this->passwordAttemptThrottle ) { + return \StatusValue::newGood(); + } + + $ip = $this->manager->getRequest()->getIP(); + try { + $username = AuthenticationRequest::getUsernameFromRequests( $reqs ); + } catch ( \UnexpectedValueException $e ) { + $username = ''; + } + + // Get everything this username could normalize to, and throttle each one individually. + // If nothing uses usernames, just throttle by IP. + $usernames = $this->manager->normalizeUsername( $username ); + $result = false; + foreach ( $usernames as $name ) { + $r = $this->passwordAttemptThrottle->increase( $name, $ip, __METHOD__ ); + if ( $r && ( !$result || $result['wait'] < $r['wait'] ) ) { + $result = $r; + } + } + + if ( $result ) { + $message = wfMessage( 'login-throttled' )->durationParams( $result['wait'] ); + return \StatusValue::newFatal( $message ); + } else { + $this->manager->setAuthenticationSessionData( 'LoginThrottle', + [ 'users' => $usernames, 'ip' => $ip ] ); + return \StatusValue::newGood(); + } + } + + /** + * @param null|\User $user + * @param AuthenticationResponse $response + */ + public function postAuthentication( $user, AuthenticationResponse $response ) { + if ( $response->status !== AuthenticationResponse::PASS ) { + return; + } elseif ( !$this->passwordAttemptThrottle ) { + return; + } + + $data = $this->manager->getAuthenticationSessionData( 'LoginThrottle' ); + if ( !$data ) { + // this can occur when login is happening via AuthenticationRequest::$loginRequest + // so testForAuthentication is skipped + $this->logger->info( 'throttler data not found for {user}', [ 'user' => $user->getName() ] ); + return; + } + + foreach ( $data['users'] as $name ) { + $this->passwordAttemptThrottle->clear( $name, $data['ip'] ); + } + } +} diff --git a/www/wiki/includes/auth/Throttler.php b/www/wiki/includes/auth/Throttler.php new file mode 100644 index 00000000..3125bd3f --- /dev/null +++ b/www/wiki/includes/auth/Throttler.php @@ -0,0 +1,208 @@ +<?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 BagOStuff; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; + +/** + * A helper class for throttling authentication attempts. + * @package MediaWiki\Auth + * @ingroup Auth + * @since 1.27 + */ +class Throttler implements LoggerAwareInterface { + /** @var string */ + protected $type; + /** + * See documentation of $wgPasswordAttemptThrottle for format. Old (pre-1.27) format is not + * allowed here. + * @var array + * @see https://www.mediawiki.org/wiki/Manual:$wgPasswordAttemptThrottle + */ + protected $conditions; + /** @var BagOStuff */ + protected $cache; + /** @var LoggerInterface */ + protected $logger; + /** @var int|float */ + protected $warningLimit; + + /** + * @param array $conditions An array of arrays describing throttling conditions. + * Defaults to $wgPasswordAttemptThrottle. See documentation of that variable for format. + * @param array $params Parameters (all optional): + * - type: throttle type, used as a namespace for counters, + * - cache: a BagOStuff object where throttle counters are stored. + * - warningLimit: the log level will be raised to warning when rejecting an attempt after + * no less than this many failures. + */ + public function __construct( array $conditions = null, array $params = [] ) { + $invalidParams = array_diff_key( $params, + array_fill_keys( [ 'type', 'cache', 'warningLimit' ], true ) ); + if ( $invalidParams ) { + throw new \InvalidArgumentException( 'unrecognized parameters: ' + . implode( ', ', array_keys( $invalidParams ) ) ); + } + + if ( $conditions === null ) { + $config = MediaWikiServices::getInstance()->getMainConfig(); + $conditions = $config->get( 'PasswordAttemptThrottle' ); + $params += [ + 'type' => 'password', + 'cache' => \ObjectCache::getLocalClusterInstance(), + 'warningLimit' => 50, + ]; + } else { + $params += [ + 'type' => 'custom', + 'cache' => \ObjectCache::getLocalClusterInstance(), + 'warningLimit' => INF, + ]; + } + + $this->type = $params['type']; + $this->conditions = static::normalizeThrottleConditions( $conditions ); + $this->cache = $params['cache']; + $this->warningLimit = $params['warningLimit']; + + $this->setLogger( LoggerFactory::getInstance( 'throttler' ) ); + } + + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * Increase the throttle counter and return whether the attempt should be throttled. + * + * Should be called before an authentication attempt. + * + * @param string|null $username + * @param string|null $ip + * @param string|null $caller The authentication method from which we were called. + * @return array|false False if the attempt should not be throttled, an associative array + * with three keys otherwise: + * - throttleIndex: which throttle condition was met (a key of the conditions array) + * - count: throttle count (ie. number of failed attempts) + * - wait: time in seconds until authentication can be attempted + */ + public function increase( $username = null, $ip = null, $caller = null ) { + if ( $username === null && $ip === null ) { + throw new \InvalidArgumentException( 'Either username or IP must be set for throttling' ); + } + + $userKey = $username ? md5( $username ) : null; + foreach ( $this->conditions as $index => $throttleCondition ) { + $ipKey = isset( $throttleCondition['allIPs'] ) ? null : $ip; + $count = $throttleCondition['count']; + $expiry = $throttleCondition['seconds']; + + // a limit of 0 is used as a disable flag in some throttling configuration settings + // throttling the whole world is probably a bad idea + if ( !$count || $userKey === null && $ipKey === null ) { + continue; + } + + $throttleKey = $this->cache->makeGlobalKey( 'throttler', $this->type, $index, $ipKey, $userKey ); + $throttleCount = $this->cache->get( $throttleKey ); + + if ( !$throttleCount ) { // counter not started yet + $this->cache->add( $throttleKey, 1, $expiry ); + } elseif ( $throttleCount < $count ) { // throttle limited not yet reached + $this->cache->incr( $throttleKey ); + } else { // throttled + $this->logRejection( [ + 'throttle' => $this->type, + 'index' => $index, + 'ip' => $ipKey, + 'username' => $username, + 'count' => $count, + 'expiry' => $expiry, + // @codeCoverageIgnoreStart + 'method' => $caller ?: __METHOD__, + // @codeCoverageIgnoreEnd + ] ); + + return [ + 'throttleIndex' => $index, + 'count' => $count, + 'wait' => $expiry, + ]; + } + } + return false; + } + + /** + * Clear the throttle counter. + * + * Should be called after a successful authentication attempt. + * + * @param string|null $username + * @param string|null $ip + * @throws \MWException + */ + public function clear( $username = null, $ip = null ) { + $userKey = $username ? md5( $username ) : null; + foreach ( $this->conditions as $index => $specificThrottle ) { + $ipKey = isset( $specificThrottle['allIPs'] ) ? null : $ip; + $throttleKey = $this->cache->makeGlobalKey( 'throttler', $this->type, $index, $ipKey, $userKey ); + $this->cache->delete( $throttleKey ); + } + } + + /** + * Handles B/C for $wgPasswordAttemptThrottle. + * @param array $throttleConditions + * @return array + * @see $wgPasswordAttemptThrottle for structure + */ + protected static function normalizeThrottleConditions( $throttleConditions ) { + if ( !is_array( $throttleConditions ) ) { + return []; + } + if ( isset( $throttleConditions['count'] ) ) { // old style + $throttleConditions = [ $throttleConditions ]; + } + return $throttleConditions; + } + + protected function logRejection( array $context ) { + $logMsg = 'Throttle {throttle} hit, throttled for {expiry} seconds due to {count} attempts ' + . 'from username {username} and IP {ip}'; + + // If we are hitting a throttle for >= warningLimit attempts, it is much more likely to be + // an attack than someone simply forgetting their password, so log it at a higher level. + $level = $context['count'] >= $this->warningLimit ? LogLevel::WARNING : LogLevel::INFO; + + // It should be noted that once the throttle is hit, every attempt to login will + // generate the log message until the throttle expires, not just the attempt that + // puts the throttle over the top. + $this->logger->log( $level, $logMsg, $context ); + } + +} diff --git a/www/wiki/includes/auth/UserDataAuthenticationRequest.php b/www/wiki/includes/auth/UserDataAuthenticationRequest.php new file mode 100644 index 00000000..35d66523 --- /dev/null +++ b/www/wiki/includes/auth/UserDataAuthenticationRequest.php @@ -0,0 +1,89 @@ +<?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 MediaWiki\MediaWikiServices; +use StatusValue; +use User; + +/** + * This represents additional user data requested on the account creation form + * + * @ingroup Auth + * @since 1.27 + */ +class UserDataAuthenticationRequest extends AuthenticationRequest { + /** @var string|null Email address */ + public $email; + + /** @var string|null Real name */ + public $realname; + + public function getFieldInfo() { + $config = MediaWikiServices::getInstance()->getMainConfig(); + $ret = [ + 'email' => [ + 'type' => 'string', + 'label' => wfMessage( 'authmanager-email-label' ), + 'help' => wfMessage( 'authmanager-email-help' ), + 'optional' => true, + ], + 'realname' => [ + 'type' => 'string', + 'label' => wfMessage( 'authmanager-realname-label' ), + 'help' => wfMessage( 'authmanager-realname-help' ), + 'optional' => true, + ], + ]; + + if ( !$config->get( 'EnableEmail' ) ) { + unset( $ret['email'] ); + } + + if ( in_array( 'realname', $config->get( 'HiddenPrefs' ), true ) ) { + unset( $ret['realname'] ); + } + + return $ret; + } + + /** + * Add data to the User object + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @return StatusValue + */ + public function populateUser( $user ) { + if ( $this->email !== null && $this->email !== '' ) { + if ( !\Sanitizer::validateEmail( $this->email ) ) { + return StatusValue::newFatal( 'invalidemailaddress' ); + } + $user->setEmail( $this->email ); + } + if ( $this->realname !== null && $this->realname !== '' ) { + $user->setRealName( $this->realname ); + } + return StatusValue::newGood(); + } + +} diff --git a/www/wiki/includes/auth/UsernameAuthenticationRequest.php b/www/wiki/includes/auth/UsernameAuthenticationRequest.php new file mode 100644 index 00000000..7bf8f130 --- /dev/null +++ b/www/wiki/includes/auth/UsernameAuthenticationRequest.php @@ -0,0 +1,39 @@ +<?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; + +/** + * AuthenticationRequest to ensure something with a username is present + * @ingroup Auth + * @since 1.27 + */ +class UsernameAuthenticationRequest extends AuthenticationRequest { + public function getFieldInfo() { + return [ + 'username' => [ + 'type' => 'string', + 'label' => wfMessage( 'userlogin-yourname' ), + 'help' => wfMessage( 'authmanager-username-help' ), + ], + ]; + } +} |