blob: 2e676b3704d95081cf50e50f9f75c67069edcc28 [file] [log] [blame]
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\Extension\CentralAuth\User;
use AbstractPbkdf2Password;
use CentralAuthSessionProvider;
use DeferredUpdates;
use Exception;
use FormattedRCFeed;
use IContextSource;
use IDBAccessObject;
use ManualLogEntry;
use MapCacheLRU;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\DAO\WikiAwareEntity;
use MediaWiki\Extension\CentralAuth\CentralAuthReadOnlyError;
use MediaWiki\Extension\CentralAuth\CentralAuthServices;
use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameUserStatus;
use MediaWiki\Extension\CentralAuth\LocalUserNotFoundException;
use MediaWiki\Extension\CentralAuth\RCFeed\CARCFeedFormatter;
use MediaWiki\Extension\CentralAuth\WikiSet;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Session\SessionManager;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\WikiMap\WikiMap;
use MWCryptHash;
use MWCryptRand;
use Password;
use PasswordError;
use PasswordFactory;
use RCFeed;
use RequestContext;
use RevisionDeleteUser;
use RuntimeException;
use Status;
use stdClass;
use User;
use WANObjectCache;
use Wikimedia\AtEase\AtEase;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\IDatabase;
class CentralAuthUser implements IDBAccessObject {
/** @var MapCacheLRU Cache of loaded CentralAuthUsers */
private static $loadedUsers = null;
/**
* The username of the current user.
* @var string
*/
private $mName;
/** @var bool */
public $mStateDirty = false;
/** @var int|false */
private $mDelayInvalidation = 0;
/** @var string[]|null */
private $mAttachedArray;
/** @var string */
private $mEmail;
/** @var bool */
private $mEmailAuthenticated;
/**
* @var string|null
* @internal
*/
public $mHomeWiki;
/** @var int|null */
private $mHiddenLevel;
/** @var bool */
private $mLocked;
/**
* @var string|null As string, it is "\n"-imploded
*/
private $mAttachedList;
/** @var string */
private $mAuthenticationTimestamp;
/** @var string[]|null */
private $mGroups;
/** @var string[][] */
private $mRights;
/** @var array<string, string|null>|null */
private $mGroupExpirations;
/** @var string */
private $mPassword;
/** @var string */
private $mAuthToken;
/** @var string */
private $mSalt;
/** @var int|null */
private $mGlobalId;
/** @var bool */
private $mFromPrimary;
/** @var bool */
private $mIsAttached;
/** @var string */
private $mRegistration;
/** @var int */
private $mGlobalEditCount;
/** @var string */
private $mBeingRenamed;
/** @var string[] */
private $mBeingRenamedArray;
/** @var array[]|null */
protected $mAttachedInfo;
/** @var int */
protected $mCasToken = 0;
/** @var \Psr\Log\LoggerInterface */
private $logger;
/** @var string[] */
private static $mCacheVars = [
'mGlobalId',
'mSalt',
'mPassword',
'mAuthToken',
'mLocked',
'mHiddenLevel',
'mRegistration',
'mEmail',
'mAuthenticationTimestamp',
'mGroups',
'mGroupExpirations',
'mRights',
'mHomeWiki',
'mBeingRenamed',
# Store the string list instead of the array, to save memory, and
# avoid unserialize() overhead
'mAttachedList',
'mCasToken'
];
private const VERSION = 10;
public const HIDDEN_LEVEL_NONE = 0;
public const HIDDEN_LEVEL_LISTS = 1;
public const HIDDEN_LEVEL_SUPPRESSED = 2;
/**
* The maximum number of edits a user can have and still be hidden
*/
private const HIDE_CONTRIBLIMIT = 1000;
/**
* The possible responses from self::authenticate(),
* self::canAuthenticate() and self::authenticateWithToken().
*
* Constants are defined as lowercase strings for
* backwards compatibility.
*/
public const AUTHENTICATE_OK = "ok";
public const AUTHENTICATE_NO_USER = "no user";
public const AUTHENTICATE_LOCKED = "locked";
public const AUTHENTICATE_BAD_PASSWORD = "bad password";
public const AUTHENTICATE_BAD_TOKEN = "bad token";
public const AUTHENTICATE_GOOD_PASSWORD = "good password";
/**
* @note Don't call this directly. Use self::getInstanceByName() or
* self::getPrimaryInstanceByName() instead.
* @param string $username
* @param int $flags Supports CentralAuthUser::READ_LATEST to use the primary DB
*/
public function __construct( $username, $flags = 0 ) {
$this->mName = $username;
$this->resetState();
if ( ( $flags & self::READ_LATEST ) == self::READ_LATEST ) {
$this->mFromPrimary = true;
}
$this->logger = LoggerFactory::getInstance( 'CentralAuth' );
}
/**
* Fetch the cache
* @return MapCacheLRU
*/
private static function getUserCache() {
if ( self::$loadedUsers === null ) {
// Limit of 20 is arbitrary
self::$loadedUsers = new MapCacheLRU( 20 );
}
return self::$loadedUsers;
}
/**
* Explicitly set the (cached) CentralAuthUser object corresponding to the supplied User.
* @param UserIdentity $user
* @param CentralAuthUser $caUser
*/
public static function setInstance( UserIdentity $user, CentralAuthUser $caUser ) {
self::setInstanceByName( $user->getName(), $caUser );
}
/**
* Explicitly set the (cached) CentralAuthUser object corresponding to the supplied User.
* @param string $username Must be validated and canonicalized by the caller
* @param CentralAuthUser $caUser
*/
public static function setInstanceByName( $username, CentralAuthUser $caUser ) {
self::getUserCache()->set( $username, $caUser );
}
/**
* Create a (cached) CentralAuthUser object corresponding to the supplied User.
* @param UserIdentity $user
* @return CentralAuthUser
*/
public static function getInstance( UserIdentity $user ) {
return self::getInstanceByName( $user->getName() );
}
/**
* Create a (cached) CentralAuthUser object corresponding to the supplied user.
* @param string $username Must be validated and canonicalized by the caller
* @return CentralAuthUser
*/
public static function getInstanceByName( $username ) {
$cache = self::getUserCache();
$ret = $cache->get( $username );
if ( !$ret ) {
$ret = new self( $username );
$cache->set( $username, $ret );
}
return $ret;
}
/**
* Create a (cached) CentralAuthUser object corresponding to the supplied User.
* This object will use DB_PRIMARY.
* @param UserIdentity $user
* @return CentralAuthUser
* @since 1.37
*/
public static function getPrimaryInstance( UserIdentity $user ) {
return self::getPrimaryInstanceByName( $user->getName() );
}
/**
* Create a (cached) CentralAuthUser object corresponding to the supplied User.
* This object will use DB_PRIMARY.
* @param string $username Must be validated and canonicalized by the caller
* @return CentralAuthUser
* @since 1.37
*/
public static function getPrimaryInstanceByName( $username ) {
$cache = self::getUserCache();
$ret = $cache->get( $username );
if ( !$ret || !$ret->mFromPrimary ) {
$ret = new self( $username, self::READ_LATEST );
$cache->set( $username, $ret );
}
return $ret;
}
/**
* Test if this is a write-mode instance, and log if not.
*/
private function checkWriteMode() {
if ( !$this->mFromPrimary ) {
$this->logger->warning(
'Write mode called on replica-loaded object',
[ 'exception' => new RuntimeException() ]
);
}
}
/**
* @return IDatabase Primary database or replica based on shouldUsePrimaryDB()
* @throws CentralAuthReadOnlyError
*/
protected function getSafeReadDB() {
return CentralAuthServices::getDatabaseManager()->getCentralDB(
$this->shouldUsePrimaryDB() ? DB_PRIMARY : DB_REPLICA
);
}
/**
* Get (and init if needed) the value of mFromPrimary
*
* @return bool
*/
protected function shouldUsePrimaryDB() {
$dbManager = CentralAuthServices::getDatabaseManager();
if ( $dbManager->isReadOnly() ) {
return false;
}
if ( $this->mFromPrimary === null ) {
$this->mFromPrimary = $dbManager->centralLBHasRecentPrimaryChanges();
}
return $this->mFromPrimary;
}
/**
* Return query data needed to properly use self::newFromRow
* @return array (
* 'tables' => array,
* 'fields' => array,
* 'where' => array,
* 'options' => array,
* 'joinConds' => array,
* )
*/
public static function selectQueryInfo() {
return [
'tables' => [ 'globaluser', 'localuser' ],
'fields' => [
'gu_id', 'gu_name', 'lu_wiki', 'gu_salt', 'gu_password', 'gu_auth_token',
'gu_locked', 'gu_hidden_level', 'gu_registration', 'gu_email',
'gu_email_authenticated', 'gu_home_db', 'gu_cas_token'
],
'where' => [],
'options' => [],
'joinConds' => [
'localuser' => [ 'LEFT OUTER JOIN', [ 'gu_name=lu_name', 'lu_wiki' => WikiMap::getCurrentWikiId() ] ]
],
];
}
/**
* Get a CentralAuthUser object from a user's id
*
* @param int $id
* @return CentralAuthUser|bool false if no user exists with that id
*/
public static function newFromId( $id ) {
$name = CentralAuthServices::getDatabaseManager()->getCentralReplicaDB()->selectField(
'globaluser',
'gu_name',
[ 'gu_id' => $id ],
__METHOD__
);
return $name === false ? false : self::getInstanceByName( $name );
}
/**
* Get a primary CentralAuthUser object from a user's id
*
* @param int $id
* @return CentralAuthUser|bool false if no user exists with that id
* @since 1.37
*/
public static function newPrimaryInstanceFromId( $id ) {
$name = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB()->selectField(
'globaluser',
'gu_name',
[ 'gu_id' => $id ],
__METHOD__
);
return $name === false ? false : self::getPrimaryInstanceByName( $name );
}
/**
* Create a CentralAuthUser object from a joined globaluser/localuser row
*
* @param stdClass $row
* @param array $renameUser Empty if no rename is going on, else (oldname, newname)
* @param bool $fromPrimary
* @return CentralAuthUser
*/
public static function newFromRow( $row, $renameUser, $fromPrimary = false ) {
$caUser = new self( $row->gu_name );
$caUser->loadFromRow( $row, $renameUser, $fromPrimary );
return $caUser;
}
/**
* Create a CentralAuthUser object for a user who is known to be unattached.
* @param string $name The user name
* @param bool $fromPrimary
* @return CentralAuthUser
*/
public static function newUnattached( $name, $fromPrimary = false ) {
$caUser = new self( $name );
$caUser->loadFromRow( false, [], $fromPrimary );
return $caUser;
}
/**
* Clear state information cache
* Does not clear $this->mName, so the state information can be reloaded with loadState()
*/
protected function resetState() {
$this->mGlobalId = null;
$this->mGroups = null;
$this->mGroupExpirations = null;
$this->mAttachedArray = null;
$this->mAttachedList = null;
$this->mHomeWiki = null;
}
/**
* Load up state information, but don't use the cache
*/
public function loadStateNoCache() {
$this->loadState( true );
}
/**
* Lazy-load up the most commonly required state information
* @param bool $recache Force a load from the database then save back to the cache
*/
protected function loadState( $recache = false ) {
if ( $recache ) {
$this->resetState();
} elseif ( isset( $this->mGlobalId ) ) {
// Already loaded
return;
}
// Check the cache (unless the primary database was requested via READ_LATEST)
if ( !$recache && $this->mFromPrimary !== true ) {
$this->loadFromCache();
} else {
$this->loadFromDatabase();
}
}
/**
* Load user groups and rights from the database.
*
* @param bool $force Set to true to load even when already loaded.
*/
protected function loadGroups( bool $force = false ) {
if ( isset( $this->mGroups ) && !$force ) {
// Already loaded
return;
}
$this->logger->debug(
'Loading groups for global user {user}',
[ 'user' => $this->mName ]
);
// We need the user id from the database, but this should be checked by the getId accessor.
$db = $this->getSafeReadDB();
$res = $db->select(
[ 'global_group_permissions', 'global_user_groups' ],
[ 'ggp_permission', 'ggp_group', 'gug_expiry', ],
[
'ggp_group=gug_group',
'gug_user' => $this->getId(),
'gug_expiry IS NULL OR gug_expiry >= ' . $db->addQuotes( $db->timestamp() ),
],
__METHOD__
);
$resSets = $db->select(
[ 'global_user_groups', 'global_group_restrictions', 'wikiset' ],
[ 'ggr_group', 'ws_id', 'ws_name', 'ws_type', 'ws_wikis' ],
[
'ggr_group=gug_group',
'gug_expiry IS NULL OR gug_expiry >= ' . $db->addQuotes( $db->timestamp() ),
'ggr_set=ws_id',
'gug_user' => $this->getId()
],
__METHOD__
);
$sets = [];
foreach ( $resSets as $row ) {
/* @var stdClass $row */
$sets[$row->ggr_group] = WikiSet::newFromRow( $row );
}
// Grab the user's rights/groups.
$rights = [];
$groups = [];
foreach ( $res as $row ) {
/** @var UserIdentity|bool $set */
$set = $sets[$row->ggp_group] ?? '';
$rights[] = [ 'right' => $row->ggp_permission, 'set' => $set ? $set->getId() : false ];
$groups[$row->ggp_group] = $row->gug_expiry;
}
$this->mRights = $rights;
$this->mGroups = array_keys( $groups );
$this->mGroupExpirations = $groups;
}
/**
* @return int|null Time when a global user group membership for this user will expire
* the next time in UNIX time, or null if this user has no temporary global group memberships.
*/
private function getClosestGlobalUserGroupExpiry(): ?int {
if ( !isset( $this->mGroupExpirations ) ) {
$this->loadGroups();
}
$closestExpiry = null;
foreach ( $this->mGroupExpirations as $expiration ) {
if ( !$expiration ) {
continue;
}
$expiration = wfTimestamp( TS_UNIX, $expiration );
if ( $closestExpiry ) {
$closestExpiry = min( $closestExpiry, $expiration );
} else {
$closestExpiry = $expiration;
}
}
return $closestExpiry;
}
protected function loadFromDatabase() {
$this->logger->debug(
'Loading state for global user {user} from DB',
[ 'user' => $this->mName ]
);
$fromPrimary = $this->shouldUsePrimaryDB();
$db = $this->getSafeReadDB(); // matches $fromPrimary above
$queryInfo = self::selectQueryInfo();
$row = $db->selectRow(
$queryInfo['tables'],
$queryInfo['fields'],
[ 'gu_name' => $this->mName ] + $queryInfo['where'],
__METHOD__,
$queryInfo['options'],
$queryInfo['joinConds']
);
$renameUserStatus = new GlobalRenameUserStatus( $this->mName );
$renameUser = $renameUserStatus->getNames( null, $fromPrimary ? 'primary' : 'replica' );
$this->loadFromRow( $row, $renameUser, $fromPrimary );
}
/**
* Load user state from a joined globaluser/localuser row
*
* @param stdClass|bool $row
* @param array $renameUser Empty if no rename is going on, else (oldname, newname)
* @param bool $fromPrimary
*/
protected function loadFromRow( $row, $renameUser, $fromPrimary = false ) {
if ( $row ) {
$this->mGlobalId = intval( $row->gu_id );
$this->mIsAttached = ( $row->lu_wiki !== null );
$this->mSalt = $row->gu_salt;
$this->mPassword = $row->gu_password;
$this->mAuthToken = $row->gu_auth_token;
$this->mLocked = $row->gu_locked;
$this->mHiddenLevel = (int)$row->gu_hidden_level;
$this->mRegistration = wfTimestamp( TS_MW, $row->gu_registration );
$this->mEmail = $row->gu_email;
$this->mAuthenticationTimestamp =
wfTimestampOrNull( TS_MW, $row->gu_email_authenticated );
$this->mHomeWiki = $row->gu_home_db;
$this->mCasToken = $row->gu_cas_token;
} else {
$this->mGlobalId = 0;
$this->mIsAttached = false;
$this->mLocked = false;
$this->mHiddenLevel = self::HIDDEN_LEVEL_NONE;
$this->mCasToken = 0;
}
$this->mFromPrimary = $fromPrimary;
$this->mBeingRenamedArray = $renameUser ?? [];
$this->mBeingRenamed = implode( '|', $this->mBeingRenamedArray );
}
/**
* Load data from memcached
*
* @return bool
*/
protected function loadFromCache() {
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
$data = $cache->getWithSetCallback(
$this->getCacheKey( $cache ),
$cache::TTL_DAY,
function ( $oldValue, &$ttl, array &$setOpts ) {
$dbr = CentralAuthServices::getDatabaseManager()->getCentralReplicaDB();
$setOpts += Database::getCacheSetOptions( $dbr );
$this->loadFromDatabase();
$this->loadAttached();
$this->loadGroups();
// if this user has global user groups expiring in less than the default TTL (1 day),
// max out the TTL so that then-expired user groups will not be loaded from cache
$closestGugExpiry = $this->getClosestGlobalUserGroupExpiry();
if ( $closestGugExpiry ) {
$ttl = min( $closestGugExpiry - time(), $ttl );
}
$data = [];
foreach ( self::$mCacheVars as $var ) {
$data[$var] = $this->$var;
}
return $data;
},
[ 'pcTTL' => $cache::TTL_PROC_LONG, 'version' => self::VERSION ]
);
$this->loadFromCacheObject( $data );
return true;
}
/**
* Load user state from a cached array.
*
* @param array $object
*/
protected function loadFromCacheObject( array $object ) {
$this->logger->debug(
'Loading CentralAuthUser for user {user} from cache object',
[ 'user' => $this->mName ]
);
foreach ( self::$mCacheVars as $var ) {
$this->$var = $object[$var];
}
$this->loadAttached();
$this->mIsAttached = $this->exists() && in_array( WikiMap::getCurrentWikiId(), $this->mAttachedArray );
$this->mFromPrimary = false;
$closestUserGroupExpiration = $this->getClosestGlobalUserGroupExpiry();
if ( $closestUserGroupExpiration !== null && $closestUserGroupExpiration < time() ) {
$this->logger->warning(
'Cached user {user} had a global group expiration in the past '
. '({unixTimestamp}), this should not be possible',
[
'user' => $this->getName(),
'unixTimestamp' => $closestUserGroupExpiration,
]
);
// load accurate data for this request from the database
$this->loadGroups( true );
// kill the current cache entry so that next request can use the cached value
$this->quickInvalidateCache();
}
}
/**
* Return the global account ID number for this account, if it exists.
* @return int
*/
public function getId() {
$this->loadState();
return $this->mGlobalId;
}
/**
* Return the local user account ID of the user with the same name on given wiki,
* irrespective of whether it is attached or not
* @param string $wikiId ID for the local database to connect to
* @return int|null Local user ID for given $wikiID. Null if $wikiID is invalid or local user
* doesn't exist
*/
public function getLocalId( $wikiId ) {
// Make sure the wiki ID is valid. (This prevents DBConnectionError in unit tests)
$wikiList = CentralAuthServices::getWikiListService()->getWikiList();
if ( !in_array( $wikiId, $wikiList ) ) {
return null;
}
// Retrieve the local user ID from the specified database.
$db = CentralAuthServices::getDatabaseManager()->getLocalDB( DB_PRIMARY, $wikiId );
$id = $db->selectField( 'user', 'user_id', [ 'user_name' => $this->mName ], __METHOD__ );
return $id ? (int)$id : null;
}
/**
* Generate a valid memcached key for caching the object's data.
* @param WANObjectCache $cache
* @return string
*/
protected function getCacheKey( WANObjectCache $cache ) {
return $cache->makeGlobalKey( 'centralauth-user', md5( $this->mName ) );
}
/**
* Return the global account's name, whether it exists or not.
* @return string
*/
public function getName() {
return $this->mName;
}
/**
* @return bool True if the account is attached on the local wiki
*/
public function isAttached() {
$this->loadState();
return $this->mIsAttached;
}
/**
* Return the password.
*
* @return Password
*/
public function getPasswordObject() {
$this->loadState();
return $this->getPasswordFromString( $this->mPassword, $this->mSalt );
}
/**
* Return the global-login token for this account.
* @return string
*/
public function getAuthToken() {
global $wgAuthenticationTokenVersion;
$this->loadState();
if ( !isset( $this->mAuthToken ) || !$this->mAuthToken ) {
$this->resetAuthToken();
}
if ( $wgAuthenticationTokenVersion === null ) {
return $this->mAuthToken;
} else {
$ret = MWCryptHash::hmac( $wgAuthenticationTokenVersion, $this->mAuthToken, false );
// The raw hash can be overly long. Shorten it up.
if ( strlen( $ret ) < 32 ) {
// Should never happen, even md5 is 128 bits
throw new \UnexpectedValueException( 'Hmac returned less than 128 bits' );
}
return substr( $ret, -32 );
}
}
/**
* Check whether a global user account for this name exists yet.
* If migration state is set for pass 1, this may trigger lazy
* evaluation of automatic migration for the account.
*
* @return bool
*/
public function exists() {
return (bool)$this->getId();
}
/**
* Returns whether the account is
* locked.
* @return bool
*/
public function isLocked() {
$this->loadState();
return (bool)$this->mLocked;
}
/**
* Returns whether user name should not
* be shown in public lists.
* @return bool
*/
public function isHidden() {
$this->loadState();
return $this->mHiddenLevel !== self::HIDDEN_LEVEL_NONE;
}
/**
* Returns whether user's name should
* be hidden from all public views because
* of privacy issues.
* @return bool
*/
public function isSuppressed() {
$this->loadState();
return $this->mHiddenLevel == self::HIDDEN_LEVEL_SUPPRESSED;
}
/**
* Returns the hidden level of the account.
* @throws Exception for now
* @return never
* @deprecated use getHiddenLevelInt() instead
*/
public function getHiddenLevel(): int {
// Have it like this for one train, then rename getHiddenLevelInt to this
throw new Exception( 'Nothing should call this!' );
}
/**
* Temporary name, will be getHiddenLevel() when migration is complete
* @return int one of self::HIDDEN_LEVEL_* constants
*/
public function getHiddenLevelInt(): int {
$this->loadState();
return $this->mHiddenLevel;
}
/**
* @return string timestamp
*/
public function getRegistration() {
$this->loadState();
return wfTimestamp( TS_MW, $this->mRegistration );
}
/**
* Return the id of the user's home wiki.
*
* @return string|null Null if the account has no attached wikis
*/
public function getHomeWiki() {
$this->loadState();
if ( $this->mHomeWiki !== null && $this->mHomeWiki !== '' ) {
return $this->mHomeWiki;
}
$attached = $this->queryAttachedBasic();
if ( !count( $attached ) ) {
return null;
}
foreach ( $attached as $wiki => $acc ) {
if ( $acc['attachedMethod'] == 'primary' || $acc['attachedMethod'] == 'new' ) {
$this->mHomeWiki = $wiki;
break;
}
}
if ( $this->mHomeWiki === null || $this->mHomeWiki === '' ) {
// Still null... try harder.
$attached = $this->queryAttached();
$this->mHomeWiki = key( $attached ); // Make sure we always have some value
$maxEdits = -1;
foreach ( $attached as $wiki => $acc ) {
if ( isset( $acc['editCount'] ) && $acc['editCount'] > $maxEdits ) {
$this->mHomeWiki = $wiki;
$maxEdits = $acc['editCount'];
}
}
}
return $this->mHomeWiki;
}
/**
* @return int total number of edits for all wikis
*/
public function getGlobalEditCount() {
if ( $this->mGlobalEditCount === null ) {
$this->mGlobalEditCount = CentralAuthServices::getEditCounter()
->getCount( $this );
}
return $this->mGlobalEditCount;
}
/**
* Register a new, not previously existing, central user account
* Remaining fields are expected to be filled out shortly...
* eeeyuck
*
* @param string|null $password
* @param string $email
* @return bool
*/
public function register( $password, $email ) {
$this->checkWriteMode();
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
list( $salt, $hash ) = $this->saltedPassword( $password );
if ( !$this->mAuthToken ) {
$this->mAuthToken = MWCryptRand::generateHex( 32 );
}
$data = [
'gu_name' => $this->mName,
'gu_email' => $email,
'gu_email_authenticated' => null,
'gu_salt' => $salt,
'gu_password' => $hash,
'gu_auth_token' => $this->mAuthToken,
'gu_locked' => 0,
'gu_hidden_level' => self::HIDDEN_LEVEL_NONE,
'gu_registration' => $dbw->timestamp(),
];
$dbw->insert(
'globaluser',
$data,
__METHOD__,
[ 'IGNORE' ]
);
$ok = $dbw->affectedRows() === 1;
$this->logger->info(
$ok
? 'registered global account "{user}"'
: 'registration failed for global account "{user}"',
[ 'user' => $this->mName ]
);
if ( $ok ) {
// Avoid lazy initialisation of edit count
$dbw->insert(
'global_edit_count',
[
'gec_user' => $dbw->insertId(),
'gec_count' => 0
],
__METHOD__
);
}
// Kill any cache entries saying we don't exist
$this->invalidateCache();
return $ok;
}
/**
* For use in migration pass zero.
* Store local user data into the auth server's migration table.
* @param string $wiki Source wiki ID
* @param array $users Associative array of ids => names
*/
public static function storeMigrationData( $wiki, $users ) {
if ( !$users ) {
return;
}
$globalTuples = [];
$tuples = [];
foreach ( $users as $name ) {
$globalTuples[] = [ 'gn_name' => $name ];
$tuples[] = [
'ln_wiki' => $wiki,
'ln_name' => $name
];
}
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->insert(
'globalnames',
$globalTuples,
__METHOD__,
[ 'IGNORE' ]
);
$dbw->insert(
'localnames',
$tuples,
__METHOD__,
[ 'IGNORE' ]
);
}
/**
* Store global user data in the auth server's main table.
*
* @param string $salt
* @param string $hash
* @param string $email
* @param string $emailAuth timestamp
* @return bool Whether we were successful or not.
*/
protected function storeGlobalData( $salt, $hash, $email, $emailAuth ) {
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$data = [
'gu_name' => $this->mName,
'gu_salt' => $salt,
'gu_password' => $hash,
'gu_auth_token' => MWCryptRand::generateHex( 32 ), // So it doesn't have to be done later
'gu_email' => $email,
'gu_email_authenticated' => $dbw->timestampOrNull( $emailAuth ),
'gu_registration' => $dbw->timestamp(), // hmmmm
'gu_locked' => 0,
'gu_hidden_level' => self::HIDDEN_LEVEL_NONE,
];
$dbw->insert(
'globaluser',
$data,
__METHOD__,
[ 'IGNORE' ]
);
$this->resetState();
return $dbw->affectedRows() != 0;
}
/**
* @param string[] $passwords
* @param bool $sendToRC
* @param bool $safe Only allow migration if all users can be migrated
* @param bool $checkHome Re-check the user's ownership of the home wiki
* @return bool
*/
public function storeAndMigrate(
$passwords = [], $sendToRC = true, $safe = false, $checkHome = false
) {
$ret = $this->attemptAutoMigration( $passwords, $sendToRC, $safe, $checkHome );
if ( $ret === true ) {
$this->recordAntiSpoof();
}
return $ret;
}
/**
* Record the current username in the AntiSpoof system
*/
protected function recordAntiSpoof() {
$spoof = CentralAuthServices::getAntiSpoofManager()->getSpoofUser( $this->mName );
$spoof->record();
}
/**
* Remove the current username from the AntiSpoof system
*/
public function removeAntiSpoof() {
$spoof = CentralAuthServices::getAntiSpoofManager()->getSpoofUser( $this->mName );
$spoof->remove();
}
/**
* Out of the given set of local account data, pick which will be the
* initially-assigned home wiki.
*
* This will be the account with the highest edit count, either out of
* all privileged accounts or all accounts if none are privileged.
*
* @param array $migrationSet
* @throws Exception
* @return string|null
*/
public function chooseHomeWiki( $migrationSet ) {
if ( empty( $migrationSet ) ) {
throw new Exception( 'Logic error -- empty migration set in chooseHomeWiki' );
}
// Sysops get priority
$found = [];
$priorityGroups = [ 'checkuser', 'suppress', 'bureaucrat', 'sysop' ];
foreach ( $priorityGroups as $group ) {
foreach ( $migrationSet as $wiki => $local ) {
if ( isset( $local['groupMemberships'][$group] ) ) {
$found[] = $wiki;
}
}
if ( count( $found ) === 1 ) {
// Easy!
return $found[0];
} elseif ( $found ) {
// We'll check edit counts now...
break;
}
}
if ( !$found ) {
// No privileged accounts; look among the plebes...
$found = array_keys( $migrationSet );
}
$maxEdits = -1;
$homeWiki = null;
foreach ( $found as $wiki ) {
$count = $migrationSet[$wiki]['editCount'];
if ( $count > $maxEdits ) {
$homeWiki = $wiki;
$maxEdits = $count;
} elseif ( $count === $maxEdits ) {
// Tie, check earlier registration
// Note that registration might be "null", which means they're a super old account.
if ( !$homeWiki || $migrationSet[$wiki]['registration'] <
$migrationSet[$homeWiki]['registration']
) {
$homeWiki = $wiki;
} elseif ( $migrationSet[$wiki]['registration'] ===
$migrationSet[$homeWiki]['registration']
) {
// Another tie? Screw it, pick one randomly.
$wikis = [ $wiki, $homeWiki ];
$homeWiki = $wikis[mt_rand( 0, 1 )];
}
}
}
return $homeWiki;
}
/**
* Go through a list of migration data looking for those which
* can be automatically migrated based on the available criteria.
*
* @param array[] $migrationSet
* @param string[] $passwords Optional, pre-authenticated passwords.
* Should match an account which is known to be attached.
* @return string[] Array of <wiki> => <authentication method>
*/
public function prepareMigration( $migrationSet, $passwords = [] ) {
// If the primary account has an email address set,
// we can use it to match other accounts. If it doesn't,
// we can't be sure that the other accounts with no mail
// are the same person, so err on the side of caution.
// For additional safety, we'll only let the mail check
// propagate from a confirmed account
$passingMail = [];
if ( $this->mEmail != '' && $this->mEmailAuthenticated ) {
$passingMail[$this->mEmail] = true;
}
$passwordConfirmed = [];
// If we've got an authenticated password to work with, we can
// also assume their email addresses are useful for this purpose...
if ( $passwords ) {
foreach ( $migrationSet as $wiki => $local ) {
if ( $local['email'] && $local['emailAuthenticated'] &&
!isset( $passingMail[$local['email']] )
) {
// Test passwords only once here as comparing hashes is very expensive
$passwordConfirmed[$wiki] = $this->matchHashes(
$passwords,
$this->getPasswordFromString( $local['password'], $local['id'] )
);
if ( $passwordConfirmed[$wiki] ) {
$passingMail[$local['email']] = true;
}
}
}
}
$attach = [];
foreach ( $migrationSet as $wiki => $local ) {
$localName = "$this->mName@$wiki";
if ( $wiki == $this->mHomeWiki ) {
// Primary account holder... duh
$method = 'primary';
} elseif ( $local['emailAuthenticated'] && isset( $passingMail[$local['email']] ) ) {
// Same email address as the primary account, or the same email address as another
// password confirmed account, means we know they could reset their password, so we
// give them the account.
// Authenticated email addresses only to prevent merges with malicious users
$method = 'mail';
} elseif (
isset( $passwordConfirmed[$wiki] ) && $passwordConfirmed[$wiki] ||
!isset( $passwordConfirmed[$wiki] ) &&
$this->matchHashes(
$passwords,
$this->getPasswordFromString( $local['password'], $local['id'] )
)
) {
// Matches the pre-authenticated password, yay!
$method = 'password';
} else {
// Can't automatically resolve this account.
// If the password matches, it will be automigrated
// at next login. If no match, user will have to input
// the conflicting password or deal with the conflict.
$this->logger->info( 'unresolvable {user}', [ 'user' => $localName ] );
continue;
}
$this->logger->info( '$method {user}', [ 'user' => $localName ] );
$attach[$wiki] = $method;
}
return $attach;
}
/**
* Do a dry run -- pick a winning primary account and try to auto-merge
* as many as possible, but don't perform any actions yet.
*
* @param string[] $passwords
* @param string|false &$home set to false if no permission to do checks
* @param array &$attached on success, list of wikis which will be auto-attached
* @param array &$unattached on success, list of wikis which won't be auto-attached
* @param array &$methods on success, associative array of each wiki's attachment method *
* @return Status
*/
public function migrationDryRun( $passwords, &$home, &$attached, &$unattached, &$methods ) {
$this->checkWriteMode(); // Because it messes with $this->mEmail and so on
$home = false;
$attached = [];
$unattached = [];
// First, make sure we were given the current wiki's password.
$self = $this->localUserData( WikiMap::getCurrentWikiId() );
$selfPassword = $this->getPasswordFromString( $self['password'], $self['id'] );
if ( !$this->matchHashes( $passwords, $selfPassword ) ) {
$this->logger->info( 'dry run: failed self-password check' );
return Status::newFatal( 'wrongpassword' );
}
$migrationSet = $this->queryUnattached();
if ( empty( $migrationSet ) ) {
$this->logger->info( 'dry run: no accounts to merge, failed migration' );
return Status::newFatal( 'centralauth-merge-no-accounts' );
}
$home = $this->chooseHomeWiki( $migrationSet );
$local = $migrationSet[$home];
// And we need to match the home wiki before proceeding...
$localPassword = $this->getPasswordFromString( $local['password'], $local['id'] );
if ( $this->matchHashes( $passwords, $localPassword ) ) {
$this->logger->info(
'dry run: passed password match to home {home}',
[ 'home' => $home ]
);
} else {
$this->logger->info(
'dry run: failed password match to home {home}',
[ 'home' => $home ]
);
return Status::newFatal( 'centralauth-merge-home-password' );
}
$this->mHomeWiki = $home;
$this->mEmail = $local['email'];
$this->mEmailAuthenticated = $local['emailAuthenticated'];
$attach = $this->prepareMigration( $migrationSet, $passwords );
$all = array_keys( $migrationSet );
$attached = array_keys( $attach );
$unattached = array_diff( $all, $attached );
$methods = $attach;
sort( $attached );
sort( $unattached );
ksort( $methods );
return Status::newGood();
}
/**
* Promote an unattached account to a
* global one, using the provided homewiki
*
* @param string $wiki
* @return Status
*/
public function promoteToGlobal( $wiki ) {
$unattached = $this->queryUnattached();
if ( !isset( $unattached[$wiki] ) ) {
return Status::newFatal( 'promote-not-on-wiki' );
}
$info = $unattached[$wiki];
if ( $this->exists() ) {
return Status::newFatal( 'promote-already-exists' );
}
$ok = $this->storeGlobalData(
$info['id'],
$info['password'],
$info['email'],
$info['emailAuthenticated']
);
if ( !$ok ) {
// Race condition?
return Status::newFatal( 'promote-already-exists' );
}
$this->attach( $wiki, 'primary' );
$this->recordAntiSpoof();
return Status::newGood();
}
/**
* Choose an email address to use from an array as obtained via self::queryUnattached.
*
* @param array[] $wikisToAttach
*/
private function chooseEmail( array $wikisToAttach ) {
$this->checkWriteMode();
if ( $this->mEmail ) {
return;
}
foreach ( $wikisToAttach as $attachWiki ) {
if ( $attachWiki['email'] ) {
$this->mEmail = $attachWiki['email'];
$this->mEmailAuthenticated = $attachWiki['emailAuthenticated'];
if ( $attachWiki['emailAuthenticated'] ) {
// If the email is authenticated, stop searching
return;
}
}
}
}
/**
* Pick a winning primary account and try to auto-merge as many as possible.
* @fixme add some locking or something
*
* @param string[] $passwords
* @param bool $sendToRC
* @param bool $safe Only migrate if all accounts can be merged
* @param bool $checkHome Re-check the user's ownership of the home wiki
* @return bool Whether full automatic migration completed successfully.
*/
protected function attemptAutoMigration(
$passwords = [], $sendToRC = true, $safe = false, $checkHome = false
) {
$this->checkWriteMode();
$migrationSet = $this->queryUnattached();
$logger = $this->logger;
if ( empty( $migrationSet ) ) {
$logger->info( 'no accounts to merge, failed migration' );
return false;
}
if ( isset( $this->mHomeWiki ) ) {
if ( !array_key_exists( $this->mHomeWiki, $migrationSet ) ) {
$logger->info(
'Invalid home wiki specification \'{user}@{home}\'',
[ 'user' => $this->mName, 'home' => $this->mHomeWiki ]
);
return false;
}
} else {
$this->mHomeWiki = $this->chooseHomeWiki( $migrationSet );
}
$home = $migrationSet[$this->mHomeWiki];
// Check home wiki when the user is initiating this merge, just
// like we did in migrationDryRun
$homePassword = $this->getPasswordFromString( $home['password'], $home['id'] );
if ( $checkHome && !$this->matchHashes( $passwords, $homePassword ) ) {
$logger->info(
'auto migrate: failed password match to home {home}',
[ 'home' => $this->mHomeWiki ]
);
return false;
}
$this->mEmail = $home['email'];
$this->mEmailAuthenticated = $home['emailAuthenticated'];
// Pick all the local accounts matching the "primary" home account
$attach = $this->prepareMigration( $migrationSet, $passwords );
if ( $safe && count( $attach ) !== count( $migrationSet ) ) {
$logger->info(
'Safe auto-migration for \'{user}\' failed',
[ 'user' => $this->mName ]
);
return false;
}
$wikisToAttach = array_intersect_key( $migrationSet, $attach );
// The home wiki might not have an email set, but maybe an other account has one?
$this->chooseEmail( $wikisToAttach );
// storeGlobalData clears $this->mHomeWiki
$homeWiki = $this->mHomeWiki;
// Actually do the migration
$ok = $this->storeGlobalData(
$home['id'],
$home['password'],
$this->mEmail,
$this->mEmailAuthenticated
);
if ( !$ok ) {
$logger->info(
'attemptedAutoMigration for existing entry \'{user}\'',
[ 'user' => $this->mName ]
);
return false;
}
if ( count( $attach ) < count( $migrationSet ) ) {
$logger->info(
'Incomplete migration for \'{user}\'',
[ 'user' => $this->mName ]
);
} else {
if ( count( $migrationSet ) == 1 ) {
$logger->info(
'Singleton migration for \'{user}\' on {home}',
[ 'user' => $this->mName, 'home' => $homeWiki ]
);
} else {
$logger->info(
'Full automatic migration for \'{user}\'',
[ 'user' => $this->mName ]
);
}
}
// Don't purge the cache 50 times.
$this->startTransaction();
foreach ( $attach as $wiki => $method ) {
$this->attach( $wiki, $method, $sendToRC );
}
$this->endTransaction();
return count( $attach ) == count( $migrationSet );
}
/**
* Attempt to migrate any remaining unattached accounts by virtue of
* the password check.
*
* @param string $password plaintext password to try matching
* @param string[] &$migrated Array of wiki IDs for records which were
* successfully migrated by this operation
* @param string[] &$remaining Array of wiki IDs for records which are still
* unattached after the operation
* @return bool true if all accounts are migrated at the end
*/
public function attemptPasswordMigration( $password, &$migrated = [], &$remaining = [] ) {
$rows = $this->queryUnattached();
$logger = $this->logger;
if ( count( $rows ) == 0 ) {
$logger->info(
'Already fully migrated user \'{user}\'',
[ 'user' => $this->mName ]
);
return true;
}
$migrated = [];
$remaining = [];
// Don't invalidate the cache 50 times
$this->startTransaction();
// Look for accounts we can match by password
foreach ( $rows as $row ) {
$wiki = $row['wiki'];
if ( $this->matchHash( $password,
$this->getPasswordFromString( $row['password'], $row['id'] ) )->isGood()
) {
$logger->info(
'Attaching \'{user}\' on {wiki} by password',
[
'user' => $this->mName,
'wiki' => $wiki
]
);
$this->attach( $wiki, 'password' );
$migrated[] = $wiki;
} else {
$logger->info(
'No password match for \'{user}\' on {wiki}',
[
'user' => $this->mName,
'wiki' => $wiki
]
);
$remaining[] = $wiki;
}
}
$this->endTransaction();
if ( count( $remaining ) == 0 ) {
$logger->info(
'Successful auto migration for \'{user}\'',
[ 'user' => $this->mName ]
);
return true;
}
$logger->info(
'Incomplete migration for \'{user}\'',
[ 'user' => $this->mName ]
);
return false;
}
/**
* @throws Exception
* @param string[] $list
* @return string[]
*/
protected static function validateList( $list ) {
$unique = array_unique( $list );
$wikiList = CentralAuthServices::getWikiListService()->getWikiList();
$valid = array_intersect( $unique, $wikiList );
if ( count( $valid ) != count( $list ) ) {
// fixme: handle this gracefully
throw new Exception( "Invalid input" );
}
return $valid;
}
/**
* Unattach a list of local accounts from the global account
* @param array $list List of wiki names
* @return Status
*/
public function adminUnattach( $list ) {
$this->checkWriteMode();
if ( !count( $list ) ) {
return Status::newFatal( 'centralauth-admin-none-selected' );
}
$status = new Status;
$valid = $this->validateList( $list );
$invalid = array_diff( $list, $valid );
foreach ( $invalid as $wikiName ) {
$status->error( 'centralauth-invalid-wiki', $wikiName );
$status->failCount++;
}
$databaseManager = CentralAuthServices::getDatabaseManager();
$dbcw = $databaseManager->getCentralPrimaryDB();
$password = $this->getPassword();
foreach ( $valid as $wikiName ) {
# Delete the user from the central localuser table
$dbcw->delete(
'localuser',
[
'lu_name' => $this->mName,
'lu_wiki' => $wikiName
],
__METHOD__
);
if ( !$dbcw->affectedRows() ) {
$wiki = WikiMap::getWiki( $wikiName );
$status->error( 'centralauth-admin-already-unmerged', $wiki->getDisplayName() );
$status->failCount++;
continue;
}
# Touch the local user row, update the password
$dblw = $databaseManager->getLocalDB( DB_PRIMARY, $wikiName );
$dblw->update(
'user',
[
'user_touched' => wfTimestampNow(),
'user_password' => $password
],
[ 'user_name' => $this->mName ],
__METHOD__
);
$userRow = $dblw->selectRow(
'user',
[ 'user_id', 'user_editcount' ],
[ 'user_name' => $this->mName ],
__METHOD__
);
# Remove the edits from the global edit count
$counter = CentralAuthServices::getEditCounter();
$counter->increment( $this, -(int)$userRow->user_editcount );
$this->clearLocalUserCache( $wikiName, $userRow->user_id );
$status->successCount++;
}
if ( in_array( WikiMap::getCurrentWikiId(), $valid ) ) {
$this->resetState();
}
$this->invalidateCache();
return $status;
}
/**
* Queue a job to unattach this user from a named wiki.
*
* @param string $wikiId
*/
protected function queueAdminUnattachJob( $wikiId ) {
$services = MediaWikiServices::getInstance();
$job = $services->getJobFactory()->newJob(
'CentralAuthUnattachUserJob',
[
'username' => $this->getName(),
'wiki' => $wikiId,
]
);
$services->getJobQueueGroupFactory()->makeJobQueueGroup( $wikiId )->lazyPush( $job );
}
/**
* Delete a global account and log what happened
*
* @param string $reason Reason for the deletion
* @param UserIdentity $deleter User doing the deletion
* @return Status
*/
public function adminDelete( $reason, UserIdentity $deleter ) {
$this->checkWriteMode();
$this->logger->info(
'Deleting global account for user \'{user}\'',
[ 'user' => $this->mName ]
);
$databaseManager = CentralAuthServices::getDatabaseManager();
$centralDB = $databaseManager->getCentralPrimaryDB();
# Synchronise passwords
$password = $this->getPassword();
$localUserRes = $centralDB->selectFieldValues(
'localuser',
'lu_wiki',
[ 'lu_name' => $this->mName ],
__METHOD__
);
foreach ( $localUserRes as $wiki ) {
$this->logger->debug( __METHOD__ . ": Fixing password on $wiki\n" );
$localDB = $databaseManager->getLocalDB( DB_PRIMARY, $wiki );
$localDB->update(
'user',
[ 'user_password' => $password ],
[ 'user_name' => $this->mName ],
__METHOD__
);
$id = $localDB->selectField(
'user',
'user_id',
[ 'user_name' => $this->mName ],
__METHOD__
);
$this->clearLocalUserCache( $wiki, $id );
}
$wasSuppressed = $this->isSuppressed();
$centralDB->startAtomic( __METHOD__ );
# Delete and lock the globaluser row
$centralDB->delete( 'globaluser', [ 'gu_name' => $this->mName ], __METHOD__ );
if ( !$centralDB->affectedRows() ) {
$centralDB->endAtomic( __METHOD__ );
return Status::newFatal( 'centralauth-admin-delete-nonexistent', $this->mName );
}
# Delete all global user groups for the user
$centralDB->delete( 'global_user_groups', [ 'gug_user' => $this->getId() ], __METHOD__ );
# Delete the localuser rows
$centralDB->delete( 'localuser', [ 'lu_name' => $this->mName ], __METHOD__ );
$centralDB->endAtomic( __METHOD__ );
if ( $wasSuppressed ) {
// "suppress/delete" is taken by core, so use "cadelete"
$this->logAction( 'cadelete', $deleter, $reason, [], /* $suppressLog = */ true );
} else {
$this->logAction( 'delete', $deleter, $reason, [], /* $suppressLog = */ false );
}
$this->invalidateCache();
return Status::newGood();
}
/**
* Lock a global account
*
* @return Status
*/
public function adminLock() {
$this->checkWriteMode();
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->update(
'globaluser',
[ 'gu_locked' => 1 ],
[ 'gu_name' => $this->mName ],
__METHOD__
);
if ( !$dbw->affectedRows() ) {
return Status::newFatal( 'centralauth-state-mismatch' );
}
$this->invalidateCache();
$user = User::newFromName( $this->mName );
SessionManager::singleton()->invalidateSessionsForUser( $user );
return Status::newGood();
}
/**
* Unlock a global account
*
* @return Status
*/
public function adminUnlock() {
$this->checkWriteMode();
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->update(
'globaluser',
[ 'gu_locked' => 0 ],
[ 'gu_name' => $this->mName ],
__METHOD__
);
if ( !$dbw->affectedRows() ) {
return Status::newFatal( 'centralauth-state-mismatch' );
}
$this->invalidateCache();
return Status::newGood();
}
/**
* Change account hiding level.
*
* @param int $level CentralAuthUser::HIDDEN_LEVEL_* class constant
* @return Status
*/
public function adminSetHidden( int $level ) {
$this->checkWriteMode();
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->update(
'globaluser',
[ 'gu_hidden_level' => $level ],
[ 'gu_name' => $this->mName ],
__METHOD__
);
if ( !$dbw->affectedRows() ) {
return Status::newFatal( 'centralauth-admin-unhide-nonexistent', $this->mName );
}
$this->invalidateCache();
return Status::newGood();
}
/**
* Set locking and hiding settings for a Global User and log the changes made.
*
* @param bool|null $setLocked
* true = lock
* false = unlock
* null = don't change
* @param int|null $setHidden
* hidden level, one of the HIDDEN_ constants
* null = don't change
* @param string $reason reason for hiding
* @param IContextSource $context
* @param bool $markAsBot Whether to mark the log entry in RC with the bot flag
* @return Status
*/
public function adminLockHide(
$setLocked, ?int $setHidden, $reason, IContextSource $context, bool $markAsBot = false
) {
$isLocked = $this->isLocked();
$oldHiddenLevel = $this->getHiddenLevelInt();
$lockStatus = $hideStatus = null;
$added = [];
$removed = [];
$user = $context->getUser();
if ( $setLocked === null ) {
$setLocked = $isLocked;
} elseif ( !$context->getAuthority()->isAllowed( 'centralauth-lock' ) ) {
return Status::newFatal( 'centralauth-admin-not-authorized' );
}
if ( $setHidden === null ) {
$setHidden = $oldHiddenLevel;
} elseif (
$setHidden !== self::HIDDEN_LEVEL_NONE
|| $oldHiddenLevel !== self::HIDDEN_LEVEL_NONE
) {
if ( !$context->getAuthority()->isAllowed( 'centralauth-suppress' ) ) {
return Status::newFatal( 'centralauth-admin-not-authorized' );
} elseif ( $this->getGlobalEditCount() > self::HIDE_CONTRIBLIMIT ) {
return Status::newFatal(
$context->msg( 'centralauth-admin-too-many-edits', $this->mName )
->numParams( self::HIDE_CONTRIBLIMIT )
);
}
}
$returnStatus = Status::newGood();
$hiddenLevels = [
self::HIDDEN_LEVEL_NONE,
self::HIDDEN_LEVEL_LISTS,
self::HIDDEN_LEVEL_SUPPRESSED,
];
// if not a known value, default to none
if ( !in_array( $setHidden, $hiddenLevels ) ) {
$setHidden = self::HIDDEN_LEVEL_NONE;
}
if ( !$isLocked && $setLocked ) {
$lockStatus = $this->adminLock();
$added[] = 'locked';
} elseif ( $isLocked && !$setLocked ) {
$lockStatus = $this->adminUnlock();
$removed[] = 'locked';
}
if ( $oldHiddenLevel != $setHidden ) {
$hideStatus = $this->adminSetHidden( $setHidden );
switch ( $setHidden ) {
case self::HIDDEN_LEVEL_NONE:
$removed[] = $oldHiddenLevel === self::HIDDEN_LEVEL_SUPPRESSED ?
'oversighted' :
'hidden';
break;
case self::HIDDEN_LEVEL_LISTS:
$added[] = 'hidden';
if ( $oldHiddenLevel === self::HIDDEN_LEVEL_SUPPRESSED ) {
$removed[] = 'oversighted';
}
break;
case self::HIDDEN_LEVEL_SUPPRESSED:
$added[] = 'oversighted';
if ( $oldHiddenLevel === self::HIDDEN_LEVEL_LISTS ) {
$removed[] = 'hidden';
}
break;
}
$userName = $user->getName();
if ( $setHidden === self::HIDDEN_LEVEL_SUPPRESSED ) {
$this->suppress( $userName, $reason );
} elseif ( $oldHiddenLevel === self::HIDDEN_LEVEL_SUPPRESSED ) {
$this->unsuppress( $userName, $reason );
}
}
$good = ( !$lockStatus || $lockStatus->isGood() ) &&
( !$hideStatus || $hideStatus->isGood() );
// Setup Status object to return all of the information for logging
if ( $good && ( $added || $removed ) ) {
$returnStatus->successCount = count( $added ) + count( $removed );
$this->logAction(
'setstatus',
$context->getUser(),
$reason,
[ 'added' => $added, 'removed' => $removed ],
$setHidden !== self::HIDDEN_LEVEL_NONE,
$markAsBot
);
} elseif ( !$good ) {
if ( $lockStatus !== null && !$lockStatus->isGood() ) {
$returnStatus->merge( $lockStatus );
}
if ( $hideStatus !== null && !$hideStatus->isGood() ) {
$returnStatus->merge( $hideStatus );
}
}
return $returnStatus;
}
/**
* Suppresses all user accounts in all wikis.
* @param string $name
* @param string $reason
*/
public function suppress( $name, $reason ) {
$this->doCrosswikiSuppression( true, $name, $reason );
}
/**
* Unsuppresses all user accounts in all wikis.
* @param string $name
* @param string $reason
*/
public function unsuppress( $name, $reason ) {
$this->doCrosswikiSuppression( false, $name, $reason );
}
/**
* @param bool $suppress
* @param string $by
* @param string $reason
*/
protected function doCrosswikiSuppression( $suppress, $by, $reason ) {
global $wgCentralAuthWikisPerSuppressJob;
$this->loadAttached();
if ( count( $this->mAttachedArray ) <= $wgCentralAuthWikisPerSuppressJob ) {
foreach ( $this->mAttachedArray as $wiki ) {
$this->doLocalSuppression( $suppress, $wiki, $by, $reason );
}
} else {
$jobParams = [
'username' => $this->getName(),
'suppress' => $suppress,
'by' => $by,
'reason' => $reason,
];
$jobs = [];
$services = MediaWikiServices::getInstance();
$jobFactory = $services->getJobFactory();
$chunks = array_chunk( $this->mAttachedArray, $wgCentralAuthWikisPerSuppressJob );
foreach ( $chunks as $wikis ) {
$jobParams['wikis'] = $wikis;
$jobs[] = $jobFactory->newJob(
'crosswikiSuppressUser',
$jobParams
);
}
// Push the jobs right before COMMIT (which is likely to succeed).
// If the job push fails, then the transaction will roll back.
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->onTransactionPreCommitOrIdle( static function () use ( $services, $jobs ) {
$services->getJobQueueGroup()->push( $jobs );
}, __METHOD__ );
}
}
/**
* Suppresses a local account of a user.
*
* @param bool $suppress
* @param string $wiki
* @param string $by
* @param string $reason
* @return array|null Error array on failure
*/
public function doLocalSuppression( $suppress, $wiki, $by, $reason ) {
global $wgConf, $wgCentralAuthGlobalBlockInterwikiPrefix;
$databaseManager = CentralAuthServices::getDatabaseManager();
$dbw = $databaseManager->getLocalDB( DB_PRIMARY, $wiki );
$data = $this->localUserData( $wiki );
if ( $suppress ) {
list( , $lang ) = $wgConf->siteFromDB( $wiki );
if ( !MediaWikiServices::getInstance()->getLanguageNameUtils()->isSupportedLanguage( $lang ) ) {
$lang = 'en';
}
$blockReason = wfMessage( 'centralauth-admin-suppressreason', $by, $reason )
->inLanguage( $lang )->text();
$wikiId = $wiki === WikiMap::getCurrentWikiId() ? WikiAwareEntity::LOCAL : $wiki;
// TODO DatabaseBlock is not @newable
$block = new DatabaseBlock( [
'address' => UserIdentityValue::newRegistered( $data['id'], $this->mName, $wikiId ),
'wiki' => $wikiId,
'reason' => $blockReason,
'timestamp' => wfTimestampNow(),
'expiry' => $dbw->getInfinity(),
'createAccount' => true,
// T281972: This is currently disabled because it doesn't work with xwiki blocks
// It is fine to disable temporarily, because locks do not have any autoblock mechanism anyway,
// and stewards are used to it.
'enableAutoblock' => false,
'hideName' => true,
'blockEmail' => true,
'by' => UserIdentityValue::newExternal(
$wgCentralAuthGlobalBlockInterwikiPrefix, $by, $wikiId
)
] );
# On normal block, BlockIp hook would be run here, but doing
# that from CentralAuth doesn't seem a good idea...
$databaseBlockStore = MediaWikiServices::getInstance()
->getDatabaseBlockStoreFactory()
->getDatabaseBlockStore( $wikiId );
if ( !$databaseBlockStore->insertBlock( $block ) ) {
return [ 'ipb_already_blocked' ];
}
# Ditto for BlockIpComplete hook.
RevisionDeleteUser::suppressUserName( $this->mName, $data['id'], $dbw );
# Locally log to suppress ?
} else {
$blockQuery = DatabaseBlock::getQueryInfo();
$ids = $dbw->selectFieldValues(
$blockQuery['tables'],
'ipb_id',
[
'ipb_user' => $data['id'],
$blockQuery['fields']['ipb_by'] . ' IS NULL', // Our blocks don't have an user associated
'ipb_deleted' => true,
],
__METHOD__,
[],
$blockQuery['joins']
);
if ( $ids ) {
$dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], __METHOD__ );
}
// Unsuppress only if unblocked
if ( $dbw->affectedRows() ) {
RevisionDeleteUser::unsuppressUserName( $this->mName, $data['id'], $dbw );
}
}
return null;
}
/**
* Add a local account record for the given wiki to the central database.
* @param string $wikiID
* @param string $method
* @param bool $sendToRC
* @param string|int $ts MediaWiki timestamp or 0 for current time
*
* Prerequisites:
* - completed migration state
*/
public function attach( $wikiID, $method = 'new', $sendToRC = true, $ts = 0 ) {
global $wgCentralAuthRC;
$this->checkWriteMode();
$dbcw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbcw->insert(
'localuser',
[
'lu_wiki' => $wikiID,
'lu_name' => $this->mName,
'lu_attached_timestamp' => $dbcw->timestamp( $ts ),
'lu_attached_method' => $method,
'lu_local_id' => $this->getLocalId( $wikiID ),
'lu_global_id' => $this->getId() ],
__METHOD__,
[ 'IGNORE' ]
);
$success = $dbcw->affectedRows() === 1;
if ( $wikiID === WikiMap::getCurrentWikiId() ) {
$this->resetState();
}
$this->invalidateCache();
if ( !$success ) {
$this->logger->info(
'Race condition? Already attached {user}@{wiki}, just tried by \'{method}\'',
[ 'user' => $this->mName, 'wiki' => $wikiID, 'method' => $method ]
);
return;
}
$this->logger->info(
'Attaching local user {user}@{wiki} by \'{method}\'',
[ 'user' => $this->mName, 'wiki' => $wikiID, 'method' => $method ]
);
$this->addLocalEdits( $wikiID );
if ( $sendToRC ) {
$userpage = Title::makeTitleSafe( NS_USER, $this->mName );
foreach ( $wgCentralAuthRC as $rc ) {
$engine = RCFeed::factory( $rc );
if ( !( $engine instanceof FormattedRCFeed ) ) {
throw new RuntimeException(
'wgCentralAuthRC only supports feeds that use FormattedRCFeed, got '
. get_class( $engine ) . ' instead'
);
}
/** @var CARCFeedFormatter $formatter */
$formatter = new $rc['formatter']();
$engine->send( $rc, $formatter->getLine( $userpage, $wikiID ) );
}
}
}
/**
* Add edits from a wiki to the global edit count
*
* @param string $wikiID
*/
protected function addLocalEdits( $wikiID ) {
$dblw = CentralAuthServices::getDatabaseManager()->getLocalDB( DB_PRIMARY, $wikiID );
$editCount = $dblw->selectField(
'user',
'user_editcount',
[ 'user_name' => $this->mName ],
__METHOD__
);
$counter = CentralAuthServices::getEditCounter();
$counter->increment( $this, $editCount );
}
/**
* If the user provides the correct password, would we let them log in?
* This encompasses checks on missing and locked accounts, at present.
* @return bool|string true if login available, or const authenticate status
*/
public function canAuthenticate() {
if ( !$this->getId() ) {
$this->logger->info(
"authentication for '{user}' failed due to missing account",
[ 'user' => $this->mName ]
);
return self::AUTHENTICATE_NO_USER;
}
// If the global account has been locked, we don't want to spam
// other wikis with local account creations.
if ( $this->isLocked() ) {
return self::AUTHENTICATE_LOCKED;
}
// Don't allow users to autocreate if they are oversighted.
// If they do, their name will appear on local user list
// (and since it contains private info, its unacceptable).
if ( $this->isSuppressed() ) {
// Avoid unnecessary database connections by only loading the user
// details if the account is suppressed, since that's a very small minority
// of login attempts for non-locked users.
$userIdentity = MediaWikiServices::getInstance()->getUserIdentityLookup()
->getUserIdentityByName( $this->getName() );
if ( !$userIdentity || !$userIdentity->isRegistered() ) {
return self::AUTHENTICATE_LOCKED;
}
}
return true;
}
/**
* Attempt to authenticate the global user account with the given password
* @param string $password
* @return string[] status represented by const(s) AUTHENTICATE_LOCKED,
* AUTHENTICATE_NO_USER, AUTHENTICATE_BAD_PASSWORD
* and AUTHENTICATE_OK
*/
public function authenticate( $password ) {
$canAuthenticate = $this->canAuthenticate();
if (
$canAuthenticate !== true &&
$canAuthenticate !== self::AUTHENTICATE_LOCKED
) {
return [ $canAuthenticate ];
}
$passwordMatchStatus = $this->matchHash( $password, $this->getPasswordObject() );
if ( $canAuthenticate === true ) {
if ( $passwordMatchStatus->isGood() ) {
$this->logger->info( "authentication for '{user}' succeeded", [ 'user' => $this->mName ] );
$passwordFactory = new PasswordFactory();
$passwordFactory->init( RequestContext::getMain()->getConfig() );
if ( $passwordFactory->needsUpdate( $passwordMatchStatus->getValue() ) ) {
DeferredUpdates::addCallableUpdate( function () use ( $password ) {
if ( CentralAuthServices::getDatabaseManager()->isReadOnly() ) {
return;
}
$centralUser = CentralAuthUser::newPrimaryInstanceFromId( $this->getId() );
if ( $centralUser ) {
// Don't bother resetting the auth token for a hash
// upgrade. It's not really a password *change*, and
// since this is being done post-send it'll cause the
// user to be logged out when they just tried to log in
// since it can't update the just-sent session cookies.
$centralUser->setPassword( $password, false );
$centralUser->saveSettings();
}
} );
}
return [ self::AUTHENTICATE_OK ];
} else {
$this->logger->info( "authentication for '{user}' failed, bad pass", [ 'user' => $this->mName ] );
return [ self::AUTHENTICATE_BAD_PASSWORD ];
}
} else {
if ( $passwordMatchStatus->isGood() ) {
$this->logger->info(
"authentication for '{user}' failed, correct pass but locked",
[ 'user' => $this->mName ]
);
return [ self::AUTHENTICATE_LOCKED ];
} else {
$this->logger->info(
"authentication for '{user}' failed, locked with wrong password",
[ 'user' => $this->mName ]
);
return [ self::AUTHENTICATE_BAD_PASSWORD, self::AUTHENTICATE_LOCKED ];
}
}
}
/**
* Attempt to authenticate the global user account with the given global authtoken
* @param string $token
* @return string status, one of: AUTHENTICATE_LOCKED,
* AUTHENTICATE_NO_USER, AUTHENTICATE_BAD_TOKEN
* and AUTHENTICATE_OK
*/
public function authenticateWithToken( $token ) {
$canAuthenticate = $this->canAuthenticate();
if ( $canAuthenticate !== true ) {
return $canAuthenticate;
}
return $this->validateAuthToken( $token ) ? self::AUTHENTICATE_OK : self::AUTHENTICATE_BAD_TOKEN;
}
/**
* @param string $plaintext User-provided password plaintext.
* @param Password $password Password to check against
*
* @return Status
*/
protected function matchHash( $plaintext, Password $password ) {
$matched = false;
if ( $password->verify( $plaintext ) ) {
$matched = true;
} elseif ( !( $password instanceof AbstractPbkdf2Password ) && function_exists( 'iconv' ) ) {
// Some wikis were converted from ISO 8859-1 to UTF-8;
// retained hashes may contain non-latin chars.
AtEase::suppressWarnings();
$latin1 = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $plaintext );
AtEase::restoreWarnings();
if ( $latin1 !== false && $password->verify( $latin1 ) ) {
$matched = true;
}
}
if ( $matched ) {
return Status::newGood( $password );
} else {
return Status::newFatal( 'bad' );
}
}
/**
* @param string[] $passwords
* @param Password $password Password to check against
*
* @return bool
*/
protected function matchHashes( array $passwords, Password $password ) {
foreach ( $passwords as $plaintext ) {
if ( $this->matchHash( $plaintext, $password )->isGood() ) {
return true;
}
}
return false;
}
/**
* @param string $encrypted Fully salted and hashed database crypto text from db.
* @param string $salt The hash "salt", eg a local id for migrated passwords.
*
* @return Password
* @throws PasswordError
*/
private function getPasswordFromString( $encrypted, $salt ) {
$passwordFactory = new PasswordFactory();
$passwordFactory->init( RequestContext::getMain()->getConfig() );
if ( preg_match( '/^[0-9a-f]{32}$/', $encrypted ) ) {
$encrypted = ":B:{$salt}:{$encrypted}";
}
return $passwordFactory->newFromCiphertext( $encrypted );
}
/**
* Fetch a list of databases where this account name is registered,
* but not yet attached to the global account. It would be used for
* an alert or management system to show which accounts have still
* to be dealt with.
*
* @return string[] of database name strings
*/
public function listUnattached() {
if ( IPUtils::isIPAddress( $this->mName ) ) {
return []; // don't bother with primary database queries
}
return $this->doListUnattached();
}
/**
* @return string[]
*/
private function doListUnattached() {
$databaseManager = CentralAuthServices::getDatabaseManager();
// Make sure lazy-loading in listUnattached() works, as we
// may need to *switch* to using the primary DB for this query
$db = $databaseManager->centralLBHasRecentPrimaryChanges()
? $databaseManager->getCentralPrimaryDB()
: $this->getSafeReadDB();
$result = $db->selectFieldValues(
[ 'localnames', 'localuser' ],
'ln_wiki',
[ 'ln_name' => $this->mName, 'lu_name IS NULL' ],
__METHOD__,
[],
[
'localuser' => [
'LEFT OUTER JOIN',
[ 'ln_wiki=lu_wiki', 'ln_name=lu_name' ]
]
]
);
$wikis = [];
foreach ( $result as $wiki ) {
if ( !WikiMap::getWiki( $wiki ) ) {
$this->logger->warning( __METHOD__ . ': invalid wiki in localnames: ' . $wiki );
continue;
}
$wikis[] = $wiki;
}
return $wikis;
}
/**
* @param string $wikiID
*/
public function addLocalName( $wikiID ) {
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->insert(
'localnames',
[
'ln_wiki' => $wikiID,
'ln_name' => $this->mName
],
__METHOD__,
[ 'IGNORE' ]
);
}
/**
* @param string $wikiID
*/
public function removeLocalName( $wikiID ) {
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->delete(
'localnames',
[
'ln_wiki' => $wikiID,
'ln_name' => $this->mName
],
__METHOD__
);
}
/**
* Updates the localname table after a rename
* @param string $wikiID
* @param string $newname
*/
public function updateLocalName( $wikiID, $newname ) {
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->update(
'localnames',
[ 'ln_name' => $newname ],
[ 'ln_wiki' => $wikiID, 'ln_name' => $this->mName ],
__METHOD__
);
}
/**
* Troll through the full set of local databases and list those
* which exist into the 'localnames' table.
*
* @return bool whether any results were found
*/
public function importLocalNames() {
$rows = [];
$databaseManager = CentralAuthServices::getDatabaseManager();
$wikiList = CentralAuthServices::getWikiListService()->getWikiList();
foreach ( $wikiList as $wikiID ) {
$dbr = $databaseManager->getLocalDB( DB_REPLICA, $wikiID );
$known = (bool)$dbr->selectField( 'user', '1',
[ 'user_name' => $this->mName ],
__METHOD__
);
if ( $known ) {
$rows[] = [ 'ln_wiki' => $wikiID, 'ln_name' => $this->mName ];
}
}
if ( $rows || $this->exists() ) {
$dbw = $databaseManager->getCentralPrimaryDB();
$dbw->startAtomic( __METHOD__ );
$dbw->insert(
'globalnames',
[ 'gn_name' => $this->mName ],
__METHOD__,
[ 'IGNORE' ]
);
if ( $rows ) {
$dbw->insert(
'localnames',
$rows,
__METHOD__,
[ 'IGNORE' ]
);
}
$dbw->endAtomic( __METHOD__ );
}
return (bool)$rows;
}
/**
* Load the list of databases where this account has been successfully
* attached
*/
public function loadAttached() {
if ( isset( $this->mAttachedArray ) ) {
// Already loaded
return;
}
if ( isset( $this->mAttachedList ) && $this->mAttachedList !== '' ) {
// We have a list already, probably from the cache.
$this->mAttachedArray = explode( "\n", $this->mAttachedList );
return;
}
$this->logger->debug(
"Loading attached wiki list for global user {$this->mName} from DB"
);
$db = $this->getSafeReadDB();
$wikis = $db->selectFieldValues(
'localuser',
'lu_wiki',
[ 'lu_name' => $this->mName ],
__METHOD__
);
$this->mAttachedArray = $wikis;
$this->mAttachedList = implode( "\n", $wikis );
}
/**
* Fetch a list of databases where this account has been successfully
* attached.
*
* @return string[] Database name strings
*/
public function listAttached() {
$this->loadAttached();
return $this->mAttachedArray;
}
/**
* Same as $this->renameInProgress, but only checks one wiki
* Not cached
* @see CentralAuthUser::renameInProgress
* @param string $wiki
* @param int $flags Bitfield of CentralAuthUser::READ_* constants
* @return string[]|false
*/
public function renameInProgressOn( $wiki, $flags = 0 ) {
$renameState = new GlobalRenameUserStatus( $this->mName );
// Use primary database as this is being used for various critical things
$names = $renameState->getNames(
$wiki,
( $flags & self::READ_LATEST ) == self::READ_LATEST ? 'primary' : 'replica'
);
return $names ?: false;
}
/**
* Check if a rename from the old name is in progress
* @return string[] (oldname, newname) if being renamed, or empty if not
*/
public function renameInProgress() {
$this->loadState();
if ( $this->mBeingRenamedArray === null ) {
$this->mBeingRenamedArray = $this->mBeingRenamed === ''
? [] : explode( '|', $this->mBeingRenamed );
}
return $this->mBeingRenamedArray;
}
/**
* Returns a list of all groups where the user is a member of the group on at
* least one wiki where their account is attached.
* @return string[] List of group names where the user is a member on at least one wiki
*/
public function getLocalGroups() {
$localgroups = [];
foreach ( $this->queryAttached() as $local ) {
$localgroups = array_unique( array_merge(
$localgroups, array_keys( $local['groupMemberships'] )
) );
}
return $localgroups;
}
/**
* Get information about each local user attached to this account
*
* @return array[] Map of database name to property table with members:
* wiki The wiki ID (database name)
* attachedTimestamp The MW timestamp when the account was attached
* attachedMethod Attach method: password, mail or primary
* ... All information returned by localUserData()
*/
public function queryAttached() {
// Cache $wikis to avoid expensive query whenever possible
// mAttachedInfo is shared with queryAttachedBasic(); check whether it contains partial data
if (
$this->mAttachedInfo !== null
&& ( !$this->mAttachedInfo || array_key_exists( 'id', reset( $this->mAttachedInfo ) ) )
) {
return $this->mAttachedInfo;
}
$wikis = $this->queryAttachedBasic();
foreach ( $wikis as $wikiId => $_ ) {
try {
$localUser = $this->localUserData( $wikiId );
$wikis[$wikiId] = array_merge( $wikis[$wikiId], $localUser );
} catch ( LocalUserNotFoundException $e ) {
// T119736: localuser table told us that the user was attached
// from $wikiId but there is no data in the primary database or replicas
// that corroborates that.
unset( $wikis[$wikiId] );
// Queue a job to delete the bogus attachment record.
$this->queueAdminUnattachJob( $wikiId );
}
}
$this->mAttachedInfo = $wikis;
return $wikis;
}
/**
* Helper method for queryAttached().
*
* Does the cheap part of the lookup by checking the cross-wiki localuser table,
* and returns attach time and method.
*
* @return array[]
*/
protected function queryAttachedBasic() {
if ( $this->mAttachedInfo !== null ) {
return $this->mAttachedInfo;
}
$db = $this->getSafeReadDB();
$result = $db->select(
'localuser',
[
'lu_wiki',
'lu_attached_timestamp',
'lu_attached_method',
],
[ 'lu_name' => $this->mName, ],
__METHOD__ );
$wikis = [];
foreach ( $result as $row ) {
/** @var stdClass $row */
if ( !WikiMap::getWiki( $row->lu_wiki ) ) {
$this->logger->warning( __METHOD__ . ': invalid wiki in localuser: ' . $row->lu_wiki );
continue;
}
$wikis[$row->lu_wiki] = [
'wiki' => $row->lu_wiki,
'attachedTimestamp' => wfTimestampOrNull( TS_MW, $row->lu_attached_timestamp ),
'attachedMethod' => $row->lu_attached_method,
];
}
$this->mAttachedInfo = $wikis;
return $wikis;
}
/**
* Find any remaining migration records for this username which haven't gotten attached to
* some global account.
* Formatted as associative array with some data.
*
* @throws Exception
* @return array[]
*/
public function queryUnattached() {
$wikiIDs = $this->listUnattached();
$items = [];
foreach ( $wikiIDs as $wikiID ) {
try {
$items[$wikiID] = $this->localUserData( $wikiID );
} catch ( LocalUserNotFoundException $e ) {
// T119736: localnames table told us that the name was
// unattached on $wikiId but there is no data in the primary database
// or replicas that corroborates that.
// Queue a job to delete the bogus record.
$this->queueAdminUnattachJob( $wikiID );
}
}
return $items;
}
/**
* Fetch a row of user data needed for migration.
*
* Returns most data in the user and ipblocks tables, user groups, and editcount.
*
* @param string $wikiID
* @throws LocalUserNotFoundException if local user not found
* @return array
*/
protected function localUserData( $wikiID ) {
$mwServices = MediaWikiServices::getInstance();
$blockRestrictions = $mwServices->getBlockRestrictionStoreFactory()->getBlockRestrictionStore( $wikiID );
$databaseManager = CentralAuthServices::getDatabaseManager();
$db = $databaseManager->getLocalDB( DB_REPLICA, $wikiID );
$fields = [
'user_id',
'user_email',
'user_name',
'user_email_authenticated',
'user_password',
'user_editcount',
'user_registration',
];
$conds = [ 'user_name' => $this->mName ];
$row = $db->selectRow( 'user', $fields, $conds, __METHOD__ );
if ( !$row ) {
# Row missing from replica, try the primary database instead
$db = $databaseManager->getLocalDB( DB_PRIMARY, $wikiID );
$row = $db->selectRow( 'user', $fields, $conds, __METHOD__ );
}
if ( !$row ) {
$ex = new LocalUserNotFoundException(
"Could not find local user data for {$this->mName}@{$wikiID}"
);
$this->logger->warning(
'Could not find local user data for {username}@{wikiId}',
[
'username' => $this->mName,
'wikiId' => $wikiID,
'exception' => $ex,
]
);
throw $ex;
}
$data = [
'wiki' => $wikiID,
'id' => $row->user_id,
'name' => $row->user_name,
'email' => $row->user_email,
'emailAuthenticated' => wfTimestampOrNull( TS_MW, $row->user_email_authenticated ),
'registration' => wfTimestampOrNull( TS_MW, $row->user_registration ),
'password' => $row->user_password,
'editCount' => $row->user_editcount,
'groupMemberships' => [], // array of (group name => UserGroupMembership object)
'blocked' => false,
];
// Edit count field may not be initialized...
if ( $row->user_editcount === null ) {
$actorWhere = $mwServices->getActorMigration()->getWhere(
$db,
'rev_user',
User::newFromId( $data['id'] )
);
$data['editCount'] = 0;
foreach ( $actorWhere['orconds'] as $cond ) {
$data['editCount'] += $db->selectField(
[ 'revision' ] + $actorWhere['tables'],
'COUNT(*)',
$cond,
__METHOD__,
[],
$actorWhere['joins']
);
}
}
// And we have to fetch groups separately, sigh...
$data['groupMemberships'] = $mwServices
->getUserGroupManagerFactory()
->getUserGroupManager( $wikiID )
->getUserGroupMemberships(
new UserIdentityValue(
(int)$data['id'],
$data['name'],
$wikiID === WikiMap::getCurrentWikiId() ? UserIdentity::LOCAL : $wikiID
)
);
// And while we're in here, look for user blocks :D
$commentStore = $mwServices->getCommentStore();
$commentQuery = $commentStore->getJoin( 'ipb_reason' );
$result = $db->select(
[ 'ipblocks' ] + $commentQuery['tables'],
[
'ipb_id',
'ipb_expiry',
'ipb_block_email',
'ipb_anon_only',
'ipb_create_account',
'ipb_enable_autoblock',
'ipb_allow_usertalk',
'ipb_sitewide',
] + $commentQuery['fields'],
[ 'ipb_user' => $data['id'] ],
__METHOD__,
[],
$commentQuery['joins']
);
global $wgLang;
foreach ( $result as $row ) {
// Check expiration
if ( $wgLang->formatExpiry( $row->ipb_expiry, TS_MW ) <= wfTimestampNow() ) {
continue;
}
$data['block-expiry'] = $row->ipb_expiry;
$data['block-reason'] = $commentStore->getComment( 'ipb_reason', $row )->text;
$data['block-anononly'] = (bool)$row->ipb_anon_only;
$data['block-nocreate'] = (bool)$row->ipb_create_account;
$data['block-noautoblock'] = !( (bool)$row->ipb_enable_autoblock );
// Poorly named database column
$data['block-nousertalk'] = !( (bool)$row->ipb_allow_usertalk );
$data['block-noemail'] = (bool)$row->ipb_block_email;
$data['block-sitewide'] = (bool)$row->ipb_sitewide;
$data['block-restrictions'] = (bool)$row->ipb_sitewide ? [] :
$blockRestrictions->loadByBlockId( $row->ipb_id );
$data['blocked'] = true;
}
return $data;
}
/**
* @return string
*/
public function getEmail(): string {
$this->loadState();
return $this->mEmail ?? '';
}
/**
* @return string
*/
public function getEmailAuthenticationTimestamp() {
$this->loadState();
return $this->mAuthenticationTimestamp;
}
/**
* @param string $email
* @return void
*/
public function setEmail( $email ) {
$this->checkWriteMode();
$this->loadState();
if ( $this->mEmail !== $email ) {
$this->mEmail = $email;
$this->mStateDirty = true;
}
}
/**
* @param string|null $ts
* @return void
*/
public function setEmailAuthenticationTimestamp( $ts ) {
$this->checkWriteMode();
$this->loadState();
if ( $this->mAuthenticationTimestamp !== $ts ) {
$this->mAuthenticationTimestamp = $ts;
$this->mStateDirty = true;
}
}
/**
* Salt and hash a new plaintext password.
* @param string|null $password plaintext
* @return string[] Two-element array with salt and hash
*/
protected function saltedPassword( $password ) {
$passwordFactory = new PasswordFactory();
$passwordFactory->init( RequestContext::getMain()->getConfig() );
return [
'',
$passwordFactory->newFromPlaintext( $password )->toString()
];
}
/**
* Set the account's password
* @param string|null $password plaintext
* @param bool $resetAuthToken if we should reset the login token
* @return bool true
*/
public function setPassword( $password, $resetAuthToken = true ) {
$this->checkWriteMode();
// Make sure state is loaded before updating ->mPassword
$this->loadState();
list( $salt, $hash ) = $this->saltedPassword( $password );
$this->mPassword = $hash;
$this->mSalt = $salt;
if ( $this->getId() ) {
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
$dbw->update(
'globaluser',
[
'gu_salt' => $salt,
'gu_password' => $hash,
],
[ 'gu_id' => $this->getId(), ],
__METHOD__
);
$this->logger->info( "Set global password for {user}", [ 'user' => $this->mName ] );
} else {
$this->logger->warning( "Tried changing password for user that doesn't exist {user}",
[ 'user' => $this->mName ] );
}
if ( $resetAuthToken ) {
$this->resetAuthToken();
}
$this->invalidateCache();
return true;
}
/**
* Get the password hash.
* Automatically converts to a new-style hash
* @return string
*/
public function getPassword() {
$this->loadState();
if ( substr( $this->mPassword, 0, 1 ) != ':' ) {
$this->mPassword = ':B:' . $this->mSalt . ':' . $this->mPassword;
}
return $this->mPassword;
}
/**
* @return CentralAuthSessionProvider
*/
private static function getSessionProvider(): CentralAuthSessionProvider {
// @phan-suppress-next-line PhanTypeMismatchReturnSuperType
return SessionManager::singleton()->getProvider( CentralAuthSessionProvider::class );
}
/**
* Get the domain parameter for setting a global cookie.
* This allows other extensions to easily set global cookies without directly relying on
* $wgCentralAuthCookieDomain (in case CentralAuth's implementation changes at some point).
*
* @return string
*/
public static function getCookieDomain() {
$provider = self::getSessionProvider();
return $provider->getCentralCookieDomain();
}
/**
* Check a global auth token against the one we know of in the database.
*
* @param string $token
* @return bool
*/
public function validateAuthToken( $token ) {
return hash_equals( $this->getAuthToken(), $token );
}
/**
* Generate a new random auth token, and store it in the database.
* Should be called as often as possible, to the extent that it will
* not randomly log users out (so on logout, as is done currently, is a good time).
*/
public function resetAuthToken() {
$this->checkWriteMode();
// Load state, since its hard to reset the token without it
$this->loadState();
// Generate a random token.
$this->mAuthToken = MWCryptRand::generateHex( 32 );
$this->mStateDirty = true;
// Save it.
$this->saveSettings();
}
public function saveSettings() {
$this->checkWriteMode();
if ( !$this->mStateDirty ) {
return;
}
$this->mStateDirty = false;
$databaseManager = CentralAuthServices::getDatabaseManager();
if ( $databaseManager->isReadOnly() ) {
return;
}
$this->loadState();
if ( !$this->mGlobalId ) {
return;
}
$newCasToken = $this->mCasToken + 1;
$dbw = $databaseManager->getCentralPrimaryDB();
$toSet = [
'gu_password' => $this->mPassword,
'gu_salt' => $this->mSalt,
'gu_auth_token' => $this->mAuthToken,
'gu_locked' => $this->mLocked,
'gu_hidden_level' => $this->getHiddenLevelInt(),
'gu_email' => $this->mEmail,
'gu_email_authenticated' =>
$dbw->timestampOrNull( $this->mAuthenticationTimestamp ),
'gu_home_db' => $this->getHomeWiki(),
'gu_cas_token' => $newCasToken
];
$dbw->update(
'globaluser',
$toSet,
[ # WHERE
'gu_id' => $this->mGlobalId,
'gu_cas_token' => $this->mCasToken
],
__METHOD__
);
if ( !$dbw->affectedRows() ) {
// Maybe the problem was a missed cache update; clear it to be safe
$this->invalidateCache();
// User was changed in the meantime or loaded with stale data
$from = ( $this->mFromPrimary ) ? 'primary' : 'replica';
$this->logger->warning(
"CAS update failed on gu_cas_token for user ID '{globalId}' " .
"(read from {from}); the version of the user to be saved is older than " .
"the current version.",
[
'globalId' => $this->mGlobalId,
'from' => $from,
'exception' => new Exception( 'CentralAuth gu_cas_token conflict' ),
]
);
return;
}
$this->mCasToken = $newCasToken;
$this->invalidateCache();
}
/**
* @return string[]
*/
public function getGlobalGroups() {
$this->loadGroups();
return $this->mGroups;
}
/**
* @return array<string, string|null> of [group name => expiration timestamp or null if permanent]
*/
public function getGlobalGroupsWithExpiration() {
$this->loadGroups();
return $this->mGroupExpirations;
}
/**
* @return string[]
*/
public function getGlobalRights() {
$this->loadGroups();
$rights = [];
$sets = [];
foreach ( $this->mRights as $right ) {
if ( $right['set'] ) {
$setId = $right['set'];
if ( !isset( $sets[$setId] ) ) {
$sets[$setId] = WikiSet::newFromID( $setId );
}
$set = $sets[$setId];
if ( $set->inSet() ) {
$rights[] = $right['right'];
}
} else {
$rights[] = $right['right'];
}
}
return $rights;
}
/**
* @param string $groups
* @return void
*/
public function removeFromGlobalGroups( $groups ) {
$this->checkWriteMode();
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
# Delete from the DB
$dbw->delete(
'global_user_groups',
[ 'gug_user' => $this->getId(), 'gug_group' => $groups ],
__METHOD__
);
$this->invalidateCache();
}
/**
* @param string $group
* @param string|null $expiry Timestamp of membership expiry in TS_MW format, or null if no expiry
* @return void
*/
public function addToGlobalGroup( string $group, ?string $expiry = null ) {
$this->checkWriteMode();
$dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
# Replace into the DB
$dbw->replace(
'global_user_groups',
[ [ 'gug_user', 'gug_group' ] ],
[
[
'gug_user' => $this->getId(),
'gug_group' => $group,
'gug_expiry' => $dbw->timestampOrNull( $expiry )
]
],
__METHOD__
);
$this->invalidateCache();
}
/**
* @param string $perm
* @return bool
*/
public function hasGlobalPermission( $perm ) {
return in_array( $perm, $this->getGlobalRights() );
}
public function invalidateCache() {
if ( !$this->mDelayInvalidation ) {
$this->logger->debug( "Updating cache for global user {$this->mName}" );
// Purge the cache
$this->quickInvalidateCache();
// Reload the state
$this->loadStateNoCache();
} else {
$this->logger->debug( "Deferring cache invalidation because we're in a transaction" );
}
}
/**
* For when speed is of the essence (e.g. when batch-purging users after rights changes)
*/
public function quickInvalidateCache() {
$this->logger->debug(
"Quick cache invalidation for global user {$this->mName}"
);
CentralAuthServices::getDatabaseManager()
->getCentralPrimaryDB()
->onTransactionPreCommitOrIdle( function () {
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
$cache->delete( $this->getCacheKey( $cache ) );
}, __METHOD__ );
}
/**
* End a "transaction".
* A transaction delays cache invalidation until after
* some operation which would otherwise repeatedly do so.
* Intended to be used for things like migration.
*/
public function endTransaction() {
$this->logger->debug( 'End CentralAuthUser cache-invalidating transaction' );
$this->mDelayInvalidation = false;
$this->invalidateCache();
}
/**
* Start a "transaction".
* A transaction delays cache invalidation until after
* some operation which would otherwise repeatedly do so.
* Intended to be used for things like migration.
*/
public function startTransaction() {
$this->logger->debug( 'Start CentralAuthUser cache-invalidating transaction' );
// Delay cache invalidation
$this->mDelayInvalidation = 1;
}
/**
* Check if the user is attached on a given wiki id.
*
* @param string $wiki
* @return bool
*/
public function attachedOn( $wiki ) {
$this->loadAttached();
return $this->exists() && in_array( $wiki, $this->mAttachedArray );
}
/**
* Get a hash representing the user/locked/hidden state of this user,
* used to check for edit conflicts
*
* @param bool $recache Force a reload of the user from the database
* @return string
*/
public function getStateHash( bool $recache = false ) {
$this->loadState( $recache );
return md5( $this->mGlobalId . ':' . $this->mName . ':' . $this->mHiddenLevel . ':' .
( $this->mLocked ? '1' : '0' ) );
}
/**
* Log an action for the current user
*
* @param string $action
* @param UserIdentity $user
* @param string $reason
* @param array $params
* @param bool $suppressLog
* @param bool $markAsBot If true, log entry is marked as made by a bot. If false, default
* behavior is observed.
*/
public function logAction(
$action,
UserIdentity $user,
$reason = '',
$params = [],
bool $suppressLog = false,
bool $markAsBot = false
) {
$nsUser = MediaWikiServices::getInstance()
->getNamespaceInfo()
->getCanonicalName( NS_USER );
// Not centralauth because of some weird length limitations
$logType = $suppressLog ? 'suppress' : 'globalauth';
$entry = new ManualLogEntry( $logType, $action );
$entry->setTarget( Title::newFromText( "$nsUser:{$this->mName}@global" ) );
$entry->setPerformer( $user );
$entry->setComment( $reason );
$entry->setParameters( $params );
if ( $markAsBot ) {
// NOTE: This is intentionally called conditionally, to respect default behavior when
// $markAsBot is set to false.
$entry->setForceBotFlag( $markAsBot );
}
$logid = $entry->insert();
$entry->publish( $logid );
}
/**
* @param string $wikiId
* @param int $userId
*/
private function clearLocalUserCache( $wikiId, $userId ) {
User::purge( $wikiId, $userId );
}
}