From ef5bd7347b0bc8c6e23cdce3eab7b9041bad5e26 Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Wed, 2 Dec 2015 12:10:26 -0500 Subject: [PATCH] Move grant and IP restriction logic from OAuth to core This also adds code to User to allow SessionProviders to apply the grant restrictions without needing to hook UserGetRights. Change-Id: Ida2b686157aab7c8240d6a7a5a5046374ef86d52 --- RELEASE-NOTES-1.27 | 9 + autoload.php | 3 + includes/DefaultSettings.php | 132 +++++++++++ includes/session/Session.php | 8 + includes/session/SessionBackend.php | 8 + includes/session/SessionProvider.php | 14 ++ includes/specialpage/SpecialPageFactory.php | 1 + includes/specials/SpecialListgrants.php | 85 +++++++ includes/user/User.php | 24 +- includes/utils/MWGrants.php | 214 +++++++++++++++++ includes/utils/MWRestrictions.php | 144 ++++++++++++ languages/i18n/en.json | 34 +++ languages/i18n/qqq.json | 34 +++ languages/messages/MessagesEn.php | 1 + .../includes/session/SessionBackendTest.php | 11 + .../includes/session/SessionProviderTest.php | 18 ++ .../phpunit/includes/session/SessionTest.php | 1 + tests/phpunit/includes/utils/MWGrantsTest.php | 117 ++++++++++ .../includes/utils/MWRestrictionsTest.php | 215 ++++++++++++++++++ 19 files changed, 1071 insertions(+), 2 deletions(-) create mode 100644 includes/specials/SpecialListgrants.php create mode 100644 includes/utils/MWGrants.php create mode 100644 includes/utils/MWRestrictions.php create mode 100644 tests/phpunit/includes/utils/MWGrantsTest.php create mode 100644 tests/phpunit/includes/utils/MWRestrictionsTest.php diff --git a/RELEASE-NOTES-1.27 b/RELEASE-NOTES-1.27 index 73b53d201c..ac25756e45 100644 --- a/RELEASE-NOTES-1.27 +++ b/RELEASE-NOTES-1.27 @@ -134,6 +134,15 @@ production. other than the usual cookies. ** SessionMetadata and SessionCheckInfo hooks allow for setting and checking custom session metadata. +* Added MWGrants and associated configuration settings $wgGrantPermissions and + $wgGrantPermissionGroups to hold configuration for authentication features + such as OAuth that want to allow restricting the user rights a user may make + use of. +** If you're already using the OAuth extension, these new variables are + identical to (and will replace) $wgMWOAuthGrantPermissions and + $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. === External library changes in 1.27 === diff --git a/autoload.php b/autoload.php index ecbb4bd76e..e87586e746 100644 --- a/autoload.php +++ b/autoload.php @@ -730,11 +730,13 @@ $wgAutoloadLocalClasses = array( 'MWDocGen' => __DIR__ . '/maintenance/mwdocgen.php', 'MWException' => __DIR__ . '/includes/exception/MWException.php', 'MWExceptionHandler' => __DIR__ . '/includes/exception/MWExceptionHandler.php', + 'MWGrants' => __DIR__ . '/includes/utils/MWGrants.php', 'MWHttpRequest' => __DIR__ . '/includes/HttpFunctions.php', 'MWMemcached' => __DIR__ . '/includes/compat/MemcachedClientCompat.php', 'MWMessagePack' => __DIR__ . '/includes/libs/MWMessagePack.php', 'MWNamespace' => __DIR__ . '/includes/MWNamespace.php', 'MWOldPassword' => __DIR__ . '/includes/password/MWOldPassword.php', + 'MWRestrictions' => __DIR__ . '/includes/utils/MWRestrictions.php', 'MWSaltedPassword' => __DIR__ . '/includes/password/MWSaltedPassword.php', 'MWTidy' => __DIR__ . '/includes/parser/MWTidy.php', 'MWTimestamp' => __DIR__ . '/includes/MWTimestamp.php', @@ -1177,6 +1179,7 @@ $wgAutoloadLocalClasses = array( 'SpecialListAdmins' => __DIR__ . '/includes/specials/SpecialListusers.php', 'SpecialListBots' => __DIR__ . '/includes/specials/SpecialListusers.php', 'SpecialListFiles' => __DIR__ . '/includes/specials/SpecialListfiles.php', + 'SpecialListGrants' => __DIR__ . '/includes/specials/SpecialListgrants.php', 'SpecialListGroupRights' => __DIR__ . '/includes/specials/SpecialListgrouprights.php', 'SpecialListUsers' => __DIR__ . '/includes/specials/SpecialListusers.php', 'SpecialLockdb' => __DIR__ . '/includes/specials/SpecialLockdb.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index e56ad963e3..e83b4a3c7d 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -5379,6 +5379,138 @@ $wgQueryPageDefaultLimit = 50; */ $wgPasswordAttemptThrottle = array( 'count' => 5, 'seconds' => 300 ); +/** + * @var Array Map of (grant => right => boolean) + * Users authorize consumers (like Apps) to act on their behalf but only with + * a subset of the user's normal account rights (signed off on by the user). + * The possible rights to grant to a consumer are bundled into groups called + * "grants". Each grant defines some rights it lets consumers inherit from the + * account they may act on behalf of. Note that a user granting a right does + * nothing if that user does not actually have that right to begin with. + * @since 1.27 + */ +$wgGrantPermissions = array(); + +// @TODO: clean up grants +// @TODO: auto-include read/editsemiprotected rights? + +$wgGrantPermissions['basic']['autoconfirmed'] = true; +$wgGrantPermissions['basic']['autopatrol'] = true; +$wgGrantPermissions['basic']['autoreview'] = true; +$wgGrantPermissions['basic']['editsemiprotected'] = true; +$wgGrantPermissions['basic']['ipblock-exempt'] = true; +$wgGrantPermissions['basic']['nominornewtalk'] = true; +$wgGrantPermissions['basic']['patrolmarks'] = true; +$wgGrantPermissions['basic']['purge'] = true; +$wgGrantPermissions['basic']['read'] = true; +$wgGrantPermissions['basic']['skipcaptcha'] = true; +$wgGrantPermissions['basic']['torunblocked'] = true; +$wgGrantPermissions['basic']['writeapi'] = true; + +$wgGrantPermissions['highvolume']['bot'] = true; +$wgGrantPermissions['highvolume']['apihighlimits'] = true; +$wgGrantPermissions['highvolume']['noratelimit'] = true; +$wgGrantPermissions['highvolume']['markbotedits'] = true; + +$wgGrantPermissions['editpage']['edit'] = true; +$wgGrantPermissions['editpage']['minoredit'] = true; + +$wgGrantPermissions['editprotected'] = $wgGrantPermissions['editpage']; +$wgGrantPermissions['editprotected']['editprotected'] = true; + +$wgGrantPermissions['editmycssjs'] = $wgGrantPermissions['editpage']; +$wgGrantPermissions['editmycssjs']['editmyusercss'] = true; +$wgGrantPermissions['editmycssjs']['editmyuserjs'] = true; + +$wgGrantPermissions['editmyoptions']['editmyoptions'] = true; + +$wgGrantPermissions['editinterface'] = $wgGrantPermissions['editpage']; +$wgGrantPermissions['editinterface']['editinterface'] = true; +$wgGrantPermissions['editinterface']['editusercss'] = true; +$wgGrantPermissions['editinterface']['edituserjs'] = true; + +$wgGrantPermissions['createeditmovepage'] = $wgGrantPermissions['editpage']; +$wgGrantPermissions['createeditmovepage']['createpage'] = true; +$wgGrantPermissions['createeditmovepage']['createtalk'] = true; +$wgGrantPermissions['createeditmovepage']['move'] = true; +$wgGrantPermissions['createeditmovepage']['move-rootuserpages'] = true; +$wgGrantPermissions['createeditmovepage']['move-subpages'] = true; + +$wgGrantPermissions['uploadfile']['upload'] = true; +$wgGrantPermissions['uploadfile']['reupload-own'] = true; + +$wgGrantPermissions['uploadeditmovefile'] = $wgGrantPermissions['uploadfile']; +$wgGrantPermissions['uploadeditmovefile']['reupload'] = true; +$wgGrantPermissions['uploadeditmovefile']['reupload-shared'] = true; +$wgGrantPermissions['uploadeditmovefile']['upload_by_url'] = true; +$wgGrantPermissions['uploadeditmovefile']['movefile'] = true; +$wgGrantPermissions['uploadeditmovefile']['suppressredirect'] = true; + +$wgGrantPermissions['patrol']['patrol'] = true; + +$wgGrantPermissions['rollback']['rollback'] = true; + +$wgGrantPermissions['blockusers']['block'] = true; +$wgGrantPermissions['blockusers']['blockemail'] = true; + +$wgGrantPermissions['viewdeleted']['browsearchive'] = true; +$wgGrantPermissions['viewdeleted']['deletedhistory'] = true; +$wgGrantPermissions['viewdeleted']['deletedtext'] = true; + +$wgGrantPermissions['delete'] = $wgGrantPermissions['editpage'] + + $wgGrantPermissions['viewdeleted']; +$wgGrantPermissions['delete']['delete'] = true; +$wgGrantPermissions['delete']['bigdelete'] = true; +$wgGrantPermissions['delete']['deletelogentry'] = true; +$wgGrantPermissions['delete']['deleterevision'] = true; +$wgGrantPermissions['delete']['undelete'] = true; + +$wgGrantPermissions['protect'] = $wgGrantPermissions['editprotected']; +$wgGrantPermissions['protect']['protect'] = true; + +$wgGrantPermissions['viewmywatchlist']['viewmywatchlist'] = true; + +$wgGrantPermissions['editmywatchlist']['editmywatchlist'] = true; + +$wgGrantPermissions['sendemail']['sendemail'] = true; + +$wgGrantPermissions['createaccount']['createaccount'] = true; + +/** + * @var Array Map of grants to their UI grouping + * @since 1.27 + */ +$wgGrantPermissionGroups = array( + // Hidden grants are implicitly present + 'basic' => 'hidden', + + 'editpage' => 'page-interaction', + 'createeditmovepage' => 'page-interaction', + 'editprotected' => 'page-interaction', + 'patrol' => 'page-interaction', + + 'uploadfile' => 'file-interaction', + 'uploadeditmovefile' => 'file-interaction', + + 'sendemail' => 'email', + + 'viewmywatchlist' => 'watchlist-interaction', + 'editviewmywatchlist' => 'watchlist-interaction', + + 'editmycssjs' => 'customization', + 'editmyoptions' => 'customization', + + 'editinterface' => 'administration', + 'rollback' => 'administration', + 'blockusers' => 'administration', + 'delete' => 'administration', + 'viewdeleted' => 'administration', + 'protect' => 'administration', + 'createaccount' => 'administration', + + 'highvolume' => 'high-volume', +); + /** @} */ # end of user rights settings /************************************************************************//** diff --git a/includes/session/Session.php b/includes/session/Session.php index 049e5f5325..840baa70fe 100644 --- a/includes/session/Session.php +++ b/includes/session/Session.php @@ -153,6 +153,14 @@ final class Session implements \Countable, \Iterator { return $this->backend->getUser(); } + /** + * Fetch the rights allowed the user when this session is active. + * @return null|string[] Allowed user rights, or null to allow all. + */ + public function getAllowedUserRights() { + return $this->backend->getAllowedUserRights(); + } + /** * Indicate whether the session user info can be changed * @return bool diff --git a/includes/session/SessionBackend.php b/includes/session/SessionBackend.php index 80d3474521..5743b12422 100644 --- a/includes/session/SessionBackend.php +++ b/includes/session/SessionBackend.php @@ -312,6 +312,14 @@ final class SessionBackend { return $this->user; } + /** + * Fetch the rights allowed the user when this session is active. + * @return null|string[] Allowed user rights, or null to allow all. + */ + public function getAllowedUserRights() { + return $this->provider->getAllowedUserRights( $this ); + } + /** * Indicate whether the session user info can be changed * @return bool diff --git a/includes/session/SessionProvider.php b/includes/session/SessionProvider.php index 18cc04c0eb..0fd3a7197f 100644 --- a/includes/session/SessionProvider.php +++ b/includes/session/SessionProvider.php @@ -389,6 +389,20 @@ abstract class SessionProvider implements SessionProviderInterface, LoggerAwareI return null; } + /** + * Fetch the rights allowed the user when the specified session is active. + * @param SessionBackend $backend + * @return null|string[] Allowed user rights, or null to allow all. + */ + public function getAllowedUserRights( SessionBackend $backend ) { + if ( $backend->getProvider() !== $this ) { + // Not that this should ever happen... + throw new \InvalidArgumentException( 'Backend\'s provider isn\'t $this' ); + } + + return null; + } + /** * @note Only override this if it makes sense to instantiate multiple * instances of the provider. Value returned must be unique across diff --git a/includes/specialpage/SpecialPageFactory.php b/includes/specialpage/SpecialPageFactory.php index 47b4fc89f1..cb20abca57 100644 --- a/includes/specialpage/SpecialPageFactory.php +++ b/includes/specialpage/SpecialPageFactory.php @@ -96,6 +96,7 @@ class SpecialPageFactory { 'ResetTokens' => 'SpecialResetTokens', 'Contributions' => 'SpecialContributions', 'Listgrouprights' => 'SpecialListGroupRights', + 'Listgrants' => 'SpecialListGrants', 'Listusers' => 'SpecialListUsers', 'Listadmins' => 'SpecialListAdmins', 'Listbots' => 'SpecialListBots', diff --git a/includes/specials/SpecialListgrants.php b/includes/specials/SpecialListgrants.php new file mode 100644 index 0000000000..c5eea3f0f3 --- /dev/null +++ b/includes/specials/SpecialListgrants.php @@ -0,0 +1,85 @@ +setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->addModuleStyles( 'mediawiki.special' ); + + $out->addHTML( + \Html::openElement( 'table', + array( 'class' => 'wikitable mw-listgrouprights-table' ) ) . + '' . + \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) . + \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) . + '' + ); + + foreach ( $this->getConfig()->get( 'GrantPermissions' ) as $grant => $rights ) { + $descs = array(); + $rights = array_filter( $rights ); // remove ones with 'false' + foreach ( $rights as $permission => $granted ) { + $descs[] = $this->msg( + 'listgrouprights-right-display', + \User::getRightDescription( $permission ), + '' . $permission . '' + )->parse(); + } + if ( !count( $descs ) ) { + $grantCellHtml = ''; + } else { + sort( $descs ); + $grantCellHtml = ''; + } + + $id = \Sanitizer::escapeId( $grant ); + $out->addHTML( \Html::rawElement( 'tr', array( 'id' => $id ), + "" . $this->msg( "grant-$grant" )->escaped() . "" . + "" . $grantCellHtml . '' + ) ); + } + + $out->addHTML( \Html::closeElement( 'table' ) ); + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/includes/user/User.php b/includes/user/User.php index fd0d612e6d..5d1b13c5c3 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -2981,6 +2981,12 @@ class User implements IDBAccessObject { public function getRights() { if ( is_null( $this->mRights ) ) { $this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() ); + + $allowedRights = $this->getRequest()->getSession()->getAllowedUserRights(); + if ( $allowedRights !== null ) { + $this->mRights = array_intersect( $this->mRights, $allowedRights ); + } + Hooks::run( 'UserGetRights', array( $this, &$this->mRights ) ); // Force reindexation of rights when a hook has unset one of them $this->mRights = array_values( array_unique( $this->mRights ) ); @@ -4516,7 +4522,14 @@ class User implements IDBAccessObject { } /** - * Check if all users have the given permission + * Check if all users may be assumed to have the given permission + * + * We generally assume so if the right is granted to '*' and isn't revoked + * on any group. It doesn't attempt to take grants or other extension + * limitations on rights into account in the general case, though, as that + * would require it to always return false and defeat the purpose. + * Specifically, session-based rights restrictions (such as OAuth or bot + * passwords) are applied based on the current session. * * @since 1.22 * @param string $right Right to check @@ -4545,7 +4558,14 @@ class User implements IDBAccessObject { } } - // Allow extensions (e.g. OAuth) to say false + // Remove any rights that aren't allowed to the global-session user + $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights(); + if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) { + $cache[$right] = false; + return false; + } + + // Allow extensions to say false if ( !Hooks::run( 'UserIsEveryoneAllowed', array( $right ) ) ) { $cache[$right] = false; return false; diff --git a/includes/utils/MWGrants.php b/includes/utils/MWGrants.php new file mode 100644 index 0000000000..b9b51d5549 --- /dev/null +++ b/includes/utils/MWGrants.php @@ -0,0 +1,214 @@ + array of rights + */ + public static function getRightsByGrant() { + global $wgGrantPermissions; + + $res = array(); + foreach ( $wgGrantPermissions as $grant => $rights ) { + $res[$grant] = array_keys( array_filter( $rights ) ); + } + return $res; + } + + /** + * Fetch the display name of the grant + * @param string $grant + * @param Language|string|null $lang + * @return string Grant description + */ + public static function grantName( $grant, $lang = null ) { + // Give grep a chance to find the usages: + // grant-blockusers, grant-createeditmovepage, grant-delete, + // grant-editinterface, grant-editmycssjs, grant-editmywatchlist, + // grant-editpage, grant-editprotected, grant-highvolume, + // grant-oversight, grant-patrol, grant-protect, grant-rollback, + // grant-sendemail, grant-uploadeditmovefile, grant-uploadfile, + // grant-basic, grant-viewdeleted, grant-viewmywatchlist, + // grant-createaccount + $msg = wfMessage( "grant-$grant" ); + if ( $lang !== null ) { + if ( is_string( $lang ) ) { + $lang = Language::factory( $lang ); + } + $msg->inLanguage( $lang ); + } + if ( !$msg->exists() ) { + $msg = wfMessage( 'grant-generic', $grant ); + if ( $lang ) { + $msg->inLanguage( $lang ); + } + } + return $msg->text(); + } + + /** + * Fetch the display names for the grants. + * @param string[] $grants + * @param Language|string|null $lang + * @return string[] Corresponding grant descriptions + */ + public static function grantNames( array $grants, $lang = null ) { + if ( $lang !== null ) { + if ( is_string( $lang ) ) { + $lang = Language::factory( $lang ); + } + } + + $ret = array(); + foreach ( $grants as $grant ) { + $ret[] = self::grantName( $grant, $lang ); + } + return $ret; + } + + /** + * Fetch the rights allowed by a set of grants. + * @param string[]|string $grants + * @return string[] + */ + public static function getGrantRights( $grants ) { + global $wgGrantPermissions; + + $rights = array(); + foreach ( (array)$grants as $grant ) { + if ( isset( $wgGrantPermissions[$grant] ) ) { + $rights = array_merge( $rights, array_keys( array_filter( $wgGrantPermissions[$grant] ) ) ); + } + } + return array_unique( $rights ); + } + + /** + * Test that all grants in the list are known. + * @param string[] $grants + * @return bool + */ + public static function grantsAreValid( array $grants ) { + return array_diff( $grants, self::getValidGrants() ) === array(); + } + + /** + * Divide the grants into groups. + * @param string[]|null $grantsFilter + * @return array Map of (group => (grant list)) + */ + public static function getGrantGroups( $grantsFilter = null ) { + global $wgGrantPermissions, $wgGrantPermissionGroups; + + if ( is_array( $grantsFilter ) ) { + $grantsFilter = array_flip( $grantsFilter ); + } + + $groups = array(); + foreach ( $wgGrantPermissions as $grant => $rights ) { + if ( $grantsFilter !== null && !isset( $grantsFilter[$grant] ) ) { + continue; + } + if ( isset( $wgGrantPermissionGroups[$grant] ) ) { + $groups[$wgGrantPermissionGroups[$grant]][] = $grant; + } else { + $groups['other'][] = $grant; + } + } + + return $groups; + } + + /** + * Get the list of grants that are hidden and should always be granted + * @return string[] + */ + public static function getHiddenGrants() { + global $wgGrantPermissionGroups; + + $grants = array(); + foreach ( $wgGrantPermissionGroups as $grant => $group ) { + if ( $group === 'hidden' ) { + $grants[] = $grant; + } + } + return $grants; + } + + /** + * Generate a link to Special:ListGrants for a particular grant name. + * + * This should be used to link end users to a full description of what + * rights they are giving when they authorize a grant. + * + * @param string $grant the grant name + * @param Language|string|null $lang + * @return string (proto-relative) HTML link + */ + public static function getGrantsLink( $grant, $lang = null ) { + return \Linker::linkKnown( + \SpecialPage::getTitleFor( 'Listgrants', false, $grant ), + htmlspecialchars( self::grantName( $grant, $lang ) ) + ); + } + + /** + * Generate wikitext to display a list of grants + * @param string[]|null $grantsFilter If non-null, only display these grants. + * @param Language|string|null $lang + * @return string Wikitext + */ + public static function getGrantsWikiText( $grantsFilter, $lang = null ) { + global $wgContLang; + + if ( is_string( $lang ) ) { + $lang = Language::factory( $lang ); + } elseif ( $lang === null ) { + $lang = $wgContLang; + } + + $s = ''; + foreach ( self::getGrantGroups( $grantsFilter ) as $group => $grants ) { + if ( $group === 'hidden' ) { + continue; // implicitly granted + } + $s .= "*" . + wfMessage( "grant-group-$group" )->inLanguage( $lang )->text() . "\n"; + $s .= ":" . $lang->semicolonList( self::grantNames( $grants, $lang ) ) . "\n"; + } + return "$s\n"; + } + +} diff --git a/includes/utils/MWRestrictions.php b/includes/utils/MWRestrictions.php new file mode 100644 index 0000000000..3b4dc114eb --- /dev/null +++ b/includes/utils/MWRestrictions.php @@ -0,0 +1,144 @@ +loadFromArray( $restrictions ); + } + } + + /** + * @return MWRestrictions + */ + public static function newDefault() { + return new self(); + } + + /** + * @param array $restrictions + * @return MWRestrictions + */ + public static function newFromArray( array $restrictions ) { + return new self( $restrictions ); + } + + /** + * @param string $json JSON representation of the restrictions + * @return MWRestrictions + */ + public static function newFromJson( $json ) { + $restrictions = FormatJson::decode( $json, true ); + if ( !is_array( $restrictions ) ) { + throw new InvalidArgumentException( 'Invalid restrictions JSON' ); + } + return new self( $restrictions ); + } + + private function loadFromArray( array $restrictions ) { + static $validKeys = array( 'IPAddresses' ); + static $neededKeys = array( 'IPAddresses' ); + + $keys = array_keys( $restrictions ); + $invalidKeys = array_diff( $keys, $validKeys ); + if ( $invalidKeys ) { + throw new InvalidArgumentException( + 'Array contains invalid keys: ' . join( ', ', $invalidKeys ) + ); + } + $missingKeys = array_diff( $neededKeys, $keys ); + if ( $missingKeys ) { + throw new InvalidArgumentException( + 'Array is missing required keys: ' . join( ', ', $missingKeys ) + ); + } + + if ( !is_array( $restrictions['IPAddresses'] ) ) { + throw new InvalidArgumentException( 'IPAddresses is not an array' ); + } + foreach ( $restrictions['IPAddresses'] as $ip ) { + if ( !\IP::isIPAddress( $ip ) ) { + throw new InvalidArgumentException( "Invalid IP address: $ip" ); + } + } + $this->ipAddresses = $restrictions['IPAddresses']; + } + + /** + * Return the restrictions as an array + * @return array + */ + public function toArray() { + return array( + 'IPAddresses' => $this->ipAddresses, + ); + } + + /** + * Return the restrictions as a JSON string + * @param bool|string $pretty Pretty-print the JSON output, see FormatJson::encode + * @return string + */ + public function toJson( $pretty = false ) { + return FormatJson::encode( $this->toArray(), $pretty, FormatJson::ALL_OK ); + } + + public function __toString() { + return $this->toJson(); + } + + /** + * Test against the passed WebRequest + * @param WebRequest $request + * @return Status + */ + public function check( WebRequest $request ) { + $ok = array( + 'ip' => $this->checkIP( $request->getIP() ), + ); + $status = Status::newGood(); + $status->setResult( $ok === array_filter( $ok ), $ok ); + return $status; + } + + /** + * Test an IP address + * @param string $ip + * @return bool + */ + public function checkIP( $ip ) { + foreach ( $this->ipAddresses as $range ) { + if ( \IP::isInRange( $ip, $range ) ) { + return true; + } + } + + return false; + } +} diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 2279b9affe..c6edf6c3ea 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1186,6 +1186,36 @@ "right-managechangetags": "Create and delete [[Special:Tags|tags]] from the database", "right-applychangetags": "Apply [[Special:Tags|tags]] along with one's changes", "right-changetags": "Add and remove arbitrary [[Special:Tags|tags]] on individual revisions and log entries", + "grant-generic": "\"$1\" rights bundle", + "grant-group-page-interaction": "Interact with pages", + "grant-group-file-interaction": "Interact with media", + "grant-group-watchlist-interaction": "Interact with your watchlist", + "grant-group-email": "Send email", + "grant-group-high-volume": "Perform high volume activity", + "grant-group-customization": "Customization and preferences", + "grant-group-administration": "Perform administrative actions", + "grant-group-other": "Miscellaneous activity", + "grant-blockusers": "Block and unblock users", + "grant-createaccount": "Create accounts", + "grant-createeditmovepage": "Create, edit, and move pages", + "grant-delete": "Delete pages, revisions, and log entries", + "grant-editinterface": "Edit the MediaWiki namespace and user CSS/JavaScript", + "grant-editmycssjs": "Edit your user CSS/JavaScript", + "grant-editmyoptions": "Edit your user preferences", + "grant-editmywatchlist": "Edit your watchlist", + "grant-editpage": "Edit existing pages", + "grant-editprotected": "Edit protected pages", + "grant-highvolume": "High-volume editing", + "grant-oversight": "Hide users and suppress revisions", + "grant-patrol": "Patrol changes to pages", + "grant-protect": "Protect and unprotect pages", + "grant-rollback": "Rollback changes to pages", + "grant-sendemail": "Send email to other users", + "grant-uploadeditmovefile": "Upload, replace, and move files", + "grant-uploadfile": "Upload new files", + "grant-basic": "Basic rights", + "grant-viewdeleted": "View deleted files and pages", + "grant-viewmywatchlist": "View your watchlist", "newuserlogpage": "User creation log", "newuserlogpagetext": "This is a log of user creations.", "rightslog": "User rights log", @@ -1891,6 +1921,10 @@ "listgrouprights-namespaceprotection-header": "Namespace restrictions", "listgrouprights-namespaceprotection-namespace": "Namespace", "listgrouprights-namespaceprotection-restrictedto": "Right(s) allowing user to edit", + "listgrants": "Grants", + "listgrants-summary": "The following is a list of grants with their associated access to user rights. Users can authorize applications to use their account, but with limited permissions based on the grants the user gave to the application. An application acting on behalf of a user cannot actually use rights that the user does not have however.\nThere may be [[{{MediaWiki:Listgrouprights-helppage}}|additional information]] about individual rights.", + "listgrants-grant": "Grant", + "listgrants-rights": "Rights", "trackingcategories": "Tracking categories", "trackingcategories-summary": "This page lists tracking categories which are automatically populated by the MediaWiki software. Their names can be changed by altering the relevant system messages in the {{ns:8}} namespace.", "trackingcategories-msg": "Tracking category", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 11b11db117..5a6de4b19e 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1361,6 +1361,36 @@ "right-managechangetags": "{{doc-right|managechangetags}}", "right-applychangetags": "{{doc-right|applychangetags}}", "right-changetags": "{{doc-right|changetags}}", + "grant-generic": "Used if the grant name is not defined. Parameters:\n* $1 - grant name\n\nDefined grants (grant name refers: blockusers, createeditmovepage, ...):\n* {{msg-mw|grant-checkuser}}\n* {{msg-mw|grant-blockusers}}\n* {{msg-mw|grant-createaccount}}\n* {{msg-mw|grant-createeditmovepage}}\n* {{msg-mw|grant-delete}}\n* {{msg-mw|grant-editinterface}}\n* {{msg-mw|grant-editmycssjs}}\n* {{msg-mw|grant-editmyoptions}}\n* {{msg-mw|grant-editmywatchlist}}\n* {{msg-mw|grant-editpage}}\n* {{msg-mw|grant-editprotected}}\n* {{msg-mw|grant-highvolume}}\n* {{msg-mw|grant-oversight}}\n* {{msg-mw|grant-patrol}}\n* {{msg-mw|grant-protect}}\n* {{msg-mw|grant-rollback}}\n* {{msg-mw|grant-sendemail}}\n* {{msg-mw|grant-uploadeditmovefile}}\n* {{msg-mw|grant-uploadfile}}\n* {{msg-mw|grant-basic}}\n* {{msg-mw|grant-viewdeleted}}\n* {{msg-mw|grant-viewmywatchlist}}", + "grant-group-page-interaction": "{{Related|grant-group}}", + "grant-group-file-interaction": "{{Related|grant-group}}", + "grant-group-watchlist-interaction": "{{Related|grant-group}}", + "grant-group-email": "{{Related|grant-group}}\n{{Identical|E-mail}}", + "grant-group-high-volume": "{{Related|grant-group}}", + "grant-group-customization": "{{Related|grant-group}}", + "grant-group-administration": "{{Related|grant-group}}", + "grant-group-other": "{{Related|grant-group}}", + "grant-blockusers": "Name for grant \"blockusers\".\n{{Related|grant}}", + "grant-createaccount": "Name for grant \"createaccount\".\n{{Related|grant}}", + "grant-createeditmovepage": "Name for grant \"createeditmovepage\".\n{{Related|grant}}", + "grant-delete": "Name for grant \"delete\".\n{{Related|grant}}", + "grant-editinterface": "Name for grant \"editinterface\".\n\n\"JS\" stands for \"JavaScript\".\n{{Related|grant}}", + "grant-editmycssjs": "Name for grant \"editmycssjs\".\n\n\"JS\" stands for \"JavaScript\".\n{{Related|grant}}", + "grant-editmyoptions": "Name for grant \"editmyoptions\".\n{{Related|grant}}", + "grant-editmywatchlist": "Name for grant \"editmywatchlist\".\n{{Related|grant}}\n{{Identical|Edit your watchlist}}", + "grant-editpage": "Name for grant \"editpage\".\n{{Related|grant}}", + "grant-editprotected": "Name for grant \"editprotected\".\n{{Related|grant}}", + "grant-highvolume": "Name for grant \"highvolume\".\n{{Related|grant}}", + "grant-oversight": "Name for grant \"oversight\".\n{{Related|grant}}", + "grant-patrol": "Name for grant \"patrol\".\n{{Related|grant}}", + "grant-protect": "Name for grant \"protect\".\n{{Related|grant}}", + "grant-rollback": "Name for grant \"rollback\".\n{{Related|grant}}", + "grant-sendemail": "Name for grant \"sendemail\".\n{{Related|grant}}", + "grant-uploadeditmovefile": "Name for grant \"uploadeditmovefile\".\n{{Related|grant}}", + "grant-uploadfile": "Name for grant \"uploadfile\".\n{{Related|grant}}\n{{Identical|Upload new file}}", + "grant-basic": "Name for grant \"basic\".\n{{Related|grant}}", + "grant-viewdeleted": "Name for grant \"viewdeleted\".\n{{Related|grant}}", + "grant-viewmywatchlist": "Name for grant \"viewmywatchlist\".\n{{Related|grant}}\n{{Identical|View your watchlist}}", "newuserlogpage": "{{doc-logpage}}\n\nPart of the \"Newuserlog\" extension. It is both the title of [[Special:Log/newusers]] and the link you can see in [[Special:RecentChanges]].", "newuserlogpagetext": "Part of the \"Newuserlog\" extension. It is the description you can see on [[Special:Log/newusers]].", "rightslog": "{{doc-logpage}}\n\nIn [[Special:Log]]", @@ -2066,6 +2096,10 @@ "listgrouprights-namespaceprotection-header": "Shown on [[Special:ListGroupRights]] as the header for the namespace restrictions table.", "listgrouprights-namespaceprotection-namespace": "Shown on [[Special:ListGroupRights]] as the 'namespace' column header for the namespace restrictions table.\n{{Identical|Namespace}}", "listgrouprights-namespaceprotection-restrictedto": "Shown on [[Special:ListGroupRights]] as the \"right(s) allowing user to edit\" column header for the namespace restrictions table.", + "listgrants": "The name of the special page [[Special:ListGrants]].", + "listgrants-summary": "Explanatory text shown at the top of the grant/rights mapping table.\n\nRefers to {{msg-mw|Listgrouprights-helppage}}.", + "listgrants-grant": "Used as table header for the grant/rights mapping table.\n{{Identical|Grant}}", + "listgrants-rights": "Used as table header for the grant/rights mapping table.\n{{Identical|Right}}", "trackingcategories": "[[Special:TrackingCategories]] page implementing list of Tracking categories [[mw:Special:MyLanguage/Help:Tracking categories|tracking category]].\n{{Identical|Tracking category}}", "trackingcategories-summary": "Description for [[Special:TrackingCategories]] page [[mw:Help:Tracking categories|tracking category]]", "trackingcategories-msg": "Header for the message column of the table on [[Special:TrackingCategories]]. This column lists the mediawiki message that controls the tracking category in question.\n{{Identical|Tracking category}}", diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index ec20601b18..8a1aafad13 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -425,6 +425,7 @@ $specialPageAliases = array( 'Listbots' => array( 'ListBots' ), 'Listfiles' => array( 'ListFiles', 'FileList', 'ImageList' ), 'Listgrouprights' => array( 'ListGroupRights', 'UserGroupRights' ), + 'Listgrants' => array( 'ListGrants' ), 'Listredirects' => array( 'ListRedirects' ), 'ListDuplicatedFiles' => array( 'ListDuplicatedFiles', 'ListFileDuplicates' ), 'Listusers' => array( 'ListUsers', 'UserList' ), diff --git a/tests/phpunit/includes/session/SessionBackendTest.php b/tests/phpunit/includes/session/SessionBackendTest.php index d64c998fa6..d06706bf63 100644 --- a/tests/phpunit/includes/session/SessionBackendTest.php +++ b/tests/phpunit/includes/session/SessionBackendTest.php @@ -743,4 +743,15 @@ class SessionBackendTest extends MediaWikiTestCase { session_write_close(); } + public function testGetAllowedUserRights() { + $this->provider = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'getAllowedUserRights' ) ) + ->getMock(); + $this->provider->expects( $this->any() )->method( 'getAllowedUserRights' ) + ->will( $this->returnValue( array( 'foo', 'bar' ) ) ); + + $backend = $this->getBackend(); + $this->assertSame( array( 'foo', 'bar' ), $backend->getAllowedUserRights() ); + } + } diff --git a/tests/phpunit/includes/session/SessionProviderTest.php b/tests/phpunit/includes/session/SessionProviderTest.php index 9d816307cf..d7aebcd6b3 100644 --- a/tests/phpunit/includes/session/SessionProviderTest.php +++ b/tests/phpunit/includes/session/SessionProviderTest.php @@ -174,4 +174,22 @@ class SessionProviderTest extends MediaWikiTestCase { ); } + public function testGetAllowedUserRights() { + $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' ); + $backend = TestUtils::getDummySessionBackend(); + + try { + $provider->getAllowedUserRights( $backend ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Backend\'s provider isn\'t $this', + $ex->getMessage() + ); + } + + \TestingAccessWrapper::newFromObject( $backend )->provider = $provider; + $this->assertNull( $provider->getAllowedUserRights( $backend ) ); + } + } diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php index 30d0267e8d..efc92f7ff7 100644 --- a/tests/phpunit/includes/session/SessionTest.php +++ b/tests/phpunit/includes/session/SessionTest.php @@ -78,6 +78,7 @@ class SessionTest extends MediaWikiTestCase { array( 'setRememberUser', array( true ), false, false ), array( 'getRequest', array(), true, true ), array( 'getUser', array(), false, true ), + array( 'getAllowedUserRights', array(), false, true ), array( 'canSetUser', array(), false, true ), array( 'setUser', array( new \stdClass ), false, false ), array( 'suggestLoginUsername', array(), true, true ), diff --git a/tests/phpunit/includes/utils/MWGrantsTest.php b/tests/phpunit/includes/utils/MWGrantsTest.php new file mode 100644 index 0000000000..9d0d962996 --- /dev/null +++ b/tests/phpunit/includes/utils/MWGrantsTest.php @@ -0,0 +1,117 @@ +setMwGlobals( array( + 'wgGrantPermissions' => array( + 'hidden1' => array( 'read' => true, 'autoconfirmed' => false ), + 'hidden2' => array( 'autoconfirmed' => true ), + 'normal' => array( 'edit' => true ), + 'normal2' => array( 'edit' => true, 'create' => true ), + 'admin' => array( 'protect' => true, 'delete' => true ), + ), + 'wgGrantPermissionGroups' => array( + 'hidden1' => 'hidden', + 'hidden2' => 'hidden', + 'normal' => 'normal-group', + 'admin' => 'admin', + ), + ) ); + } + + /** + * @covers MWGrants::getValidGrants + */ + public function testGetValidGrants() { + $this->assertSame( + array( 'hidden1', 'hidden2', 'normal', 'normal2', 'admin' ), + MWGrants::getValidGrants() + ); + } + + /** + * @covers MWGrants::getRightsByGrant + */ + public function testGetRightsByGrant() { + $this->assertSame( + array( + 'hidden1' => array( 'read' ), + 'hidden2' => array( 'autoconfirmed' ), + 'normal' => array( 'edit' ), + 'normal2' => array( 'edit', 'create' ), + 'admin' => array( 'protect', 'delete' ), + ), + MWGrants::getRightsByGrant() + ); + } + + /** + * @dataProvider provideGetGrantRights + * @covers MWGrants::getGrantRights + * @param array|string $grants + * @param array $rights + */ + public function testGetGrantRights( $grants, $rights ) { + $this->assertSame( $rights, MWGrants::getGrantRights( $grants ) ); + } + + public static function provideGetGrantRights() { + return array( + array( 'hidden1', array( 'read' ) ), + array( array( 'hidden1', 'hidden2', 'hidden3' ), array( 'read', 'autoconfirmed' ) ), + array( array( 'normal1', 'normal2' ), array( 'edit', 'create' ) ), + ); + } + + /** + * @dataProvider provideGrantsAreValid + * @covers MWGrants::grantsAreValid + * @param array $grants + * @param bool $valid + */ + public function testGrantsAreValid( $grants, $valid ) { + $this->assertSame( $valid, MWGrants::grantsAreValid( $grants ) ); + } + + public static function provideGrantsAreValid() { + return array( + array( array( 'hidden1', 'hidden2' ), true ), + array( array( 'hidden1', 'hidden3' ), false ), + ); + } + + /** + * @dataProvider provideGetGrantGroups + * @covers MWGrants::getGrantGroups + * @param array|null $grants + * @param array $expect + */ + public function testGetGrantGroups( $grants, $expect ) { + $this->assertSame( $expect, MWGrants::getGrantGroups( $grants ) ); + } + + public static function provideGetGrantGroups() { + return array( + array( null, array( + 'hidden' => array( 'hidden1', 'hidden2' ), + 'normal-group' => array( 'normal' ), + 'other' => array( 'normal2' ), + 'admin' => array( 'admin' ), + ) ), + array( array( 'hidden1', 'normal' ), array( + 'hidden' => array( 'hidden1' ), + 'normal-group' => array( 'normal' ), + ) ), + ); + } + + /** + * @covers MWGrants::getHiddenGrants + */ + public function testGetHiddenGrants() { + $this->assertSame( array( 'hidden1', 'hidden2' ), MWGrants::getHiddenGrants() ); + } + +} diff --git a/tests/phpunit/includes/utils/MWRestrictionsTest.php b/tests/phpunit/includes/utils/MWRestrictionsTest.php new file mode 100644 index 0000000000..66a1130c54 --- /dev/null +++ b/tests/phpunit/includes/utils/MWRestrictionsTest.php @@ -0,0 +1,215 @@ + array( + '10.0.0.0/8', + '172.16.0.0/12', + '2001:db8::/33', + ) + ) ); + } + + /** + * @covers MWRestrictions::newDefault + * @covers MWRestrictions::__construct + */ + public function testNewDefault() { + $ret = MWRestrictions::newDefault(); + $this->assertInstanceOf( 'MWRestrictions', $ret ); + $this->assertSame( + '{"IPAddresses":["0.0.0.0/0","::/0"]}', + $ret->toJson() + ); + } + + /** + * @covers MWRestrictions::newFromArray + * @covers MWRestrictions::__construct + * @covers MWRestrictions::loadFromArray + * @covers MWRestrictions::toArray + * @dataProvider provideArray + * @param array $data + * @param bool|InvalidArgumentException $expect True if the call succeeds, + * otherwise the exception that should be thrown. + */ + public function testArray( $data, $expect ) { + if ( $expect === true ) { + $ret = MWRestrictions::newFromArray( $data ); + $this->assertInstanceOf( 'MWRestrictions', $ret ); + $this->assertSame( $data, $ret->toArray() ); + } else { + try { + MWRestrictions::newFromArray( $data ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertEquals( $expect, $ex ); + } + } + } + + public static function provideArray() { + return array( + array( array( 'IPAddresses' => array() ), true ), + array( array( 'IPAddresses' => array( '127.0.0.1/32' ) ), true ), + array( + array( 'IPAddresses' => array( '256.0.0.1/32' ) ), + new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' ) + ), + array( + array( 'IPAddresses' => '127.0.0.1/32' ), + new InvalidArgumentException( 'IPAddresses is not an array' ) + ), + array( + array(), + new InvalidArgumentException( 'Array is missing required keys: IPAddresses' ) + ), + array( + array( 'foo' => 'bar', 'bar' => 42 ), + new InvalidArgumentException( 'Array contains invalid keys: foo, bar' ) + ), + ); + } + + /** + * @covers MWRestrictions::newFromJson + * @covers MWRestrictions::__construct + * @covers MWRestrictions::loadFromArray + * @covers MWRestrictions::toJson + * @covers MWRestrictions::__toString + * @dataProvider provideJson + * @param string $json + * @param array|InvalidArgumentException $expect + */ + public function testJson( $json, $expect ) { + if ( is_array( $expect ) ) { + $ret = MWRestrictions::newFromJson( $json ); + $this->assertInstanceOf( 'MWRestrictions', $ret ); + $this->assertSame( $expect, $ret->toArray() ); + + $this->assertSame( $json, $ret->toJson( false ) ); + $this->assertSame( $json, (string)$ret ); + + $this->assertSame( + FormatJson::encode( $expect, true, FormatJson::ALL_OK ), + $ret->toJson( true ) + ); + } else { + try { + MWRestrictions::newFromJson( $json ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertTrue( true ); + } + } + } + + public static function provideJson() { + return array( + array( + '{"IPAddresses":[]}', + array( 'IPAddresses' => array() ) + ), + array( + '{"IPAddresses":["127.0.0.1/32"]}', + array( 'IPAddresses' => array( '127.0.0.1/32' ) ) + ), + array( + '{"IPAddresses":["256.0.0.1/32"]}', + new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' ) + ), + array( + '{"IPAddresses":"127.0.0.1/32"}', + new InvalidArgumentException( 'IPAddresses is not an array' ) + ), + array( + '{}', + new InvalidArgumentException( 'Array is missing required keys: IPAddresses' ) + ), + array( + '{"foo":"bar","bar":42}', + new InvalidArgumentException( 'Array contains invalid keys: foo, bar' ) + ), + array( + '{"IPAddresses":[]', + new InvalidArgumentException( 'Invalid restrictions JSON' ) + ), + array( + '"IPAddresses"', + new InvalidArgumentException( 'Invalid restrictions JSON' ) + ), + ); + } + + /** + * @covers MWRestrictions::checkIP + * @dataProvider provideCheckIP + * @param string $ip + * @param bool $pass + */ + public function testCheckIP( $ip, $pass ) { + $this->assertSame( $pass, self::$restrictionsForChecks->checkIP( $ip ) ); + } + + public static function provideCheckIP() { + return array( + array( '10.0.0.1', true ), + array( '172.16.0.0', true ), + array( '192.0.2.1', false ), + array( '2001:db8:1::', true ), + array( '2001:0db8:0000:0000:0000:0000:0000:0000', true ), + array( '2001:0DB8:8000::', false ), + ); + } + + /** + * @covers MWRestrictions::check + * @dataProvider provideCheck + * @param WebRequest $request + * @param Status $expect + */ + public function testCheck( $request, $expect ) { + $this->assertEquals( $expect, self::$restrictionsForChecks->check( $request ) ); + } + + public function provideCheck() { + $ret = array(); + + $mockBuilder = $this->getMockBuilder( 'FauxRequest' ) + ->setMethods( array( 'getIP' ) ); + + foreach ( self::provideCheckIP() as $checkIP ) { + $ok = array(); + $request = $mockBuilder->getMock(); + + $request->expects( $this->any() )->method( 'getIP' ) + ->will( $this->returnValue( $checkIP[0] ) ); + $ok['ip'] = $checkIP[1]; + + /* If we ever add more restrictions, add nested for loops here: + * foreach ( self::provideCheckFoo() as $checkFoo ) { + * $request->expects( $this->any() )->method( 'getFoo' ) + * ->will( $this->returnValue( $checkFoo[0] ); + * $ok['foo'] = $checkFoo[1]; + * + * foreach ( self::provideCheckBar() as $checkBar ) { + * $request->expects( $this->any() )->method( 'getBar' ) + * ->will( $this->returnValue( $checkBar[0] ); + * $ok['bar'] = $checkBar[1]; + * + * // etc. + * } + * } + */ + + $status = Status::newGood(); + $status->setResult( $ok === array_filter( $ok ), $ok ); + $ret[] = array( $request, $status ); + } + + return $ret; + } +} -- 2.20.1