diff options
author | Brad Jorsch <bjorsch@wikimedia.org> | 2015-11-22 20:17:00 +0000 |
---|---|---|
committer | Gergő Tisza <gtisza@wikimedia.org> | 2016-05-16 15:11:02 +0000 |
commit | d245bd25aef1cc7f17f2323ca4d557cd820cc469 (patch) | |
tree | a6fa357b76e941e306b15cd7c863ec89739a5525 /includes/auth | |
parent | 4451ec3033c520b858f1a0ab83ee232334632789 (diff) | |
download | mediawikicore-d245bd25aef1cc7f17f2323ca4d557cd820cc469.tar.gz mediawikicore-d245bd25aef1cc7f17f2323ca4d557cd820cc469.zip |
Add AuthManager
This implements the AuthManager class and its needed interfaces and
subclasses, and integrates them into the backend portion of MediaWiki.
Integration with frontend portions of MediaWiki (e.g. ApiLogin,
Special:Login) is left for a followup.
Bug: T91699
Bug: T71589
Bug: T111299
Co-Authored-By: Gergő Tisza <gtisza@wikimedia.org>
Change-Id: If89d24838e326fe25fe867d02181eebcfbb0e196
Diffstat (limited to 'includes/auth')
33 files changed, 7368 insertions, 0 deletions
diff --git a/includes/auth/AbstractAuthenticationProvider.php b/includes/auth/AbstractAuthenticationProvider.php new file mode 100644 index 000000000000..9e38eccb3ffa --- /dev/null +++ b/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/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php b/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 000000000000..900d2e5c8ea3 --- /dev/null +++ b/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 = true; + + 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/includes/auth/AbstractPreAuthenticationProvider.php b/includes/auth/AbstractPreAuthenticationProvider.php new file mode 100644 index 000000000000..48a9c88c9d81 --- /dev/null +++ b/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 ) { + 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/includes/auth/AbstractPrimaryAuthenticationProvider.php b/includes/auth/AbstractPrimaryAuthenticationProvider.php new file mode 100644 index 000000000000..2e0d669d3e76 --- /dev/null +++ b/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 ) { + 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/includes/auth/AbstractSecondaryAuthenticationProvider.php b/includes/auth/AbstractSecondaryAuthenticationProvider.php new file mode 100644 index 000000000000..89fd6f92a5d6 --- /dev/null +++ b/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 ) { + return \StatusValue::newGood(); + } + + public function autoCreatedAccount( $user, $source ) { + } +} diff --git a/includes/auth/AuthManager.php b/includes/auth/AuthManager.php new file mode 100644 index 000000000000..efee53c6dc8c --- /dev/null +++ b/includes/auth/AuthManager.php @@ -0,0 +1,2386 @@ +<?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 Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Status; +use StatusValue; +use User; +use WebRequest; + +/** + * 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. + * + * @ingroup Auth + * @since 1.27 + */ +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() { + global $wgDisableAuthManager; + + if ( $wgDisableAuthManager ) { + throw new \BadMethodCallException( '$wgDisableAuthManager is set' ); + } + + if ( self::$instance === null ) { + self::$instance = new self( + \RequestContext::getMain()->getRequest(), + \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + } + 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 + * @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() (after adding a username, if necessary) + * 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" ); + $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" ); + $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; + } + $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 ) { + 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 ); + $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', [ + 'user' => $user->getName(), + ] ); + $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 + * + * @param string $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. + * + * @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 + * @param int $flags Bitfield of User:READ_* constants + * @return Status + */ + public function canCreateAccount( $username, $flags = User::READ_NORMAL ) { + 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 ); + 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( '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 + * @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, User::READ_LOCKING ); + 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 we get here, the user didn't submit a form with any of the + // usual AuthenticationRequests that are needed for an account + // creation. So we need to determine if there are any and return a + // UI response if so. + if ( $req->createRequest ) { + // We have a createRequest from a + // PrimaryAuthenticationProvider, so don't ask. + $providers = $this->getPreAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + } else { + // We're only preserving maybeLink, so ask for primary fields + // too. + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + } + $reqs = $this->getAuthenticationRequestsInternal( + self::ACTION_CREATE, + [], + $providers + ); + // See if we need any requests to begin + foreach ( (array)$reqs as $r ) { + if ( !$r instanceof UsernameAuthenticationRequest && + !$r instanceof UserDataAuthenticationRequest && + !$r instanceof CreationReasonAuthenticationRequest + ) { + // Needs some reqs, so request them + $reqs[] = new CreateFromLoginAuthenticationRequest( $req->createRequest, [] ); + $state['continueRequests'] = $reqs; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + $session->persist(); + return AuthenticationResponse::newUI( $reqs, wfMessage( 'authmanager-create-from-login' ) ); + } + } + // No reqs needed, so we can just continue. + $req->createRequest->returnToUrl = $returnToUrl; + $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']; + } + + // If we're coming in from a create-from-login UI response, we need + // to extract the createRequest (if any). + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreateFromLoginAuthenticationRequest::class + ); + if ( $req && $req->createRequest ) { + $reqs[] = $req->createRequest; + } + + // 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(), + ] ); + $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(), + ] ); + $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( new \SiteStatsUpdate( 0, 0, 0, 0, 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() ); + $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(), + ] ); + $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 + * @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 slave 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 && wfGetLB()->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( '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', 600 ); + $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', 600 ); + $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? + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + $status = $provider->testUserForCreation( $user, $source ); + 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, 600 ); + $user->setId( 0 ); + $user->loadFromId(); + return $ret; + } + } + + // Ignore warnings about master connections/writes...hard to avoid here + \Profiler::instance()->getTransactionProfiler()->resetExpectations(); + + $backoffKey = wfMemcKey( '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, + ] ); + + try { + $status = $user->addToDatabase(); + if ( !$status->isOk() ) { + // double-check for a race condition (T70012) + $localId = User::idFromName( $username, User::READ_LATEST ); + if ( $localId ) { + $this->logger->info( __METHOD__ . ': {username} already exists locally (race)', [ + 'username' => $username, + ] ); + $user->setId( $localId ); + $user->loadFromId( User::READ_LATEST ); + if ( $login ) { + $this->setSessionDataForUser( $user ); + } + $status = Status::newGood(); + $status->warning( 'userexists' ); + } else { + $this->logger->error( __METHOD__ . ': {username} failed with message {message}', [ + 'username' => $username, + 'message' => $status->getWikiText( null, null, 'en' ) + ] ); + $user->setId( 0 ); + $user->loadFromId(); + } + return $status; + } + } catch ( \Exception $ex ) { + $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, true ); + + // 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( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) ); + + // Watch user's userpage and talk page + $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(), + ] ); + $logid = $logEntry->insert(); + } + + 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(), + ] ); + $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(), + ] ); + $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 = []; + $allPrimaryRequired = null; + foreach ( $providers as $provider ) { + $isPrimary = $provider instanceof PrimaryAuthenticationProvider; + $thisRequired = []; + foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) { + $id = $req->getUniqueId(); + + // If it's from a Primary, mark it as "primary-required" but + // track it for later. + if ( $isPrimary ) { + if ( $req->required ) { + $thisRequired[$id] = true; + $req->required = AuthenticationRequest::PRIMARY_REQUIRED; + } + } + + if ( !isset( $reqs[$id] ) || $req->required === AuthenticationRequest::REQUIRED ) { + $reqs[$id] = $req; + } + } + + // Track which requests are required by all primaries + if ( $isPrimary ) { + $allPrimaryRequired = $allPrimaryRequired === null + ? $thisRequired + : array_intersect_key( $allPrimaryRequired, $thisRequired ); + } + } + // Any requests that were required by all primaries are required. + foreach ( (array)$allPrimaryRequired as $id => $dummy ) { + $reqs[$id]->required = AuthenticationRequest::REQUIRED; + } + + // 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 + foreach ( $reqs as $req ) { + $req->action = $providerAction; + if ( $req->username === null ) { + $req->username = $options['username']; + } + } + + // 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 ); + } + + /** + * 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; + } + + /**@}*/ + + /** + * @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; + } + + /** + * Get a provider by ID + * @param string $id + * @return AuthenticationProvider|null + */ + protected 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; + } + + /** + * @param User $user + * @param bool|null $remember + */ + private function setSessionDataForUser( $user, $remember = null ) { + $session = $this->request->getSession(); + $delay = $session->delaySave(); + + $session->resetId(); + 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(); + + \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; + + \MediaWiki\Session\SessionManager::singleton()->invalidateSessionsForUser( $user ); + + $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 + */ + 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/includes/auth/AuthManagerAuthPlugin.php b/includes/auth/AuthManagerAuthPlugin.php new file mode 100644 index 000000000000..bf1e0215bc8e --- /dev/null +++ b/includes/auth/AuthManagerAuthPlugin.php @@ -0,0 +1,251 @@ +<?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 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 \\Psr\\Log\\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->isOk() ) { + $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 = [] ) { + \Hooks::run( 'UserGroupsChanged', [ $user, $addgroups, $delgroups ] ); + return true; + } + + public function canCreateAccounts() { + return AuthManager::singleton()->canCreateAccounts(); + } + + public function addUser( $user, $password, $email = '', $realname = '' ) { + global $wgUser; + + $data = [ + 'username' => $user->getName(), + 'password' => $password, + 'retype' => $password, + 'email' => $email, + 'realname' => $realname, + ]; + if ( $this->domain !== null && $this->domain !== '' ) { + $data['domain'] = $this->domain; + } + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CREATE ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + + $res = AuthManager::singleton()->beginAccountCreation( $wgUser, $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 account creation' + ); + } + } + + 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/includes/auth/AuthPluginPrimaryAuthenticationProvider.php b/includes/auth/AuthPluginPrimaryAuthenticationProvider.php new file mode 100644 index 000000000000..9746637b0007 --- /dev/null +++ b/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: " + . join( ' ', $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( $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/includes/auth/AuthenticationProvider.php b/includes/auth/AuthenticationProvider.php new file mode 100644 index 000000000000..4db0a84be99f --- /dev/null +++ b/includes/auth/AuthenticationProvider.php @@ -0,0 +1,93 @@ +<?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. + * @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. + * This does not need to be copied into the returned requests, you only + * need to pay attention to it if the set of requests differs based on + * the user. + * @return AuthenticationRequest[] + */ + public function getAuthenticationRequests( $action, array $options ); + +} diff --git a/includes/auth/AuthenticationRequest.php b/includes/auth/AuthenticationRequest.php new file mode 100644 index 000000000000..3c19b87f174e --- /dev/null +++ b/includes/auth/AuthenticationRequest.php @@ -0,0 +1,338 @@ +<?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 the login, account creation, or password change forms. + * + * @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. */ + const REQUIRED = 1; + + /** Indicates that the request is required by a primary authentication + * provdier, but other primary authentication providers do not require it. */ + 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. May not be used by all subclasses. */ + 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="image"> if 'image' is set, otherwise <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). + * - image: (string) URL of an image to use in connection with the input + * - 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 + * + * @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. + * + * Should always return false if self::getFieldInfo() returns an empty + * array. + * + * @param array $data Submitted data as an associative array + * @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[] $requests + * @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 = []; + + foreach ( $reqs as $req ) { + $info = $req->getFieldInfo(); + if ( !$info ) { + continue; + } + + foreach ( $info as $name => $options ) { + if ( $req->required !== self::REQUIRED ) { + // If the request isn't required, its fields aren't required either. + $options['optional'] = true; + } else { + $options['optional'] = !empty( $options['optional'] ); + } + + 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']; + + // 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/includes/auth/AuthenticationResponse.php b/includes/auth/AuthenticationResponse.php new file mode 100644 index 000000000000..db0182552d6b --- /dev/null +++ b/includes/auth/AuthenticationResponse.php @@ -0,0 +1,190 @@ +<?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 + * @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(). + */ + 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 + */ + public $neededRequests = []; + + /** @var Message|null I18n message to display in case of UI or FAIL */ + public $message = null; + + /** + * @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, this holds a + * request that should result in a PASS when passed to that provider's + * PrimaryAuthenticationProvider::beginPrimaryAccountCreation(). + * + * Returned with an AuthManager login FAIL or RESTART, this holds a request + * that may be passed to AuthManager::beginCreateAccount() after setting + * its ->returnToUrl property. It may also be passed to + * AuthManager::beginAuthentication() to preserve state. + */ + public $createRequest = null; + + /** + * @var AuthenticationRequest|null Returned with a PrimaryAuthenticationProvider + * login PASS with no username, this holds a request to pass to + * AuthManager::changeAuthenticationData() to link the account once the + * local user has been determined. + */ + 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. + */ + public $loginRequest = null; + + /** + * @param string|null $username Local username + * @return AuthenticationResponse + */ + public static function newPass( $username = null ) { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::PASS; + $ret->username = $username; + return $ret; + } + + /** + * @param Message $msg + * @return AuthenticationResponse + */ + public static function newFail( Message $msg ) { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::FAIL; + $ret->message = $msg; + return $ret; + } + + /** + * @param Message $msg + * @return AuthenticationResponse + */ + public static function newRestart( Message $msg ) { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::RESTART; + $ret->message = $msg; + return $ret; + } + + /** + * @return AuthenticationResponse + */ + public static function newAbstain() { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::ABSTAIN; + return $ret; + } + + /** + * @param AuthenticationRequest[] $reqs AuthenticationRequests needed to continue + * @param Message $msg + * @return AuthenticationResponse + */ + public static function newUI( array $reqs, Message $msg ) { + if ( !$reqs ) { + throw new \InvalidArgumentException( '$reqs may not be empty' ); + } + + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::UI; + $ret->neededRequests = $reqs; + $ret->message = $msg; + 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 + */ + public static function newRedirect( array $reqs, $redirectTarget, $redirectApiData = null ) { + if ( !$reqs ) { + throw new \InvalidArgumentException( '$reqs may not be empty' ); + } + + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::REDIRECT; + $ret->neededRequests = $reqs; + $ret->redirectTarget = $redirectTarget; + $ret->redirectApiData = $redirectApiData; + return $ret; + } + +} diff --git a/includes/auth/ButtonAuthenticationRequest.php b/includes/auth/ButtonAuthenticationRequest.php new file mode 100644 index 000000000000..055d7ea49f0c --- /dev/null +++ b/includes/auth/ButtonAuthenticationRequest.php @@ -0,0 +1,106 @@ +<?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 + */ + 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/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php b/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php new file mode 100644 index 000000000000..070da9f5427f --- /dev/null +++ b/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php @@ -0,0 +1,102 @@ +<?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; +use User; + +/** + * 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 ) { + $block = $user->isBlockedFromCreateAccount(); + if ( $block ) { + $errorParams = [ + $block->getTarget(), + $block->mReason ?: \Message::newFromKey( 'blockednoreason' )->text(), + $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/includes/auth/ConfirmLinkAuthenticationRequest.php b/includes/auth/ConfirmLinkAuthenticationRequest.php new file mode 100644 index 000000000000..b82914f53ae4 --- /dev/null +++ b/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/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php b/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php new file mode 100644 index 000000000000..180aaae34e3c --- /dev/null +++ b/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php @@ -0,0 +1,150 @@ +<?php + +namespace MediaWiki\Auth; + +use StatusValue; +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 = $state['maybeLink']; + if ( !$maybeLink ) { + return AuthenticationResponse::newAbstain(); + } + + $req = new ConfirmLinkAuthenticationRequest( $maybeLink ); + return AuthenticationResponse::newUI( + [ $req ], + wfMessage( 'authprovider-confirmlink-message' ) + ); + } + + /** + * 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-failure-line', $description, $status->getMessage()->text() + ) ); + } + } + return AuthenticationResponse::newUI( + [ + new ButtonAuthenticationRequest( + 'linkOk', wfMessage( 'ok' ), wfMessage( 'authprovider-confirmlink-ok-help' ) + ) + ], + $combinedStatus->getMessage( 'authprovider-confirmlink-failed' ) + ); + } +} diff --git a/includes/auth/CreateFromLoginAuthenticationRequest.php b/includes/auth/CreateFromLoginAuthenticationRequest.php new file mode 100644 index 000000000000..949302d8bc40 --- /dev/null +++ b/includes/auth/CreateFromLoginAuthenticationRequest.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; + +/** + * This transfers state between the login and account creation flows. + * + * AuthManager::getAuthenticationRequests() won't return this type, but it + * may be passed to 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; + } + + public function getFieldInfo() { + return []; + } + + public function loadFromSubmission( array $data ) { + return true; + } +} diff --git a/includes/auth/CreatedAccountAuthenticationRequest.php b/includes/auth/CreatedAccountAuthenticationRequest.php new file mode 100644 index 000000000000..48a6e1d36c26 --- /dev/null +++ b/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/includes/auth/CreationReasonAuthenticationRequest.php b/includes/auth/CreationReasonAuthenticationRequest.php new file mode 100644 index 000000000000..1711aec97488 --- /dev/null +++ b/includes/auth/CreationReasonAuthenticationRequest.php @@ -0,0 +1,22 @@ +<?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 function getFieldInfo() { + return [ + 'reason' => [ + 'type' => 'string', + 'label' => wfMessage( 'createacct-reason' ), + 'help' => wfMessage( 'createacct-reason-help' ), + ], + ]; + } +} diff --git a/includes/auth/LegacyHookPreAuthenticationProvider.php b/includes/auth/LegacyHookPreAuthenticationProvider.php new file mode 100644 index 000000000000..1a8a75892dfc --- /dev/null +++ b/includes/auth/LegacyHookPreAuthenticationProvider.php @@ -0,0 +1,202 @@ +<?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, null, LoginForm::USER_MIGRATED, $msg, 'LoginUserMigrated' + ); + } + + $abort = LoginForm::ABORTED; + $msg = null; + if ( !\Hooks::run( 'AbortLogin', [ $user, $password, &$abort, &$msg ] ) ) { + return $this->makeFailResponse( $user, null, $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 ) { + 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, $user, LoginForm::ABORTED, $abortError, 'AbortAutoAccount' + ); + } + } else { + $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(); + } + + /** + * Construct an appropriate failure response + * @param User $user + * @param User|null $creator + * @param int $constant LoginForm constant + * @param string|null $msg Message + * @param string $hook Hook + * @return StatusValue + */ + protected function makeFailResponse( $user, $creator, $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/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php b/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 000000000000..5f5ef79c2569 --- /dev/null +++ b/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,314 @@ +<?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', + ]; + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + $fields, + [ 'user_name' => $username ], + __METHOD__ + ); + if ( !$row ) { + return AuthenticationResponse::newAbstain(); + } + + // 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 = ":A:{$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 ) ) { + $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); + $dbw->update( + 'user', + [ 'user_password' => $pwhash->toString() ], + [ 'user_id' => $row->user_id ], + __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; + } + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->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 ( $this->loginOnly ) { + $pwhash = $this->getPasswordFactory()->newFromCiphertext( null ); + $expiry = null; + // @codeCoverageIgnoreStart + } elseif ( get_class( $req ) === PasswordAuthenticationRequest::class ) { + // @codeCoverageIgnoreEnd + $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/includes/auth/PasswordAuthenticationRequest.php b/includes/auth/PasswordAuthenticationRequest.php new file mode 100644 index 000000000000..187c29ae9f65 --- /dev/null +++ b/includes/auth/PasswordAuthenticationRequest.php @@ -0,0 +1,83 @@ +<?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' ), + ], + ]; + + 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' ), + ]; + } + + return $ret; + } + + public function describeCredentials() { + return [ + 'provider' => wfMessage( 'authmanager-provider-password' ), + 'account' => new \RawMessage( '$1', [ $this->username ] ), + ]; + } +} diff --git a/includes/auth/PasswordDomainAuthenticationRequest.php b/includes/auth/PasswordDomainAuthenticationRequest.php new file mode 100644 index 000000000000..ddad54b2522e --- /dev/null +++ b/includes/auth/PasswordDomainAuthenticationRequest.php @@ -0,0 +1,83 @@ +<?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 + */ + 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/includes/auth/PreAuthenticationProvider.php b/includes/auth/PreAuthenticationProvider.php new file mode 100644 index 000000000000..846d16e26503 --- /dev/null +++ b/includes/auth/PreAuthenticationProvider.php @@ -0,0 +1,120 @@ +<?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 is a check that must pass for authentication + * to proceed. + * + * A PreAuthenticationProvider is used to supply arbitrary checks to be + * performed before the PrimaryAuthenticationProviders are consulted during the + * login process. Possible uses include checking that a per-IP throttle has not + * been reached or that a captcha has been solved. + * + * @ingroup Auth + * @since 1.27 + */ +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 + * @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 + */ + 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(). + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate ); + + /** + * Post-creation callback + * @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 + */ + 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 + * @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 + */ + public function postAccountLink( $user, AuthenticationResponse $response ); + +} diff --git a/includes/auth/PrimaryAuthenticationProvider.php b/includes/auth/PrimaryAuthenticationProvider.php new file mode 100644 index 000000000000..169e7f138f71 --- /dev/null +++ b/includes/auth/PrimaryAuthenticationProvider.php @@ -0,0 +1,334 @@ +<?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 determines which user is trying to log in. + * + * A PrimaryAuthenticationProvider is used as part of presenting a login form + * to authenticate a user. In particular, the PrimaryAuthenticationProvider + * takes form data and determines the 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. + * + * This interface also provides methods for changing authentication data such + * as passwords and for creating new users who can later be authenticated with + * this provider. + * + * @ingroup Auth + * @since 1.27 + */ +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'; + + /** + * 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 + * @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 + */ + public function postAuthentication( $user, AuthenticationResponse $response ); + + /** + * Test whether the named user exists + * @param string $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 + * @param string $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. + * + * @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. + * + * @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 + * + * Called when the account creation process ends. + * + * @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 + */ + 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(). + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate ); + + /** + * 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 + * @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 + */ + public function postAccountLink( $user, AuthenticationResponse $response ); + +} diff --git a/includes/auth/RememberMeAuthenticationRequest.php b/includes/auth/RememberMeAuthenticationRequest.php new file mode 100644 index 000000000000..d487e31092f8 --- /dev/null +++ b/includes/auth/RememberMeAuthenticationRequest.php @@ -0,0 +1,64 @@ +<?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, + ] + ]; + } +} diff --git a/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php b/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php new file mode 100644 index 000000000000..2e51cf22c1ea --- /dev/null +++ b/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php @@ -0,0 +1,132 @@ +<?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 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(); + } + } + + if ( isset( $data->req ) ) { + $needReq = $data->req; + } else { + $needReq = new PasswordAuthenticationRequest(); + $needReq->action = AuthManager::ACTION_CHANGE; + } + $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 ); + } + + if ( $req->password !== $req->retype ) { + return AuthenticationResponse::newUI( $needReqs, new \Message( 'badretype' ) ); + } + + $req->username = $user->getName(); + $status = $this->manager->allowsAuthenticationDataChange( $req ); + if ( !$status->isGood() ) { + return AuthenticationResponse::newUI( $needReqs, $status->getMessage() ); + } + $this->manager->changeAuthenticationData( $req ); + + $this->manager->removeAuthenticationSessionData( 'reset-pass' ); + return AuthenticationResponse::newPass(); + } +} diff --git a/includes/auth/SecondaryAuthenticationProvider.php b/includes/auth/SecondaryAuthenticationProvider.php new file mode 100644 index 000000000000..0d52d2500b16 --- /dev/null +++ b/includes/auth/SecondaryAuthenticationProvider.php @@ -0,0 +1,217 @@ +<?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 authentication provider performs additional authentication steps + * after a PrimaryAuthenticationProvider has done its thing. + * + * A SecondaryAuthenticationProvider is used to perform arbitrary checks on an + * authentication request after the user itself has been authenticated. 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. + * + * @ingroup Auth + * @since 1.27 + */ +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 + * @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 + */ + 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. + * + * @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 + * @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 + * @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 + */ + 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(). + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate ); + + /** + * 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/includes/auth/TemporaryPasswordAuthenticationRequest.php b/includes/auth/TemporaryPasswordAuthenticationRequest.php new file mode 100644 index 000000000000..42f0e702235d --- /dev/null +++ b/includes/auth/TemporaryPasswordAuthenticationRequest.php @@ -0,0 +1,105 @@ +<?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 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 bool Do not fail certain operations if the password cannot be mailed, there is a + * backchannel present. + */ + public $hasBackchannel = 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 = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ); + + // 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/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 000000000000..46cbab5a3a52 --- /dev/null +++ b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,454 @@ +<?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(); + } + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->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 ); + } + + $this->setPasswordResetFlag( $username, $status ); + + return AuthenticationResponse::newPass( $username ); + } + + public function testUserCanAuthenticate( $username ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->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 && !$req->hasBackchannel ) { + 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 ) { + $this->sendPasswordResetEmail( $req ); + } + } + + 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 && !$req->hasBackchannel ) { + 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 ) { + $this->sendNewAccountEmail( $user, $creator, $req->password ); + } + + 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/includes/auth/ThrottlePreAuthenticationProvider.php b/includes/auth/ThrottlePreAuthenticationProvider.php new file mode 100644 index 000000000000..e2123efa6197 --- /dev/null +++ b/includes/auth/ThrottlePreAuthenticationProvider.php @@ -0,0 +1,170 @@ +<?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 ); + + // @codeCoverageIgnoreStart + $this->throttleSettings += [ + // @codeCoverageIgnoreEnd + 'accountCreationThrottle' => [ [ + 'count' => $this->config->get( 'AccountCreationThrottle' ), + 'seconds' => 86400, + ] ], + '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 ) { + return \StatusValue::newFatal( 'acct_creation_throttle_hit', $result['count'] ); + } + + 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->logger->error( 'throttler data not found for {user}', [ 'user' => $user->getName() ] ); + return; + } + + foreach ( $data['users'] as $name ) { + $this->passwordAttemptThrottle->clear( $name, $data['ip'] ); + } + } +} diff --git a/includes/auth/Throttler.php b/includes/auth/Throttler.php new file mode 100644 index 000000000000..5b14a3baffb7 --- /dev/null +++ b/includes/auth/Throttler.php @@ -0,0 +1,210 @@ +<?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; +use MediaWiki\Logger\LoggerFactory; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Psr\Log\NullLogger; +use User; + +/** + * 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 = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ); + $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 = wfGlobalCacheKey( '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( [ + 'type' => $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 = wfGlobalCacheKey( '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 {type} 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/includes/auth/UserDataAuthenticationRequest.php b/includes/auth/UserDataAuthenticationRequest.php new file mode 100644 index 000000000000..ee77d7bc0de0 --- /dev/null +++ b/includes/auth/UserDataAuthenticationRequest.php @@ -0,0 +1,88 @@ +<?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 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 = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ); + $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/includes/auth/UsernameAuthenticationRequest.php b/includes/auth/UsernameAuthenticationRequest.php new file mode 100644 index 000000000000..7bf8f1308ed7 --- /dev/null +++ b/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' ), + ], + ]; + } +} |