$wgMWOAuthGrantPermissionGroups.
* Added MWRestrictions as a class to check restrictions on a WebRequest, e.g.
to assert that the request comes from a particular IP range.
+* Added bot passwords, a rights-restricted login mechanism for API-using bots.
=== External library changes in 1.27 ===
* The following response properties from action=login are deprecated, and may
be removed in the future: lgtoken, cookieprefix, sessionid. Clients should
handle cookies to properly manage session state.
+* action=login transparently allows login using bot passwords. Clients should
+ merely need to change the username and password used after setting up a bot
+ password.
=== Action API internal changes in 1.27 ===
* ApiQueryORM removed.
'BlockListPager' => __DIR__ . '/includes/specials/SpecialBlockList.php',
'BlockLogFormatter' => __DIR__ . '/includes/logging/BlockLogFormatter.php',
'BmpHandler' => __DIR__ . '/includes/media/BMP.php',
+ 'BotPassword' => __DIR__ . '/includes/user/BotPassword.php',
'BrokenRedirectsPage' => __DIR__ . '/includes/specials/SpecialBrokenRedirects.php',
'BufferingStatsdDataFactory' => __DIR__ . '/includes/libs/BufferingStatsdDataFactory.php',
'CLIParser' => __DIR__ . '/maintenance/parse.php',
'MediaWiki\\Logger\\Monolog\\WikiProcessor' => __DIR__ . '/includes/debug/logger/monolog/WikiProcessor.php',
'MediaWiki\\Logger\\NullSpi' => __DIR__ . '/includes/debug/logger/NullSpi.php',
'MediaWiki\\Logger\\Spi' => __DIR__ . '/includes/debug/logger/Spi.php',
+ 'MediaWiki\\Session\\BotPasswordSessionProvider' => __DIR__ . '/includes/session/BotPasswordSessionProvider.php',
'MediaWiki\\Session\\CookieSessionProvider' => __DIR__ . '/includes/session/CookieSessionProvider.php',
'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' => __DIR__ . '/includes/session/ImmutableSessionProviderWithCookie.php',
'MediaWiki\\Session\\PHPSessionHandler' => __DIR__ . '/includes/session/PHPSessionHandler.php',
'SpecialBlock' => __DIR__ . '/includes/specials/SpecialBlock.php',
'SpecialBlockList' => __DIR__ . '/includes/specials/SpecialBlockList.php',
'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBooksources.php',
+ 'SpecialBotPasswords' => __DIR__ . '/includes/specials/SpecialBotPasswords.php',
'SpecialCachedPage' => __DIR__ . '/includes/specials/SpecialCachedPage.php',
'SpecialCategories' => __DIR__ . '/includes/specials/SpecialCategories.php',
'SpecialChangeContentModel' => __DIR__ . '/includes/specials/SpecialChangeContentModel.php',
'callUserSetCookiesHook' => true,
) ),
),
+ 'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+ 'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+ 'args' => array( array(
+ 'priority' => 40,
+ ) ),
+ ),
);
/** @} */ # end user accounts }
'highvolume' => 'high-volume',
);
+/**
+ * @var bool Whether to enable bot passwords
+ * @since 1.27
+ */
+$wgEnableBotPasswords = true;
+
+/**
+ * Cluster for the bot_passwords table
+ * @var string|bool If false, the normal cluster will be used
+ * @since 1.27
+ */
+$wgBotPasswordsCluster = false;
+
+/**
+ * Database name for the bot_passwords table
+ *
+ * To use a database with a table prefix, set this variable to
+ * "{$database}-{$prefix}".
+ * @var string|bool If false, the normal database will be used
+ * @since 1.27
+ */
+$wgBotPasswordsDatabase = false;
+
/** @} */ # end of user rights settings
/************************************************************************//**
*
* @file
*/
+
use MediaWiki\Logger\LoggerFactory;
/**
return;
}
+ $authRes = false;
$context = new DerivativeContext( $this->getContext() );
- $context->setRequest( new DerivativeRequest(
- $this->getContext()->getRequest(),
- array(
- 'wpName' => $params['name'],
- 'wpPassword' => $params['password'],
- 'wpDomain' => $params['domain'],
- 'wpLoginToken' => $params['token'],
- 'wpRemember' => ''
- )
- ) );
- $loginForm = new LoginForm();
- $loginForm->setContext( $context );
+ $loginType = 'N/A';
+
+ // Check login token
+ $token = LoginForm::getLoginToken();
+ if ( !$token ) {
+ LoginForm::setLoginToken();
+ $authRes = LoginForm::NEED_TOKEN;
+ } elseif ( !$params['token'] ) {
+ $authRes = LoginForm::NEED_TOKEN;
+ } elseif ( $token !== $params['token'] ) {
+ $authRes = LoginForm::WRONG_TOKEN;
+ }
+
+ // Try bot passwords
+ if ( $authRes === false && $this->getConfig()->get( 'EnableBotPasswords' ) &&
+ strpos( $params['name'], BotPassword::getSeparator() ) !== false
+ ) {
+ $status = BotPassword::login(
+ $params['name'], $params['password'], $this->getRequest()
+ );
+ if ( $status->isOk() ) {
+ $session = $status->getValue();
+ $authRes = LoginForm::SUCCESS;
+ $loginType = 'BotPassword';
+ } else {
+ LoggerFactory::getInstance( 'authmanager' )->info(
+ 'BotPassword login failed: ' . $status->getWikiText()
+ );
+ }
+ }
+
+ // Normal login
+ if ( $authRes === false ) {
+ $context->setRequest( new DerivativeRequest(
+ $this->getContext()->getRequest(),
+ array(
+ 'wpName' => $params['name'],
+ 'wpPassword' => $params['password'],
+ 'wpDomain' => $params['domain'],
+ 'wpLoginToken' => $params['token'],
+ 'wpRemember' => ''
+ )
+ ) );
+ $loginForm = new LoginForm();
+ $loginForm->setContext( $context );
+ $authRes = $loginForm->authenticateUserData();
+ $loginType = 'LoginForm';
+ }
- $authRes = $loginForm->authenticateUserData();
switch ( $authRes ) {
case LoginForm::SUCCESS:
$user = $context->getUser();
// SessionManager/AuthManager are *really* going to break it.
$result['lgtoken'] = $user->getToken();
$result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
- $result['sessionid'] = MediaWiki\Session\SessionManager::getGlobalSession()->getId();
+ $result['sessionid'] = $session->getId();
break;
case LoginForm::NEED_TOKEN:
$result['result'] = 'NeedToken';
- $result['token'] = $loginForm->getLoginToken();
+ $result['token'] = LoginForm::getLoginToken();
// @todo: See above about deprecation
$result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
- $result['sessionid'] = MediaWiki\Session\SessionManager::getGlobalSession()->getId();
+ $result['sessionid'] = $session->getId();
break;
case LoginForm::WRONG_TOKEN:
LoggerFactory::getInstance( 'authmanager' )->info( 'Login attempt', array(
'event' => 'login',
'successful' => $authRes === LoginForm::SUCCESS,
+ 'loginType' => $loginType,
'status' => LoginForm::$statusCodes[$authRes],
) );
}
// 1.27
array( 'dropTable', 'msg_resource_links' ),
array( 'dropTable', 'msg_resource' ),
+ array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
);
}
array( 'addTable', 'uploadstash', 'patch-uploadstash.sql' ),
array( 'addTable', 'user_former_groups', 'patch-user_former_groups.sql' ),
array( 'addTable', 'sites', 'patch-sites.sql' ),
+ array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
# Needed before new field
array( 'convertArchive2' ),
// 1.27
array( 'dropTable', 'msg_resource_links' ),
array( 'dropTable', 'msg_resource' ),
+ array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
);
}
--- /dev/null
+<?php
+/**
+ * Session provider for bot passwords
+ *
+ * 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 Session
+ */
+
+namespace MediaWiki\Session;
+
+use BotPassword;
+use User;
+use WebRequest;
+
+/**
+ * Session provider for bot passwords
+ * @since 1.27
+ */
+class BotPasswordSessionProvider extends ImmutableSessionProviderWithCookie {
+
+ /**
+ * @param array $params Keys include:
+ * - priority: (required) Set the priority
+ * - sessionCookieName: Session cookie name. Default is '_BPsession'.
+ * - sessionCookieOptions: Options to pass to WebResponse::setCookie().
+ */
+ public function __construct( array $params = array() ) {
+ if ( !isset( $params['sessionCookieName'] ) ) {
+ $params['sessionCookieName'] = '_BPsession';
+ }
+ parent::__construct( $params );
+
+ if ( !isset( $params['priority'] ) ) {
+ throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
+ }
+ if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
+ $params['priority'] > SessionInfo::MAX_PRIORITY
+ ) {
+ throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
+ }
+
+ $this->priority = $params['priority'];
+ }
+
+ public function provideSessionInfo( WebRequest $request ) {
+ // Only relevant for the API
+ if ( !defined( 'MW_API' ) ) {
+ return null;
+ }
+
+ // Enabled?
+ if ( !$this->config->get( 'EnableBotPasswords' ) ) {
+ return null;
+ }
+
+ // Have a session ID?
+ $id = $this->getSessionIdFromCookie( $request );
+ if ( $id === null ) {
+ return null;
+ }
+
+ return new SessionInfo( $this->priority, array(
+ 'provider' => $this,
+ 'id' => $id,
+ 'persisted' => true
+ ) );
+ }
+
+ public function newSessionInfo( $id = null ) {
+ // We don't activate by default
+ return null;
+ }
+
+ /**
+ * Create a new session for a request
+ * @param User $user
+ * @param BotPassword $bp
+ * @param WebRequest $request
+ * @return Session
+ */
+ public function newSessionForRequest( User $user, BotPassword $bp, WebRequest $request ) {
+ $id = $this->getSessionIdFromCookie( $request );
+ $info = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+ 'provider' => $this,
+ 'id' => $id,
+ 'userInfo' => UserInfo::newFromUser( $user, true ),
+ 'persisted' => $id !== null,
+ 'metadata' => array(
+ 'centralId' => $bp->getUserCentralId(),
+ 'appId' => $bp->getAppId(),
+ 'token' => $bp->getToken(),
+ 'rights' => \MWGrants::getGrantRights( $bp->getGrants() ),
+ ),
+ ) );
+ $session = $this->getManager()->getSessionFromInfo( $info, $request );
+ $session->persist();
+ return $session;
+ }
+
+ public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
+ $missingKeys = array_diff(
+ array( 'centralId', 'appId', 'token' ),
+ array_keys( $metadata )
+ );
+ if ( $missingKeys ) {
+ $this->logger->info( "Session $info: Missing metadata: " . join( ', ', $missingKeys ) );
+ return false;
+ }
+
+ $bp = BotPassword::newFromCentralId( $metadata['centralId'], $metadata['appId'] );
+ if ( !$bp ) {
+ $this->logger->info(
+ "Session $info: No BotPassword for {$metadata['centralId']} {$metadata['appId']}"
+ );
+ return false;
+ }
+
+ if ( !hash_equals( $metadata['token'], $bp->getToken() ) ) {
+ $this->logger->info( "Session $info: BotPassword token check failed" );
+ return false;
+ }
+
+ $status = $bp->getRestrictions()->check( $request );
+ if ( !$status->isOk() ) {
+ $this->logger->info( "Session $info: Restrictions check failed", $status->getValue() );
+ return false;
+ }
+
+ // Update saved rights
+ $metadata['rights'] = \MWGrants::getGrantRights( $bp->getGrants() );
+
+ return true;
+ }
+
+ public function preventSessionsForUser( $username ) {
+ BotPassword::removeAllPasswordsForUser( $username );
+ }
+
+ public function getAllowedUserRights( SessionBackend $backend ) {
+ if ( $backend->getProvider() !== $this ) {
+ throw new InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
+ }
+ $data = $backend->getProviderMetadata();
+ if ( $data ) {
+ return $data['rights'];
+ }
+
+ // Should never happen
+ $this->logger->debug( __METHOD__ . ': No provider metadata, returning no rights allowed' );
+ return array();
+ }
+}
'Unblock' => 'SpecialUnblock',
'BlockList' => 'SpecialBlockList',
'ChangePassword' => 'SpecialChangePassword',
+ 'BotPasswords' => 'SpecialBotPasswords',
'PasswordReset' => 'SpecialPasswordReset',
'DeletedContributions' => 'DeletedContributionsPage',
'Preferences' => 'SpecialPreferences',
--- /dev/null
+<?php
+/**
+ * Implements Special:BotPasswords
+ *
+ * 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 SpecialPage
+ */
+
+/**
+ * Let users manage bot passwords
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialBotPasswords extends FormSpecialPage {
+
+ /** @var int Central user ID */
+ private $userId = 0;
+
+ /** @var BotPassword|null Bot password being edited, if any */
+ private $botPassword = null;
+
+ /** @var string Operation being performed: create, update, delete */
+ private $operation = null;
+
+ /** @var string New password set, for communication between onSubmit() and onSuccess() */
+ private $password = null;
+
+ public function __construct() {
+ parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isListed() {
+ return $this->getConfig()->get( 'EnableBotPasswords' );
+ }
+
+ /**
+ * Main execution point
+ * @param string|null $par
+ */
+ function execute( $par ) {
+ $this->getOutput()->disallowUserJs();
+ $this->requireLogin();
+
+ $par = trim( $par );
+ if ( strlen( $par ) === 0 ) {
+ $par = null;
+ } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
+ throw new ErrorPageError( 'botpasswords', 'botpasswords-bad-appid',
+ array( htmlspecialchars( $par ) ) );
+ }
+
+ parent::execute( $par );
+ }
+
+ protected function checkExecutePermissions( User $user ) {
+ parent::checkExecutePermissions( $user );
+
+ if ( !$this->getConfig()->get( 'EnableBotPasswords' ) ) {
+ throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
+ }
+
+ $this->userId = CentralIdLookup::factory()->centralIdFromLocalUser( $this->getUser() );
+ if ( !$this->userId ) {
+ throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
+ }
+ }
+
+ protected function getFormFields() {
+ $that = $this;
+ $user = $this->getUser();
+ $request = $this->getRequest();
+
+ $fields = array();
+
+ if ( $this->par !== null ) {
+ $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
+ if ( !$this->botPassword ) {
+ $this->botPassword = BotPassword::newUnsaved( array(
+ 'centralId' => $this->userId,
+ 'appId' => $this->par,
+ ) );
+ }
+
+ $sep = BotPassword::getSeparator();
+ $fields[] = array(
+ 'type' => 'info',
+ 'label-message' => 'username',
+ 'default' => $this->getUser()->getName() . $sep . $this->par
+ );
+
+ if ( $this->botPassword->isSaved() ) {
+ $fields['resetPassword'] = array(
+ 'type' => 'check',
+ 'label-message' => 'botpasswords-label-resetpassword',
+ );
+ }
+
+ $lang = $this->getLanguage();
+ $showGrants = MWGrants::getValidGrants();
+ $fields['grants'] = array(
+ 'type' => 'checkmatrix',
+ 'label-message' => 'botpasswords-label-grants',
+ 'help-message' => 'botpasswords-help-grants',
+ 'columns' => array(
+ $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
+ ),
+ 'rows' => array_combine(
+ array_map( 'MWGrants::getGrantsLink', $showGrants ),
+ $showGrants
+ ),
+ 'default' => array_map(
+ function( $g ) {
+ return "grant-$g";
+ },
+ $this->botPassword->getGrants()
+ ),
+ 'tooltips' => array_combine(
+ array_map( 'MWGrants::getGrantsLink', $showGrants ),
+ array_map(
+ function( $rights ) use ( $lang ) {
+ return $lang->semicolonList( array_map( 'User::getRightDescription', $rights ) );
+ },
+ array_intersect_key( MWGrants::getRightsByGrant(), array_flip( $showGrants ) )
+ )
+ ),
+ 'force-options-on' => array_map(
+ function( $g ) {
+ return "grant-$g";
+ },
+ MWGrants::getHiddenGrants()
+ ),
+ );
+
+ $fields['restrictions'] = array(
+ 'type' => 'textarea',
+ 'label-message' => 'botpasswords-label-restrictions',
+ 'required' => true,
+ 'default' => $this->botPassword->getRestrictions()->toJson( true ),
+ 'rows' => 5,
+ 'validation-callback' => function ( $v ) {
+ try {
+ MWRestrictions::newFromJson( $v );
+ return true;
+ } catch ( InvalidArgumentException $ex ) {
+ return $ex->getMessage();
+ }
+ },
+ );
+
+ } else {
+ $dbr = BotPassword::getDB( DB_SLAVE );
+ $res = $dbr->select(
+ 'bot_passwords',
+ array( 'bp_app_id' ),
+ array( 'bp_user' => $this->userId ),
+ __METHOD__
+ );
+ foreach ( $res as $row ) {
+ $fields[] = array(
+ 'section' => 'existing',
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => Linker::link(
+ $this->getPageTitle( $row->bp_app_id ),
+ htmlspecialchars( $row->bp_app_id ),
+ array(),
+ array(),
+ array( 'known' )
+ ),
+ );
+ }
+
+ $fields['appId'] = array(
+ 'section' => 'createnew',
+ 'type' => 'textwithbutton',
+ 'label-message' => 'botpasswords-label-appid',
+ 'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
+ 'required' => true,
+ 'size' => BotPassword::APPID_MAXLENGTH,
+ 'maxlength' => BotPassword::APPID_MAXLENGTH,
+ 'validation-callback' => function ( $v ) {
+ $v = trim( $v );
+ return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
+ },
+ );
+
+ $fields[] = array(
+ 'type' => 'hidden',
+ 'default' => 'new',
+ 'name' => 'op',
+ );
+ }
+
+ return $fields;
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setId( 'mw-botpasswords-form' );
+ $form->setTableId( 'mw-botpasswords-table' );
+ $form->addPreText( $this->msg( 'botpasswords-summary' )->parseAsBlock() );
+ $form->suppressDefaultSubmit();
+
+ if ( $this->par !== null ) {
+ if ( $this->botPassword->isSaved() ) {
+ $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
+ $form->addButton( array(
+ 'name' => 'op',
+ 'value' => 'update',
+ 'label-message' => 'botpasswords-label-update',
+ 'flags' => array( 'primary', 'progressive' ),
+ ) );
+ $form->addButton( array(
+ 'name' => 'op',
+ 'value' => 'delete',
+ 'label-message' => 'botpasswords-label-delete',
+ 'flags' => array( 'destructive' ),
+ ) );
+ } else {
+ $form->setWrapperLegendMsg( 'botpasswords-createnew' );
+ $form->addButton( array(
+ 'name' => 'op',
+ 'value' => 'create',
+ 'label-message' => 'botpasswords-label-create',
+ 'flags' => array( 'primary', 'constructive' ),
+ ) );
+ }
+
+ $form->addButton( array(
+ 'name' => 'op',
+ 'value' => 'cancel',
+ 'label-message' => 'botpasswords-label-cancel'
+ ) );
+ }
+ }
+
+ public function onSubmit( array $data ) {
+ $op = $this->getRequest()->getVal( 'op', '' );
+
+ switch ( $op ) {
+ case 'new':
+ $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
+ return false;
+
+ case 'create':
+ $this->operation = 'insert';
+ return $this->save( $data );
+
+ case 'update':
+ $this->operation = 'update';
+ return $this->save( $data );
+
+ case 'delete':
+ $this->operation = 'delete';
+ $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
+ if ( $bp ) {
+ $bp->delete();
+ }
+ return Status::newGood();
+
+ case 'cancel':
+ $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
+ return false;
+ }
+
+ return false;
+ }
+
+ private function save( array $data ) {
+ $bp = BotPassword::newUnsaved( array(
+ 'centralId' => $this->userId,
+ 'appId' => $this->par,
+ 'restrictions' => MWRestrictions::newFromJson( $data['restrictions'] ),
+ 'grants' => array_merge(
+ MWGrants::getHiddenGrants(),
+ preg_replace( '/^grant-/', '', $data['grants'] )
+ )
+ ) );
+
+ if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
+ $this->password = PasswordFactory::generateRandomPasswordString(
+ max( 32, $this->getConfig()->get( 'MinimalPasswordLength' ) )
+ );
+ $passwordFactory = new PasswordFactory();
+ $passwordFactory->init( RequestContext::getMain()->getConfig() );
+ $password = $passwordFactory->newFromPlaintext( $this->password );
+ } else {
+ $password = null;
+ }
+
+ if ( $bp->save( $this->operation, $password ) ) {
+ return Status::newGood();
+ } else {
+ // Messages: botpasswords-insert-failed, botpasswords-update-failed
+ return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
+ }
+ }
+
+ public function onSuccess() {
+ $out = $this->getOutput();
+
+ switch ( $this->operation ) {
+ case 'insert':
+ $out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() );
+ $out->addWikiMsg( 'botpasswords-created-body', $this->par );
+ break;
+
+ case 'update':
+ $out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() );
+ $out->addWikiMsg( 'botpasswords-updated-body', $this->par );
+ break;
+
+ case 'delete':
+ $out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() );
+ $out->addWikiMsg( 'botpasswords-deleted-body', $this->par );
+ $this->password = null;
+ break;
+ }
+
+ if ( $this->password !== null ) {
+ $sep = BotPassword::getSeparator();
+ $out->addWikiMsg(
+ 'botpasswords-newpassword',
+ htmlspecialchars( $this->getUser()->getName() . $sep . $this->par ),
+ htmlspecialchars( $this->password )
+ );
+ $this->password = null;
+ }
+
+ $out->addReturnTo( $this->getPageTitle() );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+}
--- /dev/null
+<?php
+/**
+ * Utility class for bot passwords
+ *
+ * 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
+ */
+
+use MediaWiki\Session\BotPasswordSessionProvider;
+use MediaWiki\Session\SessionInfo;
+
+/**
+ * Utility class for bot passwords
+ * @since 1.27
+ */
+class BotPassword implements IDBAccessObject {
+
+ const APPID_MAXLENGTH = 32;
+
+ /** @var bool */
+ private $isSaved;
+
+ /** @var int */
+ private $centralId;
+
+ /** @var string */
+ private $appId;
+
+ /** @var string */
+ private $token;
+
+ /** @var MWRestrictions */
+ private $restrictions;
+
+ /** @var string[] */
+ private $grants;
+
+ /** @var int */
+ private $flags = self::READ_NORMAL;
+
+ /**
+ * @param object $row bot_passwords database row
+ * @param bool $isSaved Whether the bot password was read from the database
+ * @param int $flags IDBAccessObject read flags
+ */
+ protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
+ $this->isSaved = $isSaved;
+ $this->flags = $flags;
+
+ $this->centralId = (int)$row->bp_user;
+ $this->appId = $row->bp_app_id;
+ $this->token = $row->bp_token;
+ $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
+ $this->grants = FormatJson::decode( $row->bp_grants );
+ }
+
+ /**
+ * Get a database connection for the bot passwords database
+ * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_SLAVE.
+ * @return DatabaseBase
+ */
+ public static function getDB( $db ) {
+ global $wgBotPasswordsCluster, $wgBotPasswordsDatabase;
+
+ $lb = $wgBotPasswordsCluster
+ ? wfGetLBFactory()->getExternalLB( $wgBotPasswordsCluster )
+ : wfGetLB( $wgBotPasswordsDatabase );
+ return $lb->getConnectionRef( $db, array(), $wgBotPasswordsDatabase );
+ }
+
+ /**
+ * Load a BotPassword from the database
+ * @param User $user
+ * @param string $appId
+ * @param int $flags IDBAccessObject read flags
+ * @return BotPassword|null
+ */
+ public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
+ $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
+ $user, CentralIdLookup::AUDIENCE_RAW, $flags
+ );
+ return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
+ }
+
+ /**
+ * Load a BotPassword from the database
+ * @param int $centralId from CentralIdLookup
+ * @param string $appId
+ * @param int $flags IDBAccessObject read flags
+ * @return BotPassword|null
+ */
+ public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+ $db = self::getDB( $index );
+ $row = $db->selectRow(
+ 'bot_passwords',
+ array( 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ),
+ array( 'bp_user' => $centralId, 'bp_app_id' => $appId ),
+ __METHOD__,
+ $options
+ );
+ return $row ? new self( $row, true, $flags ) : null;
+ }
+
+ /**
+ * Create an unsaved BotPassword
+ * @param array $data Data to use to create the bot password. Keys are:
+ * - user: (User) User object to create the password for. Overrides username and centralId.
+ * - username: (string) Username to create the password for. Overrides centralId.
+ * - centralId: (int) User central ID to create the password for.
+ * - appId: (string) App ID for the password.
+ * - restrictions: (MWRestrictions, optional) Restrictions.
+ * - grants: (string[], optional) Grants.
+ * @param int $flags IDBAccessObject read flags
+ * @return BotPassword|null
+ */
+ public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
+ $row = (object)array(
+ 'bp_user' => 0,
+ 'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
+ 'bp_token' => '**unsaved**',
+ 'bp_restrictions' => isset( $data['restrictions'] )
+ ? $data['restrictions']
+ : MWRestrictions::newDefault(),
+ 'bp_grants' => isset( $data['grants'] ) ? $data['grants'] : array(),
+ );
+
+ if (
+ $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
+ !$row->bp_restrictions instanceof MWRestrictions ||
+ !is_array( $row->bp_grants )
+ ) {
+ return null;
+ }
+
+ $row->bp_restrictions = $row->bp_restrictions->toJson();
+ $row->bp_grants = FormatJson::encode( $row->bp_grants );
+
+ if ( isset( $data['user'] ) ) {
+ if ( !$data['user'] instanceof User ) {
+ return null;
+ }
+ $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
+ $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
+ );
+ } elseif ( isset( $data['username'] ) ) {
+ $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
+ $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
+ );
+ } elseif ( isset( $data['centralId'] ) ) {
+ $row->bp_user = $data['centralId'];
+ }
+ if ( !$row->bp_user ) {
+ return null;
+ }
+
+ return new self( $row, false, $flags );
+ }
+
+ /**
+ * Indicate whether this is known to be saved
+ * @return bool
+ */
+ public function isSaved() {
+ return $this->isSaved;
+ }
+
+ /**
+ * Get the central user ID
+ * @return int
+ */
+ public function getUserCentralId() {
+ return $this->centralId;
+ }
+
+ /**
+ * Get the app ID
+ * @return string
+ */
+ public function getAppId() {
+ return $this->appId;
+ }
+
+ /**
+ * Get the token
+ * @return string
+ */
+ public function getToken() {
+ return $this->token;
+ }
+
+ /**
+ * Get the restrictions
+ * @return MWRestrictions
+ */
+ public function getRestrictions() {
+ return $this->restrictions;
+ }
+
+ /**
+ * Get the grants
+ * @return string[]
+ */
+ public function getGrants() {
+ return $this->grants;
+ }
+
+ /**
+ * Get the separator for combined user name + app ID
+ * @return string
+ */
+ public static function getSeparator() {
+ global $wgUserrightsInterwikiDelimiter;
+ return $wgUserrightsInterwikiDelimiter;
+ }
+
+ /**
+ * Get the password
+ * @return Password
+ */
+ protected function getPassword() {
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
+ $db = self::getDB( $index );
+ $password = $db->selectField(
+ 'bot_passwords',
+ 'bp_password',
+ array( 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ),
+ __METHOD__,
+ $options
+ );
+ if ( $password === false ) {
+ return PasswordFactory::newInvalidPassword();
+ }
+
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ try {
+ return $passwordFactory->newFromCiphertext( $password );
+ } catch ( PasswordError $ex ) {
+ return PasswordFactory::newInvalidPassword();
+ }
+ }
+
+ /**
+ * Save the BotPassword to the database
+ * @param string $operation 'update' or 'insert'
+ * @param Password|null $password Password to set.
+ * @return bool Success
+ */
+ public function save( $operation, Password $password = null ) {
+ $conds = array(
+ 'bp_user' => $this->centralId,
+ 'bp_app_id' => $this->appId,
+ );
+ $fields = array(
+ 'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
+ 'bp_restrictions' => $this->restrictions->toJson(),
+ 'bp_grants' => FormatJson::encode( $this->grants ),
+ );
+
+ if ( $password !== null ) {
+ $fields['bp_password'] = $password->toString();
+ } elseif ( $operation === 'insert' ) {
+ $fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
+ }
+
+ $dbw = self::getDB( DB_MASTER );
+ switch ( $operation ) {
+ case 'insert':
+ $dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, array( 'IGNORE' ) );
+ break;
+
+ case 'update':
+ $dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
+ break;
+
+ default:
+ return false;
+ }
+ $ok = (bool)$dbw->affectedRows();
+ if ( $ok ) {
+ $this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
+ $this->isSaved = true;
+ }
+ return $ok;
+ }
+
+ /**
+ * Delete the BotPassword from the database
+ * @return bool Success
+ */
+ public function delete() {
+ $conds = array(
+ 'bp_user' => $this->centralId,
+ 'bp_app_id' => $this->appId,
+ );
+ $dbw = self::getDB( DB_MASTER );
+ $dbw->delete( 'bot_passwords', $conds, __METHOD__ );
+ $ok = (bool)$dbw->affectedRows();
+ if ( $ok ) {
+ $this->token = '**unsaved**';
+ $this->isSaved = false;
+ }
+ return $ok;
+ }
+
+ /**
+ * Invalidate all passwords for a user, by name
+ * @param string $username User name
+ * @return bool Whether any passwords were invalidated
+ */
+ public static function invalidateAllPasswordsForUser( $username ) {
+ $centralId = CentralIdLookup::factory()->centralIdFromName(
+ $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+ );
+ return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
+ }
+
+ /**
+ * Invalidate all passwords for a user, by central ID
+ * @param int $centralId
+ * @return bool Whether any passwords were invalidated
+ */
+ public static function invalidateAllPasswordsForCentralId( $centralId ) {
+ $dbw = self::getDB( DB_MASTER );
+ $dbw->update(
+ 'bot_passwords',
+ array( 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ),
+ array( 'bp_user' => $centralId ),
+ __METHOD__
+ );
+ return (bool)$dbw->affectedRows();
+ }
+
+ /**
+ * Remove all passwords for a user, by name
+ * @param string $username User name
+ * @return bool Whether any passwords were removed
+ */
+ public static function removeAllPasswordsForUser( $username ) {
+ $centralId = CentralIdLookup::factory()->centralIdFromName(
+ $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+ );
+ return $centralId && self::removeAllPasswordsForCentralId( $centralId );
+ }
+
+ /**
+ * Remove all passwords for a user, by central ID
+ * @param int $centralId
+ * @return bool Whether any passwords were removed
+ */
+ public static function removeAllPasswordsForCentralId( $centralId ) {
+ $dbw = self::getDB( DB_MASTER );
+ $dbw->delete(
+ 'bot_passwords',
+ array( 'bp_user' => $centralId ),
+ __METHOD__
+ );
+ return (bool)$dbw->affectedRows();
+ }
+
+ /**
+ * Try to log the user in
+ * @param string $username Combined user name and app ID
+ * @param string $password Supplied password
+ * @param WebRequest $request
+ * @return Status On success, the good status's value is the new Session object
+ */
+ public static function login( $username, $password, WebRequest $request ) {
+ global $wgEnableBotPasswords;
+
+ if ( !$wgEnableBotPasswords ) {
+ return Status::newFatal( 'botpasswords-disabled' );
+ }
+
+ $manager = MediaWiki\Session\SessionManager::singleton();
+ $provider = $manager->getProvider(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider'
+ );
+ if ( !$provider ) {
+ return Status::newFatal( 'botpasswords-no-provider' );
+ }
+
+ // Split name into name+appId
+ $sep = self::getSeparator();
+ if ( strpos( $username, $sep ) === false ) {
+ return Status::newFatal( 'botpasswords-invalid-name', $sep );
+ }
+ list( $name, $appId ) = explode( $sep, $username, 2 );
+
+ // Find the named user
+ $user = User::newFromName( $name );
+ if ( !$user || $user->isAnon() ) {
+ return Status::newFatal( 'nosuchuser', $name );
+ }
+
+ // Get the bot password
+ $bp = self::newFromUser( $user, $appId );
+ if ( !$bp ) {
+ return Status::newFatal( 'botpasswords-not-exist', $name, $appId );
+ }
+
+ // Check restrictions
+ $status = $bp->getRestrictions()->check( $request );
+ if ( !$status->isOk() ) {
+ return Status::newFatal( 'botpasswords-restriction-failed' );
+ }
+
+ // Check the password
+ if ( !$bp->getPassword()->equals( $password ) ) {
+ return Status::newFatal( 'wrongpassword' );
+ }
+
+ // Ok! Create the session.
+ return Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) );
+ }
+}
return self::$instances[$providerId];
}
+ /**
+ * Reset internal cache for unit testing
+ */
+ public static function resetCache() {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
+ }
+ self::$instances = array();
+ }
+
final public function getProviderId() {
return $this->providerId;
}
),
__METHOD__
);
+
+ // When the main password is changed, invalidate all bot passwords too
+ BotPassword::invalidateAllPasswordsForUser( $this->getName() );
}
/**
"resetpass_submit": "Set password and log in",
"changepassword-success": "Your password has been changed successfully!",
"changepassword-throttled": "You have made too many recent login attempts.\nPlease wait $1 before trying again.",
+ "botpasswords": "Bot passwords",
+ "botpasswords-summary": "<em>Bot passwords</em> allow access to a user account via the API without using the account's main login credentials. The user rights available when logged in with a bot password may be restricted.\n\nIf you don't know why you might want to do this, you should probably not do it. No one should ever ask you to generate one of these and give it to them.",
+ "botpasswords-disabled": "Bot passwords are disabled.",
+ "botpasswords-no-central-id": "To use bot passwords, you must be logged in to a centralized account.",
+ "botpasswords-existing": "Existing bot passwords",
+ "botpasswords-createnew": "Create a new bot password",
+ "botpasswords-editexisting": "Edit and existing bot password",
+ "botpasswords-label-appid": "Bot name:",
+ "botpasswords-label-create": "Create",
+ "botpasswords-label-update": "Update",
+ "botpasswords-label-cancel": "Cancel",
+ "botpasswords-label-delete": "Delete",
+ "botpasswords-label-resetpassword": "Reset the password",
+ "botpasswords-label-grants": "Applicable grants:",
+ "botpasswords-help-grants": "Each grant gives access to listed user rights that a user account already has. See the [[Special:ListGrants|table of grants]] for more information.",
+ "botpasswords-label-restrictions": "Usage restrictions:",
+ "botpasswords-label-grants-column": "Granted",
+ "botpasswords-bad-appid": "The bot name \"$1\" is not valid.",
+ "botpasswords-insert-failed": "Failed to add bot name \"$1\". Was it already added?",
+ "botpasswords-update-failed": "Failed to update bot name \"$1\". Was it deleted?",
+ "botpasswords-created-title": "Bot password created",
+ "botpasswords-created-body": "The bot password \"$1\" was created successfully.",
+ "botpasswords-updated-title": "Bot password updated",
+ "botpasswords-updated-body": "The bot password \"$1\" was updated successfully.",
+ "botpasswords-deleted-title": "Bot password deleted",
+ "botpasswords-deleted-body": "The bot password \"$1\" was deleted.",
+ "botpasswords-newpassword": "The new password to log in with <strong>$1</strong> is <strong>$2</strong>. <em>Please record this for future reference.</em>",
+ "botpasswords-no-provider": "BotPasswordsSessionProvider is not available.",
+ "botpasswords-restriction-failed": "Bot password restrictions prevent this login.",
+ "botpasswords-invalid-name": "The username specified does not contain the bot password separator (\"$1\").",
+ "botpasswords-not-exist": "User \"$1\" does not have a bot password named \"$2\".",
"resetpass_forbidden": "Passwords cannot be changed",
"resetpass-no-info": "You must be logged in to access this page directly.",
"resetpass-submit-loggedin": "Change password",
"resetpass_submit": "Submit button on [[Special:ChangePassword]]",
"changepassword-success": "Used in [[Special:ChangePassword]].",
"changepassword-throttled": "Error message shown at [[Special:ChangePassword]] after the user has tried to login with incorrect password too many times.\n\nThe user has to wait a certain time before trying to log in again.\n\nParameters:\n* $1 - the time to wait before the next login attempt. Automatically formatted using the following duration messages:\n** {{msg-mw|Duration-millennia}}\n** {{msg-mw|Duration-centuries}}\n** {{msg-mw|Duration-decades}}\n** {{msg-mw|Duration-years}}\n** {{msg-mw|Duration-weeks}}\n** {{msg-mw|Duration-days}}\n** {{msg-mw|Duration-hours}}\n** {{msg-mw|Duration-minutes}}\n** {{msg-mw|Duration-seconds}}\n\nThis is a protection against robots trying to find the password by trying lots of them.\nThe number of attempts and waiting time are configured via [[mw:Manual:$wgPasswordAttemptThrottle|$wgPasswordAttemptThrottle]].\nThis message is used in html.\n\nSee also:\n* {{msg-mw|Changeemail-throttled}}",
+ "botpasswords": "The name of the special page [[Special:BotPasswords]].",
+ "botpasswords-bad-appid": "Used as an error message when an invalid \"bot name\" is supplied on [[Special:BotPasswords]]. Parameters:\n* $1 - The rejected bot name.",
+ "botpasswords-created-body": "Success message when a new bot password is created. Parameters:\n* $1 - Bot name",
+ "botpasswords-created-title": "Title of the success page when a new bot password is created.",
+ "botpasswords-createnew": "Form section label for the part of the form related to creating a new bot password.",
+ "botpasswords-deleted-body": "Success message when a bot password is deleted. Parameters:\n* $1 - Bot name",
+ "botpasswords-deleted-title": "Title of the success page when a bot password is deleted.",
+ "botpasswords-disabled": "Error message displayed when bot passwords are not enabled (<code>$wgEnableBotPasswords = false</code>).",
+ "botpasswords-editexisting": "Form section label for the part of the form related to editing an existing bot password.",
+ "botpasswords-existing": "Form section label for the part of the form listing the user's existing bot passwords.",
+ "botpasswords-help-grants": "Help text for the grant selection checkmatrix.",
+ "botpasswords-insert-failed": "Error message when saving a new bot password failed. It's likely that the failure was because the user resubmitted the form after a previous successful save. Parameters:\n* $1 - Bot name",
+ "botpasswords-label-appid": "Form field label for the \"bot name\", internally known as the \"application ID\".",
+ "botpasswords-label-cancel": "Button label for a button to cancel the creation or edit of a bot password.\n{{Identical|Cancel}}",
+ "botpasswords-label-create": "Button label for the button to create a new bot password.\n{{Identical|Create}}",
+ "botpasswords-label-delete": "Button label for the button to delete a bot password.\n{{Identical|Delete}}",
+ "botpasswords-label-grants": "Label for the checkmatrix for selecting grants allowed when the bot password is used.",
+ "botpasswords-label-grants-column": "Label for the checkbox column on the checkmatrix for selecting grants allowed when the bot password is used.",
+ "botpasswords-label-resetpassword": "Label for the checkbox to reset the actual password for the current bot password.",
+ "botpasswords-label-restrictions": "Label for the textarea field in which JSON defining access restrictions (e.g. which IP address ranges are allowed) is entered.",
+ "botpasswords-label-update": "Button label for the button to save changes to a bot password.",
+ "botpasswords-newpassword": "Success message to display the new password when a bot password is created or updated. Parameters:\n* $1 - User name to be used for login.\n* $2 - Password to be used for login.",
+ "botpasswords-no-central-id": "Error message displayed when the current user does not have a central ID (e.g. they're not logged in or not attached in something like CentralAuth).",
+ "botpasswords-no-provider": "Error message when login is attempted but the BotPasswordsSessionProvider is not included in <code>$wgSessionProviders</code>.",
+ "botpasswords-restriction-failed": "Error message when login is rejected because the configured restrictions were not satisfied.",
+ "botpasswords-invalid-name": "Error message when a username lacking the separator character is passed to BotPassword. Parameters:\n* $1 - The separator character.",
+ "botpasswords-not-exist": "Error message when a username exists but does not a bot password for the given \"bot name\". Parameters:\n* $1 - username\n* $2 - bot name",
+ "botpasswords-summary": "Explanatory text shown at the top of [[Special:BotPasswords]].",
+ "botpasswords-update-failed": "Error message when saving changes to an existing bot password failed. It's likely that the failure was because the user deleted the bot password in another browser window. Parameters:\n* $1 - Bot name",
+ "botpasswords-updated-body": "Success message when a bot password is updated. Parameters:\n* $1 - Bot name",
+ "botpasswords-updated-title": "Title of the success page when a bot password is updated.",
"resetpass_forbidden": "Used as error message in changing password. Maybe the external auth plugin won't allow local password changes.",
"resetpass-no-info": "Error message for [[Special:ChangePassword]].\n\nParameters:\n* $1 (unused) - a link to [[Special:UserLogin]] with {{msg-mw|loginreqlink}} as link description",
"resetpass-submit-loggedin": "Button on [[Special:ResetPass]] to submit new password.\n\n{{Identical|Change password}}",
'Blankpage' => array( 'BlankPage' ),
'Block' => array( 'Block', 'BlockIP', 'BlockUser' ),
'Booksources' => array( 'BookSources' ),
+ 'BotPasswords' => array( 'BotPasswords' ),
'BrokenRedirects' => array( 'BrokenRedirects' ),
'Categories' => array( 'Categories' ),
'ChangeContentModel' => array( 'ChangeContentModel' ),
--- /dev/null
+--
+-- This table contains a user's bot passwords: passwords that allow access to
+-- the account via the API with limited rights.
+--
+CREATE TABLE /*_*/bot_passwords (
+ -- Foreign key to user.user_id
+ bp_user int NOT NULL,
+
+ -- Application identifier
+ bp_app_id varbinary(32) NOT NULL,
+
+ -- Password hashes, like user.user_password
+ bp_password tinyblob NOT NULL,
+
+ -- Like user.user_token
+ bp_token binary(32) NOT NULL default '',
+
+ -- JSON blob for MWRestrictions
+ bp_restrictions blob NOT NULL,
+
+ -- Grants allowed to the account when authenticated with this bot-password
+ bp_grants blob NOT NULL,
+
+ PRIMARY KEY ( bp_user, bp_app_id )
+) /*$wgDBTableOptions*/;
--- /dev/null
+CREATE TABLE bot_passwords (
+ bp_user INTEGER NOT NULL,
+ bp_app_id TEXT NOT NULL,
+ bp_password TEXT NOT NULL,
+ bp_token TEXT NOT NULL,
+ bp_restrictions TEXT NOT NULL,
+ bp_grants TEXT NOT NULL,
+ PRIMARY KEY ( bp_user, bp_app_id )
+);
CREATE INDEX user_newtalk_id_idx ON user_newtalk (user_id);
CREATE INDEX user_newtalk_ip_idx ON user_newtalk (user_ip);
+CREATE TABLE bot_passwords (
+ bp_user INTEGER NOT NULL,
+ bp_app_id TEXT NOT NULL,
+ bp_password TEXT NOT NULL,
+ bp_token TEXT NOT NULL,
+ bp_restrictions TEXT NOT NULL,
+ bp_grants TEXT NOT NULL,
+ PRIMARY KEY ( bp_user, bp_app_id )
+);
CREATE SEQUENCE page_page_id_seq;
CREATE TABLE page (
CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+--
+-- This table contains a user's bot passwords: passwords that allow access to
+-- the account via the API with limited rights.
+--
+CREATE TABLE /*_*/bot_passwords (
+ -- User ID obtained from CentralIdLookup.
+ bp_user int NOT NULL,
+
+ -- Application identifier
+ bp_app_id varbinary(32) NOT NULL,
+
+ -- Password hashes, like user.user_password
+ bp_password tinyblob NOT NULL,
+
+ -- Like user.user_token
+ bp_token binary(32) NOT NULL default '',
+
+ -- JSON blob for MWRestrictions
+ bp_restrictions blob NOT NULL,
+
+ -- Grants allowed to the account when authenticated with this bot-password
+ bp_grants blob NOT NULL,
+
+ PRIMARY KEY ( bp_user, bp_app_id )
+) /*$wgDBTableOptions*/;
+
--
-- Core of the wiki: each page has an entry here which identifies
-- it by title and contains some essential metadata.
* Test result of attempted login with an empty username
*/
public function testApiLoginNoName() {
+ $session = array(
+ 'wsLoginToken' => 'foobar'
+ );
$data = $this->doApiRequest( array( 'action' => 'login',
'lgname' => '', 'lgpassword' => self::$users['sysop']->password,
- ) );
+ 'lgtoken' => 'foobar',
+ ), $session );
$this->assertEquals( 'NoName', $data[0]['login']['result'] );
}
$this->assertArrayHasKey( 'lgtoken', $data[0]['login'] );
}
+ public function testBotPassword() {
+ global $wgServer, $wgSessionProviders;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+
+ $this->setMwGlobals( array(
+ 'wgSessionProviders' => array_merge( $wgSessionProviders, array(
+ array(
+ 'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+ 'args' => array( array( 'priority' => 40 ) ),
+ )
+ ) ),
+ 'wgEnableBotPasswords' => true,
+ 'wgBotPasswordsDatabase' => false,
+ 'wgCentralIdLookupProvider' => 'local',
+ 'wgGrantPermissions' => array(
+ 'test' => array( 'read' => true ),
+ ),
+ ) );
+
+ // Make sure our session provider is present
+ $manager = TestingAccessWrapper::newFromObject( MediaWiki\Session\SessionManager::singleton() );
+ if ( !isset( $manager->sessionProviders['MediaWiki\\Session\\BotPasswordSessionProvider'] ) ) {
+ $tmp = $manager->sessionProviders;
+ $manager->sessionProviders = null;
+ $manager->sessionProviders = $tmp + $manager->getProviders();
+ }
+ $this->assertNotNull(
+ MediaWiki\Session\SessionManager::singleton()->getProvider(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider'
+ ),
+ 'sanity check'
+ );
+
+ $user = self::$users['sysop'];
+ $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() );
+ $this->assertNotEquals( 0, $centralId, 'sanity check' );
+
+ $passwordFactory = new PasswordFactory();
+ $passwordFactory->init( RequestContext::getMain()->getConfig() );
+ // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+ $passwordFactory->setDefaultType( 'A' );
+ $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert(
+ 'bot_passwords',
+ array(
+ 'bp_user' => $centralId,
+ 'bp_app_id' => 'foo',
+ 'bp_password' => $pwhash->toString(),
+ 'bp_token' => '',
+ 'bp_restrictions' => MWRestrictions::newDefault()->toJson(),
+ 'bp_grants' => '["test"]',
+ ),
+ __METHOD__
+ );
+
+ $lgName = $user->username . BotPassword::getSeparator() . 'foo';
+
+ $ret = $this->doApiRequest( array(
+ 'action' => 'login',
+ 'lgname' => $lgName,
+ 'lgpassword' => 'foobaz',
+ ) );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $this->assertNotInternalType( 'null', $result['login'] );
+
+ $a = $result['login']['result'];
+ $this->assertEquals( 'NeedToken', $a );
+ $token = $result['login']['token'];
+
+ $ret = $this->doApiRequest( array(
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => $lgName,
+ 'lgpassword' => 'foobaz',
+ ), $ret[2] );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $a = $result['login']['result'];
+
+ $this->assertEquals( 'Success', $a );
+ }
+
}
--- /dev/null
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\BotPasswordSessionProvider
+ */
+class BotPasswordSessionProviderTest extends MediaWikiTestCase {
+
+ private $config;
+
+ private function getProvider( $name = null, $prefix = null ) {
+ global $wgSessionProviders;
+
+ $params = array(
+ 'priority' => 40,
+ 'sessionCookieName' => $name,
+ 'sessionCookieOptions' => array(),
+ );
+ if ( $prefix !== null ) {
+ $params['sessionCookieOptions']['prefix'] = $prefix;
+ }
+
+ if ( !$this->config ) {
+ $this->config = new \HashConfig( array(
+ 'CookiePrefix' => 'wgCookiePrefix',
+ 'EnableBotPasswords' => true,
+ 'BotPasswordsDatabase' => false,
+ 'SessionProviders' => $wgSessionProviders + array(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+ 'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+ 'args' => array( $params ),
+ )
+ ),
+ ) );
+ }
+ $manager = new SessionManager( array(
+ 'config' => new \MultiConfig( array( $this->config, \RequestContext::getMain()->getConfig() ) ),
+ 'logger' => new \Psr\Log\NullLogger,
+ 'store' => new TestBagOStuff,
+ ) );
+
+ return $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( array(
+ 'wgEnableBotPasswords' => true,
+ 'wgBotPasswordsDatabase' => false,
+ 'wgCentralIdLookupProvider' => 'local',
+ 'wgGrantPermissions' => array(
+ 'test' => array( 'read' => true ),
+ ),
+ ) );
+ }
+
+ public function addDBData() {
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+ $passwordFactory->setDefaultType( 'A' );
+ $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+ $userId = \CentralIdLookup::factory( 'local' )->centralIdFromName( 'UTSysop' );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete(
+ 'bot_passwords',
+ array( 'bp_user' => $userId, 'bp_app_id' => 'BotPasswordSessionProvider' ),
+ __METHOD__
+ );
+ $dbw->insert(
+ 'bot_passwords',
+ array(
+ 'bp_user' => $userId,
+ 'bp_app_id' => 'BotPasswordSessionProvider',
+ 'bp_password' => $pwhash->toString(),
+ 'bp_token' => 'token!',
+ 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+ 'bp_grants' => '["test"]',
+ ),
+ __METHOD__
+ );
+ }
+
+ public function testConstructor() {
+ try {
+ $provider = new BotPasswordSessionProvider();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: priority must be specified',
+ $ex->getMessage()
+ );
+ }
+
+ try {
+ $provider = new BotPasswordSessionProvider( array(
+ 'priority' => SessionInfo::MIN_PRIORITY - 1
+ ) );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+ $ex->getMessage()
+ );
+ }
+
+ try {
+ $provider = new BotPasswordSessionProvider( array(
+ 'priority' => SessionInfo::MAX_PRIORITY + 1
+ ) );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+ $ex->getMessage()
+ );
+ }
+
+ $provider = new BotPasswordSessionProvider( array(
+ 'priority' => 40
+ ) );
+ $priv = \TestingAccessWrapper::newFromObject( $provider );
+ $this->assertSame( 40, $priv->priority );
+ $this->assertSame( '_BPsession', $priv->sessionCookieName );
+ $this->assertSame( array(), $priv->sessionCookieOptions );
+
+ $provider = new BotPasswordSessionProvider( array(
+ 'priority' => 40,
+ 'sessionCookieName' => null,
+ ) );
+ $priv = \TestingAccessWrapper::newFromObject( $provider );
+ $this->assertSame( '_BPsession', $priv->sessionCookieName );
+
+ $provider = new BotPasswordSessionProvider( array(
+ 'priority' => 40,
+ 'sessionCookieName' => 'Foo',
+ 'sessionCookieOptions' => array( 'Bar' ),
+ ) );
+ $priv = \TestingAccessWrapper::newFromObject( $provider );
+ $this->assertSame( 'Foo', $priv->sessionCookieName );
+ $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions );
+ }
+
+ public function testBasics() {
+ $provider = $this->getProvider();
+
+ $this->assertTrue( $provider->persistsSessionID() );
+ $this->assertFalse( $provider->canChangeUser() );
+
+ $this->assertNull( $provider->newSessionInfo() );
+ $this->assertNull( $provider->newSessionInfo( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ) );
+ }
+
+ public function testProvideSessionInfo() {
+ $provider = $this->getProvider();
+ $request = new \FauxRequest;
+ $request->setCookie( '_BPsession', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'wgCookiePrefix' );
+
+ if ( !defined( 'MW_API' ) ) {
+ $this->assertNull( $provider->provideSessionInfo( $request ) );
+ define( 'MW_API', 1 );
+ }
+
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertInstanceOf( 'MediaWiki\\Session\\SessionInfo', $info );
+ $this->assertSame( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $info->getId() );
+
+ $this->config->set( 'EnableBotPasswords', false );
+ $this->assertNull( $provider->provideSessionInfo( $request ) );
+ $this->config->set( 'EnableBotPasswords', true );
+
+ $this->assertNull( $provider->provideSessionInfo( new \FauxRequest ) );
+ }
+
+ public function testNewSessionInfoForRequest() {
+ $provider = $this->getProvider();
+ $user = \User::newFromName( 'UTSysop' );
+ $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+ $request->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '127.0.0.1' ) );
+ $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+ $session = $provider->newSessionForRequest( $user, $bp, $request );
+ $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+
+ $this->assertEquals( $session->getId(), $request->getSession()->getId() );
+ $this->assertEquals( $user->getName(), $session->getUser()->getName() );
+
+ $this->assertEquals( array(
+ 'centralId' => $bp->getUserCentralId(),
+ 'appId' => $bp->getAppId(),
+ 'token' => $bp->getToken(),
+ 'rights' => array( 'read' ),
+ ), $session->getProviderMetadata() );
+
+ $this->assertEquals( array( 'read' ), $session->getAllowedUserRights() );
+ }
+
+ public function testCheckSessionInfo() {
+ $logger = new \TestLogger( true, function ( $m ) {
+ return preg_replace(
+ '/^Session \[\d+\][a-zA-Z0-9_\\\\]+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m
+ );
+ } );
+ $provider = $this->getProvider();
+ $provider->setLogger( $logger );
+
+ $user = \User::newFromName( 'UTSysop' );
+ $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+ $request->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '127.0.0.1' ) );
+ $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+ $data = array(
+ 'provider' => $provider,
+ 'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ 'userInfo' => UserInfo::newFromUser( $user, true ),
+ 'persisted' => false,
+ 'metadata' => array(
+ 'centralId' => $bp->getUserCentralId(),
+ 'appId' => $bp->getAppId(),
+ 'token' => $bp->getToken(),
+ ),
+ );
+ $dataMD = $data['metadata'];
+
+ foreach ( array_keys( $data['metadata'] ) as $key ) {
+ $data['metadata'] = $dataMD;
+ unset( $data['metadata'][$key] );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( array(
+ array( LogLevel::INFO, "Session X: Missing metadata: $key" )
+ ), $logger->getBuffer() );
+ $logger->clearBuffer();
+ }
+
+ $data['metadata'] = $dataMD;
+ $data['metadata']['appId'] = 'Foobar';
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( array(
+ array( LogLevel::INFO, "Session X: No BotPassword for {$bp->getUserCentralId()} Foobar" ),
+ ), $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $data['metadata'] = $dataMD;
+ $data['metadata']['token'] = 'Foobar';
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( array(
+ array( LogLevel::INFO, 'Session X: BotPassword token check failed' ),
+ ), $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $request2 = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+ $request2->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '10.0.0.1' ) );
+ $data['metadata'] = $dataMD;
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request2, $metadata ) );
+ $this->assertSame( array(
+ array( LogLevel::INFO, 'Session X: Restrictions check failed' ),
+ ), $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertTrue( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( array(), $logger->getBuffer() );
+ $this->assertEquals( $dataMD + array( 'rights' => array( 'read' ) ), $metadata );
+ }
+}
--- /dev/null
+<?php
+
+use MediaWiki\Session\SessionManager;
+
+/**
+ * @covers BotPassword
+ * @group Database
+ */
+class BotPasswordTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( array(
+ 'wgEnableBotPasswords' => true,
+ 'wgBotPasswordsDatabase' => false,
+ 'wgCentralIdLookupProvider' => 'BotPasswordTest OkMock',
+ 'wgGrantPermissions' => array(
+ 'test' => array( 'read' => true ),
+ ),
+ 'wgUserrightsInterwikiDelimiter' => '@',
+ ) );
+
+ $mock1 = $this->getMockForAbstractClass( 'CentralIdLookup' );
+ $mock1->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( true ) );
+ $mock1->expects( $this->any() )->method( 'lookupUserNames' )
+ ->will( $this->returnValue( array( 'UTSysop' => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ) ) );
+ $mock1->expects( $this->never() )->method( 'lookupCentralIds' );
+
+ $mock2 = $this->getMockForAbstractClass( 'CentralIdLookup' );
+ $mock2->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( false ) );
+ $mock2->expects( $this->any() )->method( 'lookupUserNames' )
+ ->will( $this->returnArgument( 0 ) );
+ $mock2->expects( $this->never() )->method( 'lookupCentralIds' );
+
+ $this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', array(
+ 'BotPasswordTest OkMock' => array( 'factory' => function () use ( $mock1 ) {
+ return $mock1;
+ } ),
+ 'BotPasswordTest FailMock' => array( 'factory' => function () use ( $mock2 ) {
+ return $mock2;
+ } ),
+ ) );
+
+ CentralIdLookup::resetCache();
+ }
+
+ public function addDBData() {
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+ $passwordFactory->setDefaultType( 'A' );
+ $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete(
+ 'bot_passwords',
+ array( 'bp_user' => array( 42, 43 ), 'bp_app_id' => 'BotPassword' ),
+ __METHOD__
+ );
+ $dbw->insert(
+ 'bot_passwords',
+ array(
+ array(
+ 'bp_user' => 42,
+ 'bp_app_id' => 'BotPassword',
+ 'bp_password' => $pwhash->toString(),
+ 'bp_token' => 'token!',
+ 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+ 'bp_grants' => '["test"]',
+ ),
+ array(
+ 'bp_user' => 43,
+ 'bp_app_id' => 'BotPassword',
+ 'bp_password' => $pwhash->toString(),
+ 'bp_token' => 'token!',
+ 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+ 'bp_grants' => '["test"]',
+ ),
+ ),
+ __METHOD__
+ );
+ }
+
+ public function testBasics() {
+ $user = User::newFromName( 'UTSysop' );
+ $bp = BotPassword::newFromUser( $user, 'BotPassword' );
+ $this->assertInstanceOf( 'BotPassword', $bp );
+ $this->assertTrue( $bp->isSaved() );
+ $this->assertSame( 42, $bp->getUserCentralId() );
+ $this->assertSame( 'BotPassword', $bp->getAppId() );
+ $this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) );
+ $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+ $this->assertSame( array( 'test' ), $bp->getGrants() );
+
+ $this->assertNull( BotPassword::newFromUser( $user, 'DoesNotExist' ) );
+
+ $this->setMwGlobals( array(
+ 'wgCentralIdLookupProvider' => 'BotPasswordTest FailMock'
+ ) );
+ $this->assertNull( BotPassword::newFromUser( $user, 'BotPassword' ) );
+
+ $this->assertSame( '@', BotPassword::getSeparator() );
+ $this->setMwGlobals( array(
+ 'wgUserrightsInterwikiDelimiter' => '#',
+ ) );
+ $this->assertSame( '#', BotPassword::getSeparator() );
+ }
+
+ public function testUnsaved() {
+ $user = User::newFromName( 'UTSysop' );
+ $bp = BotPassword::newUnsaved( array(
+ 'user' => $user,
+ 'appId' => 'DoesNotExist'
+ ) );
+ $this->assertInstanceOf( 'BotPassword', $bp );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertSame( 42, $bp->getUserCentralId() );
+ $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+ $this->assertEquals( MWRestrictions::newDefault(), $bp->getRestrictions() );
+ $this->assertSame( array(), $bp->getGrants() );
+
+ $bp = BotPassword::newUnsaved( array(
+ 'username' => 'UTDummy',
+ 'appId' => 'DoesNotExist2',
+ 'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+ 'grants' => array( 'test' ),
+ ) );
+ $this->assertInstanceOf( 'BotPassword', $bp );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertSame( 43, $bp->getUserCentralId() );
+ $this->assertSame( 'DoesNotExist2', $bp->getAppId() );
+ $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+ $this->assertSame( array( 'test' ), $bp->getGrants() );
+
+ $user = User::newFromName( 'UTSysop' );
+ $bp = BotPassword::newUnsaved( array(
+ 'centralId' => 45,
+ 'appId' => 'DoesNotExist'
+ ) );
+ $this->assertInstanceOf( 'BotPassword', $bp );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertSame( 45, $bp->getUserCentralId() );
+ $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+
+ $user = User::newFromName( 'UTSysop' );
+ $bp = BotPassword::newUnsaved( array(
+ 'user' => $user,
+ 'appId' => 'BotPassword'
+ ) );
+ $this->assertInstanceOf( 'BotPassword', $bp );
+ $this->assertFalse( $bp->isSaved() );
+
+ $this->assertNull( BotPassword::newUnsaved( array(
+ 'user' => $user,
+ 'appId' => '',
+ ) ) );
+ $this->assertNull( BotPassword::newUnsaved( array(
+ 'user' => $user,
+ 'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ),
+ ) ) );
+ $this->assertNull( BotPassword::newUnsaved( array(
+ 'user' => 'UTSysop',
+ 'appId' => 'Ok',
+ ) ) );
+ $this->assertNull( BotPassword::newUnsaved( array(
+ 'username' => 'UTInvalid',
+ 'appId' => 'Ok',
+ ) ) );
+ $this->assertNull( BotPassword::newUnsaved( array(
+ 'appId' => 'Ok',
+ ) ) );
+ }
+
+ public function testGetPassword() {
+ $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+
+ $password = $bp->getPassword();
+ $this->assertInstanceOf( 'Password', $password );
+ $this->assertTrue( $password->equals( 'foobaz' ) );
+
+ $bp->centralId = 44;
+ $password = $bp->getPassword();
+ $this->assertInstanceOf( 'InvalidPassword', $password );
+
+ $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'bot_passwords',
+ array( 'bp_password' => 'garbage' ),
+ array( 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ),
+ __METHOD__
+ );
+ $password = $bp->getPassword();
+ $this->assertInstanceOf( 'InvalidPassword', $password );
+ }
+
+ public function testInvalidateAllPasswordsForUser() {
+ $bp1 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $bp2 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+
+ $this->assertNotInstanceOf( 'InvalidPassword', $bp1->getPassword(), 'sanity check' );
+ $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword(), 'sanity check' );
+ BotPassword::invalidateAllPasswordsForUser( 'UTSysop' );
+ $this->assertInstanceOf( 'InvalidPassword', $bp1->getPassword() );
+ $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword() );
+
+ $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $this->assertInstanceOf( 'InvalidPassword', $bp->getPassword() );
+ }
+
+ public function testRemoveAllPasswordsForUser() {
+ $this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ), 'sanity check' );
+ $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ), 'sanity check' );
+
+ BotPassword::removeAllPasswordsForUser( 'UTSysop' );
+
+ $this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+ }
+
+ public function testLogin() {
+ // Test failure when bot passwords aren't enabled
+ $this->setMwGlobals( 'wgEnableBotPasswords', false );
+ $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status );
+ $this->setMwGlobals( 'wgEnableBotPasswords', true );
+
+ // Test failure when BotPasswordSessionProvider isn't configured
+ $manager = new SessionManager( array(
+ 'logger' => new Psr\Log\NullLogger,
+ 'store' => new EmptyBagOStuff,
+ ) );
+ $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+ $this->assertNull(
+ $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' ),
+ 'sanity check'
+ );
+ $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status );
+ ScopedCallback::consume( $reset );
+
+ // Now configure BotPasswordSessionProvider for further tests...
+ $mainConfig = RequestContext::getMain()->getConfig();
+ $config = new HashConfig( array(
+ 'SessionProviders' => $mainConfig->get( 'SessionProviders' ) + array(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+ 'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+ 'args' => array( array( 'priority' => 40 ) ),
+ )
+ ),
+ ) );
+ $manager = new SessionManager( array(
+ 'config' => new MultiConfig( array( $config, RequestContext::getMain()->getConfig() ) ),
+ 'logger' => new Psr\Log\NullLogger,
+ 'store' => new EmptyBagOStuff,
+ ) );
+ $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+
+ // No "@"-thing in the username
+ $status = BotPassword::login( 'UTSysop', 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'botpasswords-invalid-name', '@' ), $status );
+
+ // No base user
+ $status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'nosuchuser', 'UTDummy' ), $status );
+
+ // No bot password
+ $status = BotPassword::login( 'UTSysop@DoesNotExist', 'foobaz', new FauxRequest );
+ $this->assertEquals(
+ Status::newFatal( 'botpasswords-not-exist', 'UTSysop', 'DoesNotExist' ),
+ $status
+ );
+
+ // Failed restriction
+ $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+ $request->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '10.0.0.1' ) );
+ $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request );
+ $this->assertEquals( Status::newFatal( 'botpasswords-restriction-failed' ), $status );
+
+ // Wrong password
+ $status = BotPassword::login( 'UTSysop@BotPassword', 'UTSysopPassword', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'wrongpassword' ), $status );
+
+ // Success!
+ $request = new FauxRequest;
+ $this->assertNotInstanceOf(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider',
+ $request->getSession()->getProvider(),
+ 'sanity check'
+ );
+ $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request );
+ $this->assertInstanceOf( 'Status', $status );
+ $this->assertTrue( $status->isGood() );
+ $session = $status->getValue();
+ $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+ $this->assertInstanceOf(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider', $session->getProvider()
+ );
+ $this->assertSame( $session->getId(), $request->getSession()->getId() );
+
+ ScopedCallback::consume( $reset );
+ }
+
+ /**
+ * @dataProvider provideSave
+ * @param string|null $password
+ */
+ public function testSave( $password ) {
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+ $passwordFactory->setDefaultType( 'A' );
+
+ $bp = BotPassword::newUnsaved( array(
+ 'centralId' => 42,
+ 'appId' => 'TestSave',
+ 'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+ 'grants' => array( 'test' ),
+ ) );
+ $this->assertFalse( $bp->isSaved(), 'sanity check' );
+ $this->assertNull(
+ BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ), 'sanity check'
+ );
+
+ $pwhash = $password ? $passwordFactory->newFromPlaintext( $password ) : null;
+ $this->assertFalse( $bp->save( 'update', $pwhash ) );
+ $this->assertTrue( $bp->save( 'insert', $pwhash ) );
+ $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+ $this->assertInstanceOf( 'BotPassword', $bp2 );
+ $this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() );
+ $this->assertEquals( $bp->getAppId(), $bp2->getAppId() );
+ $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+ $this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() );
+ $this->assertEquals( $bp->getGrants(), $bp2->getGrants() );
+ $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+ if ( $password === null ) {
+ $this->assertInstanceOf( 'InvalidPassword', $pw );
+ } else {
+ $this->assertTrue( $pw->equals( $password ) );
+ }
+
+ $token = $bp->getToken();
+ $this->assertFalse( $bp->save( 'insert' ) );
+ $this->assertTrue( $bp->save( 'update' ) );
+ $this->assertNotEquals( $token, $bp->getToken() );
+ $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+ $this->assertInstanceOf( 'BotPassword', $bp2 );
+ $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+ $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+ if ( $password === null ) {
+ $this->assertInstanceOf( 'InvalidPassword', $pw );
+ } else {
+ $this->assertTrue( $pw->equals( $password ) );
+ }
+
+ $pwhash = $passwordFactory->newFromPlaintext( 'XXX' );
+ $token = $bp->getToken();
+ $this->assertTrue( $bp->save( 'update', $pwhash ) );
+ $this->assertNotEquals( $token, $bp->getToken() );
+ $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+ $this->assertTrue( $pw->equals( 'XXX' ) );
+
+ $this->assertTrue( $bp->delete() );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertNull( BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ) );
+
+ $this->assertFalse( $bp->save( 'foobar' ) );
+ }
+
+ public static function provideSave() {
+ return array(
+ array( null ),
+ array( 'foobar' ),
+ );
+ }
+}