Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F5961472
CentralAuthUser.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
92 KB
Referenced Files
None
Subscribers
None
CentralAuthUser.php
View Options
<?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
BadMethodCallException
;
use
CentralAuthSessionProvider
;
use
Exception
;
use
InvalidArgumentException
;
use
LogicException
;
use
ManualLogEntry
;
use
MapCacheLRU
;
use
MediaWiki\Block\DatabaseBlock
;
use
MediaWiki\Context\IContextSource
;
use
MediaWiki\Context\RequestContext
;
use
MediaWiki\DAO\WikiAwareEntity
;
use
MediaWiki\Deferred\DeferredUpdates
;
use
MediaWiki\Extension\CentralAuth\CentralAuthReadOnlyError
;
use
MediaWiki\Extension\CentralAuth\CentralAuthServices
;
use
MediaWiki\Extension\CentralAuth\LocalUserNotFoundException
;
use
MediaWiki\Extension\CentralAuth\RCFeed\CARCFeedFormatter
;
use
MediaWiki\Extension\CentralAuth\WikiSet
;
use
MediaWiki\Logger\LoggerFactory
;
use
MediaWiki\MainConfigNames
;
use
MediaWiki\MediaWikiServices
;
use
MediaWiki\Password\AbstractPbkdf2Password
;
use
MediaWiki\Password\Password
;
use
MediaWiki\Password\PasswordError
;
use
MediaWiki\Password\PasswordFactory
;
use
MediaWiki\RCFeed\FormattedRCFeed
;
use
MediaWiki\RCFeed\RCFeed
;
use
MediaWiki\Session\SessionManager
;
use
MediaWiki\Status\Status
;
use
MediaWiki\Title\Title
;
use
MediaWiki\User\ExternalUserNames
;
use
MediaWiki\User\User
;
use
MediaWiki\User\UserIdentity
;
use
MediaWiki\User\UserIdentityValue
;
use
MediaWiki\WikiMap\WikiMap
;
use
MWCryptHash
;
use
MWCryptRand
;
use
RevisionDeleteUser
;
use
RuntimeException
;
use
stdClass
;
use
Wikimedia\AtEase\AtEase
;
use
Wikimedia\IPUtils
;
use
Wikimedia\ObjectCache\WANObjectCache
;
use
Wikimedia\Rdbms\Database
;
use
Wikimedia\Rdbms\DBAccessObjectUtils
;
use
Wikimedia\Rdbms\IDBAccessObject
;
use
Wikimedia\Rdbms\IReadableDatabase
;
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 array|null
* @phan-var ?list<array{group:string,expiry:?string}>
*/
private
$mGroups
;
/**
* @var array|null
* @phan-var ?list<array{right:string,set:?int}>
*/
private
$mRights
;
/** @var string */
private
$mPassword
;
/** @var string */
private
$mAuthToken
;
/** @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'
,
'mPassword'
,
'mAuthToken'
,
'mLocked'
,
'mHiddenLevel'
,
'mRegistration'
,
'mEmail'
,
'mAuthenticationTimestamp'
,
'mGroups'
,
'mRights'
,
'mHomeWiki'
,
'mBeingRenamed'
,
# Store the string list instead of the array, to save memory, and
# avoid unserialize() overhead
'mAttachedList'
,
'mCasToken'
];
private
const
VERSION
=
13
;
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 IDBAccessObject::READ_LATEST to use the primary DB
*/
public
function
__construct
(
$username
,
$flags
=
0
)
{
$this
->
mName
=
$username
;
$this
->
resetState
();
if
(
DBAccessObjectUtils
::
hasFlags
(
$flags
,
IDBAccessObject
::
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
::
getUserCache
()->
set
(
$user
->
getName
(),
$caUser
);
}
/**
* Create a (cached) CentralAuthUser object corresponding to the supplied User.
* @param UserIdentity $user
* @return CentralAuthUser
*/
public
static
function
getInstance
(
UserIdentity
$user
):
self
{
return
self
::
getInstanceByName
(
$user
->
getName
()
);
}
/**
* Create a (cached) CentralAuthUser object corresponding to the supplied user.
*
* @param string $username A valid username. (Since 1.42 it does not have to be in the
* canonical form anymore). IP addresses/ranges and external usernames are also accepted
* for B/C but discouraged; they will be handled like a non-registered username.
* @return CentralAuthUser
* @throws InvalidArgumentException on invalid usernames.
*/
public
static
function
getInstanceByName
(
$username
):
self
{
if
(
IPUtils
::
isValid
(
$username
)
)
{
$canonUsername
=
IPUtils
::
sanitizeIP
(
$username
);
}
elseif
(
IPUtils
::
isValidRange
(
$username
)
)
{
$canonUsername
=
IPUtils
::
sanitizeRange
(
$username
);
}
elseif
(
ExternalUserNames
::
isExternal
(
$username
)
)
{
$canonUsername
=
$username
;
}
else
{
$canonUsername
=
MediaWikiServices
::
getInstance
()->
getUserNameUtils
()
->
getCanonical
(
$username
);
}
if
(
$canonUsername
===
false
||
$canonUsername
===
null
)
{
throw
new
InvalidArgumentException
(
"Invalid username: $username"
);
}
$cache
=
self
::
getUserCache
();
$ret
=
$cache
->
get
(
$canonUsername
);
if
(
!
$ret
)
{
$ret
=
new
self
(
$canonUsername
);
$cache
->
set
(
$canonUsername
,
$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
):
self
{
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
):
self
{
$cache
=
self
::
getUserCache
();
$ret
=
$cache
->
get
(
$username
);
if
(
!
$ret
||
!
$ret
->
mFromPrimary
)
{
$ret
=
new
self
(
$username
,
IDBAccessObject
::
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 IReadableDatabase Primary database or replica based on shouldUsePrimaryDB()
* @throws CentralAuthReadOnlyError
*/
protected
function
getSafeReadDB
()
{
if
(
$this
->
shouldUsePrimaryDB
()
)
{
return
CentralAuthServices
::
getDatabaseManager
()->
getCentralPrimaryDB
();
}
else
{
return
CentralAuthServices
::
getDatabaseManager
()->
getCentralReplicaDB
();
}
}
/**
* 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,
* 'conds' => array,
* 'options' => array,
* 'join_conds' => array,
* )
*/
public
static
function
selectQueryInfo
()
{
return
[
'tables'
=>
[
'globaluser'
,
'localuser'
],
'fields'
=>
[
'gu_id'
,
'gu_name'
,
'lu_wiki'
,
'gu_password'
,
'gu_auth_token'
,
'gu_locked'
,
'gu_hidden_level'
,
'gu_registration'
,
'gu_email'
,
'gu_email_authenticated'
,
'gu_home_db'
,
'gu_cas_token'
],
'conds'
=>
[],
'options'
=>
[],
'join_conds'
=>
[
'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
()
->
newSelectQueryBuilder
()
->
select
(
'gu_name'
)
->
from
(
'globaluser'
)
->
where
(
[
'gu_id'
=>
$id
]
)
->
caller
(
__METHOD__
)
->
fetchField
();
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
()
->
newSelectQueryBuilder
()
->
select
(
'gu_name'
)
->
from
(
'globaluser'
)
->
where
(
[
'gu_id'
=>
$id
]
)
->
caller
(
__METHOD__
)
->
fetchField
();
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
):
self
{
$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
):
self
{
$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
->
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
();
// Grab the user's rights/groups.
$userAndExpiryConds
=
[
'gug_user'
=>
$this
->
getId
(),
$db
->
expr
(
'gug_expiry'
,
'='
,
null
)->
or
(
'gug_expiry'
,
'>='
,
$db
->
timestamp
()
),
];
$resGroups
=
$db
->
newSelectQueryBuilder
()
->
select
(
[
'gug_group'
,
'gug_expiry'
,
'ggr_set'
]
)
->
from
(
'global_user_groups'
)
->
leftJoin
(
'global_group_restrictions'
,
null
,
'ggr_group=gug_group'
)
->
where
(
$userAndExpiryConds
)
->
caller
(
__METHOD__
)
->
fetchResultSet
();
$this
->
mGroups
=
[];
foreach
(
$resGroups
as
$row
)
{
$this
->
mGroups
[]
=
[
'group'
=>
$row
->
gug_group
,
'expiry'
=>
$row
->
gug_expiry
,
'set'
=>
$row
->
ggr_set
];
}
$resRights
=
$db
->
newSelectQueryBuilder
()
->
select
(
[
'ggp_permission'
,
'ggr_set'
]
)
->
from
(
'global_group_permissions'
)
->
join
(
'global_user_groups'
,
null
,
'ggp_group=gug_group'
)
->
leftJoin
(
'global_group_restrictions'
,
null
,
'ggr_group=gug_group'
)
->
where
(
$userAndExpiryConds
)
->
caller
(
__METHOD__
)
->
fetchResultSet
();
$this
->
mRights
=
[];
foreach
(
$resRights
as
$row
)
{
// Only store the set id here, and don't compute effective rights, because
// this is stored in a shared cache for all wikis (see loadFromCache()),
// which also isn't invalidated if the set is changed.
$this
->
mRights
[]
=
[
'right'
=>
$row
->
ggp_permission
,
'set'
=>
$row
->
ggr_set
];
}
}
/**
* @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
{
$this
->
loadGroups
();
$closestExpiry
=
null
;
foreach
(
$this
->
mGroups
as
[
'expiry'
=>
$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
();
// matches $fromPrimary above
$db
=
$this
->
getSafeReadDB
();
$row
=
$db
->
newSelectQueryBuilder
()
->
queryInfo
(
self
::
selectQueryInfo
()
)
->
where
(
[
'gu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
fetchRow
();
$renameUser
=
CentralAuthServices
::
getGlobalRenameFactory
()
->
newGlobalRenameUserStatus
(
$this
->
mName
)
->
getNames
(
null
,
$fromPrimary
?
IDBAccessObject
::
READ_LATEST
:
IDBAccessObject
::
READ_NORMAL
);
$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
->
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
->
newSelectQueryBuilder
()
->
select
(
'user_id'
)
->
from
(
'user'
)
->
where
(
[
'user_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
fetchField
();
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
,
''
);
}
/**
* 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 an array of blocks per wiki the user is attached to.
* @throws LocalUserNotFoundException
* @return array
*/
public
function
getBlocks
():
array
{
$blocksByWikiId
=
[];
$mwServices
=
MediaWikiServices
::
getInstance
();
$dbm
=
CentralAuthServices
::
getDatabaseManager
();
$wikis
=
$this
->
queryAttachedBasic
();
foreach
(
$wikis
as
$wikiId
=>
$_
)
{
$db
=
$dbm
->
getLocalDB
(
DB_REPLICA
,
$wikiId
);
$fields
=
[
'user_id'
];
$conds
=
[
'user_name'
=>
$this
->
mName
];
$row
=
$db
->
newSelectQueryBuilder
()
->
select
(
$fields
)
->
from
(
'user'
)
->
where
(
$conds
)
->
caller
(
__METHOD__
)
->
fetchRow
();
if
(
!
$row
)
{
# Row missing from replica, try the primary database instead
$db
=
$dbm
->
getLocalDB
(
DB_PRIMARY
,
$wikiId
);
$row
=
$db
->
newSelectQueryBuilder
()
->
select
(
$fields
)
->
from
(
'user'
)
->
where
(
$conds
)
->
caller
(
__METHOD__
)
->
fetchRow
();
}
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
;
}
// if the current wiki is the local wiki, then the BlockStore expect "WikiAwareEntity::LOCAL" as value;
if
(
$wikiId
===
WikiMap
::
getCurrentWikiId
()
)
{
$wikiId
=
WikiAwareEntity
::
LOCAL
;
}
$blockStore
=
$mwServices
->
getDatabaseBlockStoreFactory
()
->
getDatabaseBlockStore
(
$wikiId
);
$blocksByWikiId
[
$wikiId
]
=
$blockStore
->
newListFromConds
(
[
'bt_user'
=>
$row
->
user_id
]
);
}
return
$blocksByWikiId
;
}
/**
* Returns true if the account has any blocks on any wikis.
* @throws LocalUserNotFoundException
* @return bool
*/
public
function
isBlocked
():
bool
{
$blocksByWikiId
=
$this
->
getBlocks
();
foreach
(
$blocksByWikiId
as
$wikiId
=>
$blocks
)
{
if
(
count
(
$blocks
)
>
0
)
{
return
true
;
}
}
return
false
;
}
/**
* Returns true if the account has any public logs.
*
* By default this method ignores 'newusers' logs as almost all users will
* have a log record of that type.
*
* @param array $excludeTypes is an array of log types to ignore
* @return bool
*/
public
function
hasPublicLogs
(
$excludeTypes
=
[
'newusers'
]
):
bool
{
$services
=
MediaWikiServices
::
getInstance
();
$dbm
=
CentralAuthServices
::
getDatabaseManager
();
$wikis
=
$this
->
queryAttachedBasic
();
foreach
(
$wikis
as
$wikiId
=>
$_
)
{
$dbr
=
$dbm
->
getLocalDB
(
DB_REPLICA
,
$wikiId
);
$actorStore
=
$services
->
getActorStoreFactory
()->
getActorStore
(
$wikiId
);
$user
=
$actorStore
->
getUserIdentityByName
(
$this
->
getName
()
);
if
(
!
$user
)
{
continue
;
}
$actorId
=
$actorStore
->
acquireActorId
(
$user
,
$dbr
);
$conds
=
[
'log_actor'
=>
$actorId
,
$dbr
->
expr
(
'log_type'
,
'!='
,
$excludeTypes
),
];
$row
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
[
'log_id'
]
)
->
from
(
'logging'
)
->
where
(
$conds
)
->
caller
(
__METHOD__
)
->
fetchRow
();
if
(
$row
)
{
return
true
;
}
}
return
false
;
}
/**
* 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
BadMethodCallException
(
'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
();
// Make sure we always have some value
$this
->
mHomeWiki
=
key
(
$attached
);
$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
();
$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_password'
=>
$hash
,
'gu_auth_token'
=>
$this
->
mAuthToken
,
'gu_locked'
=>
0
,
'gu_hidden_level'
=>
self
::
HIDDEN_LEVEL_NONE
,
'gu_registration'
=>
$dbw
->
timestamp
(),
];
$dbw
->
newInsertQueryBuilder
()
->
insertInto
(
'globaluser'
)
->
ignore
()
->
row
(
$data
)
->
caller
(
__METHOD__
)
->
execute
();
$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
->
newInsertQueryBuilder
()
->
insertInto
(
'global_edit_count'
)
->
row
(
[
'gec_user'
=>
$dbw
->
insertId
(),
'gec_count'
=>
0
]
)
->
caller
(
__METHOD__
)
->
execute
();
}
// 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
->
newInsertQueryBuilder
()
->
insertInto
(
'globalnames'
)
->
ignore
()
->
rows
(
$globalTuples
)
->
caller
(
__METHOD__
)
->
execute
();
$dbw
->
newInsertQueryBuilder
()
->
insertInto
(
'localnames'
)
->
ignore
()
->
rows
(
$tuples
)
->
caller
(
__METHOD__
)
->
execute
();
}
/**
* 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_password'
=>
$hash
,
// So it doesn't have to be done later
'gu_auth_token'
=>
MWCryptRand
::
generateHex
(
32
),
'gu_email'
=>
$email
,
'gu_email_authenticated'
=>
$dbw
->
timestampOrNull
(
$emailAuth
),
'gu_registration'
=>
$dbw
->
timestamp
(),
'gu_locked'
=>
0
,
'gu_hidden_level'
=>
self
::
HIDDEN_LEVEL_NONE
,
];
$dbw
->
newInsertQueryBuilder
()
->
insertInto
(
'globaluser'
)
->
ignore
()
->
row
(
$data
)
->
caller
(
__METHOD__
)
->
execute
();
$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
(
!
$migrationSet
)
{
throw
new
LogicException
(
'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
)
{
// Because it messes with $this->mEmail and so on
$this
->
checkWriteMode
();
$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
(
!
$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
(
!
$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
RuntimeException
(
"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
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'localuser'
)
->
where
(
[
'lu_name'
=>
$this
->
mName
,
'lu_wiki'
=>
$wikiName
]
)
->
caller
(
__METHOD__
)
->
execute
();
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
->
newUpdateQueryBuilder
()
->
update
(
'user'
)
->
set
(
[
'user_touched'
=>
$dblw
->
timestamp
(),
'user_password'
=>
$password
]
)
->
where
(
[
'user_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
$userRow
=
$dblw
->
newSelectQueryBuilder
()
->
select
(
[
'user_id'
,
'user_editcount'
]
)
->
from
(
'user'
)
->
where
(
[
'user_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
fetchRow
();
# 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
->
newSelectQueryBuilder
()
->
select
(
'lu_wiki'
)
->
from
(
'localuser'
)
->
where
(
[
'lu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
foreach
(
$localUserRes
as
$wiki
)
{
$this
->
logger
->
debug
(
__METHOD__
.
": Fixing password on $wiki
\n
"
);
$localDB
=
$databaseManager
->
getLocalDB
(
DB_PRIMARY
,
$wiki
);
$localDB
->
newUpdateQueryBuilder
()
->
update
(
'user'
)
->
set
(
[
'user_password'
=>
$password
]
)
->
where
(
[
'user_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
$id
=
$localDB
->
newSelectQueryBuilder
()
->
select
(
'user_id'
)
->
from
(
'user'
)
->
where
(
[
'user_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
fetchField
();
$this
->
clearLocalUserCache
(
$wiki
,
$id
);
}
$wasSuppressed
=
$this
->
isSuppressed
();
$centralDB
->
startAtomic
(
__METHOD__
);
# Delete and lock the globaluser row
$centralDB
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'globaluser'
)
->
where
(
[
'gu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
if
(
!
$centralDB
->
affectedRows
()
)
{
$centralDB
->
endAtomic
(
__METHOD__
);
return
Status
::
newFatal
(
'centralauth-admin-delete-nonexistent'
,
$this
->
mName
);
}
# Delete all global user groups for the user
$centralDB
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'global_user_groups'
)
->
where
(
[
'gug_user'
=>
$this
->
getId
()
]
)
->
caller
(
__METHOD__
)
->
execute
();
# Delete the localuser rows
$centralDB
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'localuser'
)
->
where
(
[
'lu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
$centralDB
->
endAtomic
(
__METHOD__
);
if
(
$wasSuppressed
)
{
// "suppress/delete" is taken by core, so use "cadelete"
$this
->
logAction
(
'cadelete'
,
$deleter
,
$reason
,
[],
true
);
}
else
{
$this
->
logAction
(
'delete'
,
$deleter
,
$reason
,
[],
);
}
$this
->
invalidateCache
();
return
Status
::
newGood
();
}
/**
* Lock a global account
*
* @return Status
*/
public
function
adminLock
()
{
$this
->
checkWriteMode
();
$dbw
=
CentralAuthServices
::
getDatabaseManager
()->
getCentralPrimaryDB
();
$dbw
->
newUpdateQueryBuilder
()
->
update
(
'globaluser'
)
->
set
(
[
'gu_locked'
=>
1
]
)
->
where
(
[
'gu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
if
(
!
$dbw
->
affectedRows
()
)
{
return
Status
::
newFatal
(
'centralauth-state-mismatch'
);
}
$this
->
invalidateCache
();
$user
=
User
::
newFromName
(
$this
->
mName
);
SessionManager
::
singleton
()->
invalidateSessionsForUser
(
$user
);
// T375870: Track rate of locks for monitoring of anti-abuse tool usage.
MediaWikiServices
::
getInstance
()->
getStatsFactory
()->
withComponent
(
'CentralAuth'
)
->
getCounter
(
'account_lock'
)
->
increment
();
return
Status
::
newGood
();
}
/**
* Unlock a global account
*
* @return Status
*/
public
function
adminUnlock
()
{
$this
->
checkWriteMode
();
$dbw
=
CentralAuthServices
::
getDatabaseManager
()->
getCentralPrimaryDB
();
$dbw
->
newUpdateQueryBuilder
()
->
update
(
'globaluser'
)
->
set
(
[
'gu_locked'
=>
0
]
)
->
where
(
[
'gu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
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
->
newUpdateQueryBuilder
()
->
update
(
'globaluser'
)
->
set
(
[
'gu_hidden_level'
=>
$level
]
)
->
where
(
[
'gu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
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
);
$wikiId
=
$wiki
===
WikiMap
::
getCurrentWikiId
()
?
WikiAwareEntity
::
LOCAL
:
$wiki
;
$blockStore
=
MediaWikiServices
::
getInstance
()
->
getDatabaseBlockStoreFactory
()
->
getDatabaseBlockStore
(
$wikiId
);
if
(
$suppress
)
{
[
,
$lang
]
=
$wgConf
->
siteFromDB
(
$wiki
);
if
(
!
MediaWikiServices
::
getInstance
()->
getLanguageNameUtils
()->
isSupportedLanguage
(
$lang
??
''
)
)
{
$lang
=
'en'
;
}
$blockReason
=
wfMessage
(
'centralauth-admin-suppressreason'
,
$by
,
$reason
)
->
inLanguage
(
$lang
)->
text
();
// 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
,
'enableAutoblock'
=>
true
,
'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...
if
(
!
$blockStore
->
insertBlock
(
$block
)
)
{
return
[
'ipb_already_blocked'
];
}
# Ditto for BlockIpComplete hook.
RevisionDeleteUser
::
suppressUserName
(
$this
->
mName
,
$data
[
'id'
],
$dbw
);
# Locally log to suppress ?
}
else
{
$affected
=
$blockStore
->
deleteBlocksMatchingConds
(
[
'bt_user'
=>
$data
[
'id'
],
// Our blocks don't have a user associated
'bl_by'
=>
null
,
'bl_deleted'
=>
true
,
]
);
// Unsuppress only if unblocked
if
(
$affected
)
{
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
->
newInsertQueryBuilder
()
->
insertInto
(
'localuser'
)
->
ignore
()
->
row
(
[
'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
()
]
)
->
caller
(
__METHOD__
)
->
execute
();
$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
->
newSelectQueryBuilder
()
->
select
(
'user_editcount'
)
->
from
(
'user'
)
->
where
(
[
'user_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
fetchField
();
$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
]
);
$config
=
RequestContext
::
getMain
()->
getConfig
();
$passwordFactory
=
new
PasswordFactory
(
$config
->
get
(
MainConfigNames
::
PasswordConfig
),
$config
->
get
(
MainConfigNames
::
PasswordDefault
)
);
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
)
{
$config
=
RequestContext
::
getMain
()->
getConfig
();
$passwordFactory
=
new
PasswordFactory
(
$config
->
get
(
MainConfigNames
::
PasswordConfig
),
$config
->
get
(
MainConfigNames
::
PasswordDefault
)
);
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
)
)
{
// don't bother with primary database queries
return
[];
}
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
->
newSelectQueryBuilder
()
->
select
(
'ln_wiki'
)
->
from
(
'localnames'
)
->
leftJoin
(
'localuser'
,
null
,
[
'ln_wiki=lu_wiki'
,
'ln_name=lu_name'
]
)
->
where
(
[
'ln_name'
=>
$this
->
mName
,
'lu_name'
=>
null
]
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
$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
->
newInsertQueryBuilder
()
->
insertInto
(
'localnames'
)
->
ignore
()
->
row
(
[
'ln_wiki'
=>
$wikiID
,
'ln_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
}
/**
* @param string $wikiID
*/
public
function
removeLocalName
(
$wikiID
)
{
$dbw
=
CentralAuthServices
::
getDatabaseManager
()->
getCentralPrimaryDB
();
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'localnames'
)
->
where
(
[
'ln_wiki'
=>
$wikiID
,
'ln_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
}
/**
* Updates the localname table after a rename
* @param string $wikiID
* @param string $newname
*/
public
function
updateLocalName
(
$wikiID
,
$newname
)
{
$dbw
=
CentralAuthServices
::
getDatabaseManager
()->
getCentralPrimaryDB
();
$dbw
->
newUpdateQueryBuilder
()
->
update
(
'localnames'
)
->
set
(
[
'ln_name'
=>
$newname
]
)
->
where
(
[
'ln_wiki'
=>
$wikiID
,
'ln_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
execute
();
}
/**
* 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
->
newSelectQueryBuilder
()
->
select
(
'lu_wiki'
)
->
from
(
'localuser'
)
->
where
(
[
'lu_name'
=>
$this
->
mName
]
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
$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 $recency Bitfield of IDBAccessObject::READ_* constants
* @return string[]|false
*/
public
function
renameInProgressOn
(
string
$wiki
,
int
$recency
=
IDBAccessObject
::
READ_NORMAL
)
{
return
CentralAuthServices
::
getGlobalRenameFactory
()
->
newGlobalRenameUserStatus
(
$this
->
mName
)
->
getNames
(
$wiki
,
$recency
)
?:
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[]
*/
public
function
queryAttachedBasic
()
{
if
(
$this
->
mAttachedInfo
!==
null
)
{
return
$this
->
mAttachedInfo
;
}
$db
=
$this
->
getSafeReadDB
();
$result
=
$db
->
newSelectQueryBuilder
()
->
select
(
[
'lu_wiki'
,
'lu_attached_timestamp'
,
'lu_attached_method'
,
]
)
->
from
(
'localuser'
)
->
where
(
[
'lu_name'
=>
$this
->
mName
,
]
)
->
caller
(
__METHOD__
)
->
fetchResultSet
();
$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
();
$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
->
newSelectQueryBuilder
()
->
select
(
$fields
)
->
from
(
'user'
)
->
where
(
$conds
)
->
caller
(
__METHOD__
)
->
fetchRow
();
if
(
!
$row
)
{
# Row missing from replica, try the primary database instead
$db
=
$databaseManager
->
getLocalDB
(
DB_PRIMARY
,
$wikiID
);
$row
=
$db
->
newSelectQueryBuilder
()
->
select
(
$fields
)
->
from
(
'user'
)
->
where
(
$conds
)
->
caller
(
__METHOD__
)
->
fetchRow
();
}
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
,
// array of (group name => UserGroupMembership object)
'groupMemberships'
=>
[],
'blocked'
=>
false
,
];
// Edit count field may not be initialized...
if
(
$row
->
user_editcount
===
null
)
{
$data
[
'editCount'
]
=
$db
->
newSelectQueryBuilder
()
->
select
(
'COUNT(*)'
)
->
from
(
'revision'
)
->
where
(
[
'actor_user'
=>
$data
[
'id'
]
]
)
->
join
(
'actor'
,
null
,
'actor_id = rev_actor'
)
->
caller
(
__METHOD__
)
->
fetchField
();
}
// 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
$blockStore
=
$mwServices
->
getDatabaseBlockStoreFactory
()
->
getDatabaseBlockStore
(
$wikiID
);
$blocks
=
$blockStore
->
newListFromConds
(
[
'bt_user'
=>
$data
[
'id'
]
]
);
foreach
(
$blocks
as
$block
)
{
$data
[
'block-expiry'
]
=
$block
->
getExpiry
();
$data
[
'block-reason'
]
=
$block
->
getReasonComment
()->
text
;
$data
[
'block-anononly'
]
=
!
$block
->
isHardblock
();
$data
[
'block-nocreate'
]
=
$block
->
isCreateAccountBlocked
();
$data
[
'block-noautoblock'
]
=
!
$block
->
isAutoblocking
();
// Poorly named database column
$data
[
'block-nousertalk'
]
=
!
$block
->
isUsertalkEditAllowed
();
$data
[
'block-noemail'
]
=
$block
->
isEmailBlocked
();
$data
[
'block-sitewide'
]
=
$block
->
isSitewide
();
$data
[
'block-restrictions'
]
=
$block
->
getRestrictions
();
$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 Password in the form :<TYPE>:<PARAM1>:<PARAM2>:...:<HASH>
*/
protected
function
saltedPassword
(
$password
)
{
$config
=
RequestContext
::
getMain
()->
getConfig
();
$passwordFactory
=
new
PasswordFactory
(
$config
->
get
(
MainConfigNames
::
PasswordConfig
),
$config
->
get
(
MainConfigNames
::
PasswordDefault
)
);
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
();
$hash
=
$this
->
saltedPassword
(
$password
);
$this
->
mPassword
=
$hash
;
if
(
$this
->
getId
()
)
{
$dbw
=
CentralAuthServices
::
getDatabaseManager
()->
getCentralPrimaryDB
();
$dbw
->
newUpdateQueryBuilder
()
->
update
(
'globaluser'
)
->
set
(
[
'gu_password'
=>
$hash
]
)
->
where
(
[
'gu_id'
=>
$this
->
getId
(),
]
)
->
caller
(
__METHOD__
)
->
execute
();
$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
();
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_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
->
newUpdateQueryBuilder
()
->
update
(
'globaluser'
)
->
set
(
$toSet
)
->
where
(
[
'gu_id'
=>
$this
->
mGlobalId
,
'gu_cas_token'
=>
$this
->
mCasToken
]
)
->
caller
(
__METHOD__
)
->
execute
();
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
RuntimeException
,
]
);
return
;
}
$this
->
mCasToken
=
$newCasToken
;
$this
->
invalidateCache
();
}
/**
* @return string[]
*/
public
function
getGlobalGroups
()
{
return
array_keys
(
$this
->
getGlobalGroupsWithExpiration
()
);
}
/**
* @return array<string,?string> of [group name => expiration timestamp or null if permanent]
*/
public
function
getGlobalGroupsWithExpiration
()
{
$this
->
loadGroups
();
$groupExpirations
=
[];
foreach
(
$this
->
mGroups
as
[
'group'
=>
$group
,
'expiry'
=>
$expiration
]
)
{
$groupExpirations
[
$group
]
=
$expiration
;
}
ksort
(
$groupExpirations
);
return
$groupExpirations
;
}
/**
* Return the user's groups that are active on the current wiki (according to WikiSet settings).
*
* @return string[]
*/
public
function
getActiveGlobalGroups
()
{
$this
->
loadGroups
();
$groups
=
[];
$sets
=
[];
foreach
(
$this
->
mGroups
as
[
'group'
=>
$group
,
'set'
=>
$setId
]
)
{
if
(
$setId
)
{
$sets
[
$setId
]
??=
WikiSet
::
newFromID
(
$setId
);
if
(
!
$sets
[
$setId
]->
inSet
()
)
{
continue
;
}
}
$groups
[]
=
$group
;
}
sort
(
$groups
);
return
$groups
;
}
/**
* @return string[]
*/
public
function
getGlobalRights
()
{
$this
->
loadGroups
();
$rights
=
[];
$sets
=
[];
foreach
(
$this
->
mRights
as
[
'right'
=>
$right
,
'set'
=>
$setId
]
)
{
if
(
$setId
)
{
$sets
[
$setId
]
??=
WikiSet
::
newFromID
(
$setId
);
if
(
!
$sets
[
$setId
]->
inSet
()
)
{
continue
;
}
}
$rights
[]
=
$right
;
}
return
$rights
;
}
/**
* @param string $groups
* @return void
*/
public
function
removeFromGlobalGroups
(
$groups
)
{
$this
->
checkWriteMode
();
$dbw
=
CentralAuthServices
::
getDatabaseManager
()->
getCentralPrimaryDB
();
# Delete from the DB
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'global_user_groups'
)
->
where
(
[
'gug_user'
=>
$this
->
getId
(),
'gug_group'
=>
$groups
]
)
->
caller
(
__METHOD__
)
->
execute
();
$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
->
newReplaceQueryBuilder
()
->
replaceInto
(
'global_user_groups'
)
->
uniqueIndexFields
(
[
'gug_user'
,
'gug_group'
]
)
->
row
(
[
'gug_user'
=>
$this
->
getId
(),
'gug_group'
=>
$group
,
'gug_expiry'
=>
$dbw
->
timestampOrNull
(
$expiry
)
]
)
->
caller
(
__METHOD__
)
->
execute
();
$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
);
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Sat, Oct 3, 08:07 (1 w, 6 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
0b/09/5fcca92d338acd700af73ef82267
Default Alt Text
CentralAuthUser.php (92 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment