Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.35% covered (warning)
55.35%
848 / 1532
44.07% covered (danger)
44.07%
52 / 118
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralAuthUser
55.35% covered (warning)
55.35%
848 / 1532
44.07% covered (danger)
44.07%
52 / 118
12222.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getUserCache
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 clearUserCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setInstance
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInstanceByName
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
7.77
 getPrimaryInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrimaryInstanceByName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 checkWriteMode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getSafeReadDB
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 shouldUsePrimaryDB
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 selectQueryInfo
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 newFromId
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 newPrimaryInstanceFromId
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 newFromRow
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 newUnattached
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resetState
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 loadStateNoCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadState
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 loadGroups
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 getClosestGlobalUserGroupExpiry
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 loadFromDatabase
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 loadFromRow
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 loadFromCache
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
3
 loadFromCacheObject
52.38% covered (warning)
52.38%
11 / 21
0.00% covered (danger)
0.00%
0 / 1
7.70
 getId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLocalId
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
6.46
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAttached
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPasswordObject
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthToken
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLocked
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isHidden
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isSuppressed
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getBlocks
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
30
 isBlocked
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 hasPublicLogs
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 getHiddenLevel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHiddenLevelInt
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRegistration
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHomeWiki
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
12
 getGlobalEditCount
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 register
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
4
 storeMigrationData
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
3
 storeGlobalData
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 storeAndMigrate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 recordAntiSpoof
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 removeAntiSpoof
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 chooseHomeWiki
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
14.46
 prepareMigration
39.39% covered (danger)
39.39%
13 / 33
0.00% covered (danger)
0.00%
0 / 1
81.33
 migrationDryRun
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
20
 promoteToGlobal
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 chooseEmail
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
12.41
 attemptAutoMigration
60.00% covered (warning)
60.00%
39 / 65
0.00% covered (danger)
0.00%
0 / 1
21.22
 attemptPasswordMigration
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
30
 validateList
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 adminUnattach
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 queueAdminUnattachJob
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 adminDelete
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
20
 adminLock
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 adminUnlock
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 adminSetHidden
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 adminLockHide
85.53% covered (warning)
85.53%
65 / 76
0.00% covered (danger)
0.00%
0 / 1
33.91
 suppress
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unsuppress
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doCrosswikiSuppression
16.67% covered (danger)
16.67%
4 / 24
0.00% covered (danger)
0.00%
0 / 1
13.26
 doLocalSuppression
78.95% covered (warning)
78.95%
30 / 38
0.00% covered (danger)
0.00%
0 / 1
6.34
 attach
68.29% covered (warning)
68.29%
28 / 41
0.00% covered (danger)
0.00%
0 / 1
7.15
 addLocalEdits
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 canAuthenticate
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 authenticate
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 authenticateWithToken
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 matchHash
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 matchHashes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getPasswordFromString
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 listUnattached
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 doListUnattached
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
4.18
 addLocalName
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 removeLocalName
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 updateLocalName
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 loadAttached
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 listAttached
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 renameInProgressOn
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 renameInProgress
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getLocalGroups
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 queryAttached
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
6.56
 queryAttachedBasic
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
4
 queryUnattached
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 localUserData
58.14% covered (warning)
58.14%
50 / 86
0.00% covered (danger)
0.00%
0 / 1
8.64
 getEmail
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getEmailAuthenticationTimestamp
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setEmail
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setEmailAuthenticationTimestamp
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 saltedPassword
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setPassword
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 getPassword
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionProvider
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCookieDomain
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 validateAuthToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetAuthToken
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 saveSettings
70.83% covered (warning)
70.83%
34 / 48
0.00% covered (danger)
0.00%
0 / 1
6.89
 getGlobalGroups
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGlobalGroupsWithExpiration
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 getActiveGlobalGroups
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getGlobalRights
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 removeFromGlobalGroups
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 addToGlobalGroup
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 hasGlobalPermission
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 quickInvalidateCache
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 endTransaction
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 startTransaction
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 attachedOn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getStateHash
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 logAction
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 clearLocalUserCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Extension\CentralAuth\User;
22
23use BadMethodCallException;
24use CentralAuthSessionProvider;
25use Exception;
26use InvalidArgumentException;
27use LogicException;
28use ManualLogEntry;
29use MapCacheLRU;
30use MediaWiki\Block\DatabaseBlock;
31use MediaWiki\Context\IContextSource;
32use MediaWiki\Context\RequestContext;
33use MediaWiki\DAO\WikiAwareEntity;
34use MediaWiki\Deferred\DeferredUpdates;
35use MediaWiki\Extension\CentralAuth\CentralAuthReadOnlyError;
36use MediaWiki\Extension\CentralAuth\CentralAuthServices;
37use MediaWiki\Extension\CentralAuth\LocalUserNotFoundException;
38use MediaWiki\Extension\CentralAuth\RCFeed\CARCFeedFormatter;
39use MediaWiki\Extension\CentralAuth\WikiSet;
40use MediaWiki\Logger\LoggerFactory;
41use MediaWiki\MainConfigNames;
42use MediaWiki\MediaWikiServices;
43use MediaWiki\Password\AbstractPbkdf2Password;
44use MediaWiki\Password\Password;
45use MediaWiki\Password\PasswordError;
46use MediaWiki\Password\PasswordFactory;
47use MediaWiki\RCFeed\FormattedRCFeed;
48use MediaWiki\RCFeed\RCFeed;
49use MediaWiki\Session\SessionManager;
50use MediaWiki\Status\Status;
51use MediaWiki\Title\Title;
52use MediaWiki\User\ExternalUserNames;
53use MediaWiki\User\User;
54use MediaWiki\User\UserIdentity;
55use MediaWiki\User\UserIdentityValue;
56use MediaWiki\WikiMap\WikiMap;
57use MWCryptHash;
58use MWCryptRand;
59use RevisionDeleteUser;
60use RuntimeException;
61use stdClass;
62use Wikimedia\AtEase\AtEase;
63use Wikimedia\IPUtils;
64use Wikimedia\ObjectCache\WANObjectCache;
65use Wikimedia\Rdbms\Database;
66use Wikimedia\Rdbms\DBAccessObjectUtils;
67use Wikimedia\Rdbms\IDBAccessObject;
68use Wikimedia\Rdbms\IReadableDatabase;
69
70class CentralAuthUser implements IDBAccessObject {
71
72    /** @var MapCacheLRU Cache of loaded CentralAuthUsers */
73    private static $loadedUsers = null;
74
75    /**
76     * The username of the current user.
77     * @var string
78     */
79    private $mName;
80    /** @var bool */
81    public $mStateDirty = false;
82    /** @var int|false */
83    private $mDelayInvalidation = 0;
84
85    /** @var string[]|null */
86    private $mAttachedArray;
87    /** @var string */
88    private $mEmail;
89    /** @var bool */
90    private $mEmailAuthenticated;
91    /**
92     * @var string|null
93     * @internal
94     */
95    public $mHomeWiki;
96    /** @var int|null */
97    private $mHiddenLevel;
98    /** @var bool */
99    private $mLocked;
100    /**
101     * @var string|null As string, it is "\n"-imploded
102     */
103    private $mAttachedList;
104    /** @var string */
105    private $mAuthenticationTimestamp;
106    /**
107     * @var array|null
108     * @phan-var ?list<array{group:string,expiry:?string}>
109     */
110    private $mGroups;
111    /**
112     * @var array|null
113     * @phan-var ?list<array{right:string,set:?int}>
114     */
115    private $mRights;
116    /** @var string */
117    private $mPassword;
118    /** @var string|null */
119    private $mAuthToken;
120    /** @var int|null */
121    private $mGlobalId;
122    /** @var bool */
123    private $mFromPrimary;
124    /** @var bool */
125    private $mIsAttached;
126    /** @var string */
127    private $mRegistration;
128    /** @var int */
129    private $mGlobalEditCount;
130    /** @var string */
131    private $mBeingRenamed;
132    /** @var string[] */
133    private $mBeingRenamedArray;
134    /** @var array[]|null */
135    protected $mAttachedInfo;
136    /** @var int */
137    protected $mCasToken = 0;
138    /** @var \Psr\Log\LoggerInterface */
139    private $logger;
140
141    /** @var string[] */
142    private static $mCacheVars = [
143        'mGlobalId',
144        'mPassword',
145        'mAuthToken',
146        'mLocked',
147        'mHiddenLevel',
148        'mRegistration',
149        'mEmail',
150        'mAuthenticationTimestamp',
151        'mGroups',
152        'mRights',
153        'mHomeWiki',
154        'mBeingRenamed',
155
156        # Store the string list instead of the array, to save memory, and
157        # avoid unserialize() overhead
158        'mAttachedList',
159
160        'mCasToken'
161    ];
162
163    private const VERSION = 13;
164
165    public const HIDDEN_LEVEL_NONE = 0;
166    public const HIDDEN_LEVEL_LISTS = 1;
167    public const HIDDEN_LEVEL_SUPPRESSED = 2;
168
169    /**
170     * The maximum number of edits a user can have and still be hidden
171     */
172    private const HIDE_CONTRIBLIMIT = 1000;
173
174    /**
175     * The possible responses from self::authenticate(),
176     * self::canAuthenticate() and self::authenticateWithToken().
177     *
178     * Constants are defined as lowercase strings for
179     * backwards compatibility.
180     */
181    public const AUTHENTICATE_OK = "ok";
182    public const AUTHENTICATE_NO_USER = "no user";
183    public const AUTHENTICATE_LOCKED = "locked";
184    public const AUTHENTICATE_BAD_PASSWORD = "bad password";
185    public const AUTHENTICATE_BAD_TOKEN = "bad token";
186    public const AUTHENTICATE_GOOD_PASSWORD = "good password";
187
188    /**
189     * @note Don't call this directly. Use self::getInstanceByName() or
190     *  self::getPrimaryInstanceByName() instead.
191     * @param string $username
192     * @param int $flags Supports IDBAccessObject::READ_LATEST to use the primary DB
193     */
194    public function __construct( $username, $flags = 0 ) {
195        $this->mName = $username;
196        $this->resetState();
197        if ( DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ) ) {
198            $this->mFromPrimary = true;
199        }
200        $this->logger = LoggerFactory::getInstance( 'CentralAuth' );
201    }
202
203    /**
204     * Fetch the cache
205     * @return MapCacheLRU
206     */
207    private static function getUserCache() {
208        if ( self::$loadedUsers === null ) {
209            // Limit of 20 is arbitrary
210            self::$loadedUsers = new MapCacheLRU( 20 );
211        }
212        return self::$loadedUsers;
213    }
214
215    /**
216     * Clears the internal cache of CentralAuthUser instances.
217     *
218     * @internal Only for use in CentralAuth tests.
219     * @return void
220     */
221    public static function clearUserCache() {
222        self::$loadedUsers->clear();
223    }
224
225    /**
226     * Explicitly set the (cached) CentralAuthUser object corresponding to the supplied User.
227     * @param UserIdentity $user
228     * @param CentralAuthUser $caUser
229     */
230    public static function setInstance( UserIdentity $user, CentralAuthUser $caUser ) {
231        self::getUserCache()->set( $user->getName(), $caUser );
232    }
233
234    /**
235     * Create a (cached) CentralAuthUser object corresponding to the supplied User.
236     * @param UserIdentity $user
237     * @return CentralAuthUser
238     */
239    public static function getInstance( UserIdentity $user ): self {
240        return self::getInstanceByName( $user->getName() );
241    }
242
243    /**
244     * Create a (cached) CentralAuthUser object corresponding to the supplied user.
245     *
246     * @param string $username A valid username. (Since 1.42 it does not have to be in the
247     *   canonical form anymore). IP addresses/ranges and external usernames are also accepted
248     *   for B/C but discouraged; they will be handled like a non-registered username.
249     * @return CentralAuthUser
250     * @throws InvalidArgumentException on invalid usernames.
251     */
252    public static function getInstanceByName( $username ): self {
253        if ( IPUtils::isValid( $username ) ) {
254            $canonUsername = IPUtils::sanitizeIP( $username );
255        } elseif ( IPUtils::isValidRange( $username ) ) {
256            $canonUsername = IPUtils::sanitizeRange( $username );
257        } elseif ( ExternalUserNames::isExternal( $username ) ) {
258            $canonUsername = $username;
259        } else {
260            $canonUsername = MediaWikiServices::getInstance()->getUserNameUtils()
261                ->getCanonical( $username );
262        }
263
264        if ( $canonUsername === false || $canonUsername === null ) {
265            throw new InvalidArgumentException( "Invalid username: $username" );
266        }
267
268        $cache = self::getUserCache();
269        $ret = $cache->get( $canonUsername );
270        if ( !$ret ) {
271            $ret = new self( $canonUsername );
272            $cache->set( $canonUsername, $ret );
273        }
274        return $ret;
275    }
276
277    /**
278     * Create a (cached) CentralAuthUser object corresponding to the supplied User.
279     * This object will use DB_PRIMARY.
280     * @param UserIdentity $user
281     * @return CentralAuthUser
282     * @since 1.37
283     */
284    public static function getPrimaryInstance( UserIdentity $user ): self {
285        return self::getPrimaryInstanceByName( $user->getName() );
286    }
287
288    /**
289     * Create a (cached) CentralAuthUser object corresponding to the supplied User.
290     * This object will use DB_PRIMARY.
291     * @param string $username Must be validated and canonicalized by the caller
292     * @return CentralAuthUser
293     * @since 1.37
294     */
295    public static function getPrimaryInstanceByName( $username ): self {
296        $cache = self::getUserCache();
297        $ret = $cache->get( $username );
298        if ( !$ret || !$ret->mFromPrimary ) {
299            $ret = new self( $username, IDBAccessObject::READ_LATEST );
300            $cache->set( $username, $ret );
301        }
302        return $ret;
303    }
304
305    /**
306     * Test if this is a write-mode instance, and log if not.
307     */
308    private function checkWriteMode() {
309        if ( !$this->mFromPrimary ) {
310            $this->logger->warning(
311                'Write mode called on replica-loaded object',
312                [ 'exception' => new RuntimeException() ]
313            );
314        }
315    }
316
317    /**
318     * @return IReadableDatabase Primary database or replica based on shouldUsePrimaryDB()
319     * @throws CentralAuthReadOnlyError
320     */
321    protected function getSafeReadDB() {
322        if ( $this->shouldUsePrimaryDB() ) {
323            return CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
324        } else {
325            return CentralAuthServices::getDatabaseManager()->getCentralReplicaDB();
326        }
327    }
328
329    /**
330     * Get (and init if needed) the value of mFromPrimary
331     *
332     * @return bool
333     */
334    protected function shouldUsePrimaryDB() {
335        $dbManager = CentralAuthServices::getDatabaseManager();
336        if ( $dbManager->isReadOnly() ) {
337            return false;
338        }
339        if ( $this->mFromPrimary === null ) {
340            $this->mFromPrimary = $dbManager->centralLBHasRecentPrimaryChanges();
341        }
342
343        return $this->mFromPrimary;
344    }
345
346    /**
347     * Return query data needed to properly use self::newFromRow
348     * @return array (
349     *   'tables' => array,
350     *   'fields' => array,
351     *   'conds' => array,
352     *   'options' => array,
353     *   'join_conds' => array,
354     *  )
355     */
356    public static function selectQueryInfo() {
357        return [
358            'tables' => [ 'globaluser', 'localuser' ],
359            'fields' => [
360                'gu_id', 'gu_name', 'lu_wiki', 'gu_password', 'gu_auth_token',
361                'gu_locked', 'gu_hidden_level', 'gu_registration', 'gu_email',
362                'gu_email_authenticated', 'gu_home_db', 'gu_cas_token'
363            ],
364            'conds' => [],
365            'options' => [],
366            'join_conds' => [
367                'localuser' => [ 'LEFT OUTER JOIN', [ 'gu_name=lu_name', 'lu_wiki' => WikiMap::getCurrentWikiId() ] ]
368            ],
369        ];
370    }
371
372    /**
373     * Get a CentralAuthUser object from a user's id
374     *
375     * @param int $id
376     * @return CentralAuthUser|bool false if no user exists with that id
377     */
378    public static function newFromId( $id ) {
379        $name = CentralAuthServices::getDatabaseManager()
380            ->getCentralReplicaDB()
381            ->newSelectQueryBuilder()
382            ->select( 'gu_name' )
383            ->from( 'globaluser' )
384            ->where( [ 'gu_id' => $id ] )
385            ->caller( __METHOD__ )
386            ->fetchField();
387
388        return $name === false ? false : self::getInstanceByName( $name );
389    }
390
391    /**
392     * Get a primary CentralAuthUser object from a user's id
393     *
394     * @param int $id
395     * @return CentralAuthUser|bool false if no user exists with that id
396     * @since 1.37
397     */
398    public static function newPrimaryInstanceFromId( $id ) {
399        $name = CentralAuthServices::getDatabaseManager()
400            ->getCentralPrimaryDB()
401            ->newSelectQueryBuilder()
402            ->select( 'gu_name' )
403            ->from( 'globaluser' )
404            ->where( [ 'gu_id' => $id ] )
405            ->caller( __METHOD__ )
406            ->fetchField();
407
408        return $name === false ? false : self::getPrimaryInstanceByName( $name );
409    }
410
411    /**
412     * Create a CentralAuthUser object from a joined globaluser/localuser row
413     *
414     * @param stdClass $row
415     * @param array $renameUser Empty if no rename is going on, else (oldname, newname)
416     * @param bool $fromPrimary
417     * @return CentralAuthUser
418     */
419    public static function newFromRow( $row, $renameUser, $fromPrimary = false ): self {
420        $caUser = new self( $row->gu_name );
421        $caUser->loadFromRow( $row, $renameUser, $fromPrimary );
422        return $caUser;
423    }
424
425    /**
426     * Create a CentralAuthUser object for a user who is known to be unattached.
427     * @param string $name The user name
428     * @param bool $fromPrimary
429     * @return CentralAuthUser
430     */
431    public static function newUnattached( $name, $fromPrimary = false ): self {
432        $caUser = new self( $name );
433        $caUser->loadFromRow( false, [], $fromPrimary );
434        return $caUser;
435    }
436
437    /**
438     * Clear state information cache
439     * Does not clear $this->mName, so the state information can be reloaded with loadState()
440     */
441    protected function resetState() {
442        $this->mGlobalId = null;
443        $this->mGroups = null;
444        $this->mAttachedArray = null;
445        $this->mAttachedList = null;
446        $this->mHomeWiki = null;
447    }
448
449    /**
450     * Load up state information, but don't use the cache
451     */
452    public function loadStateNoCache() {
453        $this->loadState( true );
454    }
455
456    /**
457     * Lazy-load up the most commonly required state information
458     * @param bool $recache Force a load from the database then save back to the cache
459     */
460    protected function loadState( $recache = false ) {
461        if ( $recache ) {
462            $this->resetState();
463        } elseif ( $this->mGlobalId !== null ) {
464            // Already loaded
465            return;
466        }
467
468        // Check the cache (unless the primary database was requested via READ_LATEST)
469        if ( !$recache && $this->mFromPrimary !== true ) {
470            $this->loadFromCache();
471        } else {
472            $this->loadFromDatabase();
473        }
474    }
475
476    /**
477     * Load user groups and rights from the database.
478     *
479     * @param bool $force Set to true to load even when already loaded.
480     */
481    protected function loadGroups( bool $force = false ) {
482        if ( $this->mGroups !== null && !$force ) {
483            // Already loaded
484            return;
485        }
486        $this->logger->debug(
487            'Loading groups for global user {user}',
488            [ 'user' => $this->mName ]
489        );
490
491        // We need the user id from the database, but this should be checked by the getId accessor.
492        $db = $this->getSafeReadDB();
493
494        // Grab the user's rights/groups.
495        $userAndExpiryConds = [
496            'gug_user' => $this->getId(),
497            $db->expr( 'gug_expiry', '=', null )->or( 'gug_expiry', '>=', $db->timestamp() ),
498        ];
499
500        $resGroups = $db->newSelectQueryBuilder()
501            ->select( [ 'gug_group', 'gug_expiry', 'ggr_set' ] )
502            ->from( 'global_user_groups' )
503            ->leftJoin( 'global_group_restrictions', null, 'ggr_group=gug_group' )
504            ->where( $userAndExpiryConds )
505            ->caller( __METHOD__ )
506            ->fetchResultSet();
507        $this->mGroups = [];
508        foreach ( $resGroups as $row ) {
509            $this->mGroups[] = [ 'group' => $row->gug_group, 'expiry' => $row->gug_expiry, 'set' => $row->ggr_set ];
510        }
511
512        $resRights = $db->newSelectQueryBuilder()
513            ->select( [ 'ggp_permission', 'ggr_set' ] )
514            ->from( 'global_group_permissions' )
515            ->join( 'global_user_groups', null, 'ggp_group=gug_group' )
516            ->leftJoin( 'global_group_restrictions', null, 'ggr_group=gug_group' )
517            ->where( $userAndExpiryConds )
518            ->caller( __METHOD__ )
519            ->fetchResultSet();
520        $this->mRights = [];
521        foreach ( $resRights as $row ) {
522            // Only store the set id here, and don't compute effective rights, because
523            // this is stored in a shared cache for all wikis (see loadFromCache()),
524            // which also isn't invalidated if the set is changed.
525            $this->mRights[] = [ 'right' => $row->ggp_permission, 'set' => $row->ggr_set ];
526        }
527    }
528
529    /**
530     * @return int|null Time when a global user group membership for this user will expire
531     * the next time in UNIX time, or null if this user has no temporary global group memberships.
532     */
533    private function getClosestGlobalUserGroupExpiry(): ?int {
534        $this->loadGroups();
535
536        $closestExpiry = null;
537
538        foreach ( $this->mGroups as [ 'expiry' => $expiration ] ) {
539            if ( !$expiration ) {
540                continue;
541            }
542
543            $expiration = wfTimestamp( TS_UNIX, $expiration );
544
545            if ( $closestExpiry ) {
546                $closestExpiry = min( $closestExpiry, $expiration );
547            } else {
548                $closestExpiry = $expiration;
549            }
550        }
551
552        return $closestExpiry;
553    }
554
555    protected function loadFromDatabase() {
556        $this->logger->debug(
557            'Loading state for global user {user} from DB',
558            [ 'user' => $this->mName ]
559        );
560
561        $fromPrimary = $this->shouldUsePrimaryDB();
562        // matches $fromPrimary above
563        $db = $this->getSafeReadDB();
564
565        $row = $db->newSelectQueryBuilder()
566            ->queryInfo( self::selectQueryInfo() )
567            ->where( [ 'gu_name' => $this->mName ] )
568            ->caller( __METHOD__ )
569            ->fetchRow();
570
571        $renameUser = CentralAuthServices::getGlobalRenameFactory()
572            ->newGlobalRenameUserStatus( $this->mName )
573            ->getNames(
574                null,
575                $fromPrimary ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL
576            );
577
578        $this->loadFromRow( $row, $renameUser, $fromPrimary );
579    }
580
581    /**
582     * Load user state from a joined globaluser/localuser row
583     *
584     * @param stdClass|bool $row
585     * @param array $renameUser Empty if no rename is going on, else (oldname, newname)
586     * @param bool $fromPrimary
587     */
588    protected function loadFromRow( $row, $renameUser, $fromPrimary = false ) {
589        if ( $row ) {
590            $this->mGlobalId = intval( $row->gu_id );
591            $this->mIsAttached = ( $row->lu_wiki !== null );
592            $this->mPassword = $row->gu_password;
593            $this->mAuthToken = $row->gu_auth_token;
594            $this->mLocked = $row->gu_locked;
595            $this->mHiddenLevel = (int)$row->gu_hidden_level;
596            $this->mRegistration = wfTimestamp( TS_MW, $row->gu_registration );
597            $this->mEmail = $row->gu_email;
598            $this->mAuthenticationTimestamp =
599                wfTimestampOrNull( TS_MW, $row->gu_email_authenticated );
600            $this->mHomeWiki = $row->gu_home_db;
601            $this->mCasToken = $row->gu_cas_token;
602        } else {
603            $this->mGlobalId = 0;
604            $this->mIsAttached = false;
605            $this->mLocked = false;
606            $this->mHiddenLevel = self::HIDDEN_LEVEL_NONE;
607            $this->mCasToken = 0;
608        }
609
610        $this->mFromPrimary = $fromPrimary;
611
612        $this->mBeingRenamedArray = $renameUser ?? [];
613        $this->mBeingRenamed = implode( '|', $this->mBeingRenamedArray );
614    }
615
616    /**
617     * Load data from memcached
618     *
619     * @return bool
620     */
621    protected function loadFromCache() {
622        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
623        $data = $cache->getWithSetCallback(
624            $this->getCacheKey( $cache ),
625            $cache::TTL_DAY,
626            function ( $oldValue, &$ttl, array &$setOpts ) {
627                $dbr = CentralAuthServices::getDatabaseManager()->getCentralReplicaDB();
628                $setOpts += Database::getCacheSetOptions( $dbr );
629
630                $this->loadFromDatabase();
631                $this->loadAttached();
632                $this->loadGroups();
633
634                // if this user has global user groups expiring in less than the default TTL (1 day),
635                // max out the TTL so that then-expired user groups will not be loaded from cache
636                $closestGugExpiry = $this->getClosestGlobalUserGroupExpiry();
637                if ( $closestGugExpiry ) {
638                    $ttl = min( $closestGugExpiry - time(), $ttl );
639                }
640
641                $data = [];
642                foreach ( self::$mCacheVars as $var ) {
643                    $data[$var] = $this->$var;
644                }
645
646                return $data;
647            },
648            [ 'pcTTL' => $cache::TTL_PROC_LONG, 'version' => self::VERSION ]
649        );
650
651        $this->loadFromCacheObject( $data );
652
653        return true;
654    }
655
656    /**
657     * Load user state from a cached array.
658     *
659     * @param array $object
660     */
661    protected function loadFromCacheObject( array $object ) {
662        $this->logger->debug(
663            'Loading CentralAuthUser for user {user} from cache object',
664            [ 'user' => $this->mName ]
665        );
666
667        foreach ( self::$mCacheVars as $var ) {
668            $this->$var = $object[$var];
669        }
670
671        $this->loadAttached();
672
673        $this->mIsAttached = $this->exists() && in_array( WikiMap::getCurrentWikiId(), $this->mAttachedArray );
674        $this->mFromPrimary = false;
675
676        $closestUserGroupExpiration = $this->getClosestGlobalUserGroupExpiry();
677        if ( $closestUserGroupExpiration !== null && $closestUserGroupExpiration < time() ) {
678            $this->logger->warning(
679                'Cached user {user} had a global group expiration in the past '
680                    . '({unixTimestamp}), this should not be possible',
681                [
682                    'user' => $this->getName(),
683                    'unixTimestamp' => $closestUserGroupExpiration,
684                ]
685            );
686
687            // load accurate data for this request from the database
688            $this->loadGroups( true );
689
690            // kill the current cache entry so that next request can use the cached value
691            $this->quickInvalidateCache();
692        }
693    }
694
695    /**
696     * Return the global account ID number for this account, if it exists.
697     * @return int
698     */
699    public function getId() {
700        $this->loadState();
701        return $this->mGlobalId;
702    }
703
704    /**
705     * Return the local user account ID of the user with the same name on given wiki,
706     * irrespective of whether it is attached or not
707     * @param string $wikiId ID for the local database to connect to
708     * @return int|null Local user ID for given $wikiID. Null if $wikiID is invalid or local user
709     *  doesn't exist
710     */
711    public function getLocalId( $wikiId ) {
712        // Make sure the wiki ID is valid. (This prevents DBConnectionError in unit tests)
713        $wikiList = CentralAuthServices::getWikiListService()->getWikiList();
714        if ( !in_array( $wikiId, $wikiList ) ) {
715            return null;
716        }
717        // Retrieve the local user ID from the specified database.
718        $db = CentralAuthServices::getDatabaseManager()->getLocalDB( DB_PRIMARY, $wikiId );
719        $id = $db->newSelectQueryBuilder()
720            ->select( 'user_id' )
721            ->from( 'user' )
722            ->where( [ 'user_name' => $this->mName ] )
723            ->caller( __METHOD__ )
724            ->fetchField();
725        return $id ? (int)$id : null;
726    }
727
728    /**
729     * Generate a valid memcached key for caching the object's data.
730     * @param WANObjectCache $cache
731     * @return string
732     */
733    protected function getCacheKey( WANObjectCache $cache ) {
734        return $cache->makeGlobalKey( 'centralauth-user', md5( $this->mName ) );
735    }
736
737    /**
738     * Return the global account's name, whether it exists or not.
739     * @return string
740     */
741    public function getName() {
742        return $this->mName;
743    }
744
745    /**
746     * @return bool True if the account is attached on the local wiki
747     */
748    public function isAttached() {
749        $this->loadState();
750        return $this->mIsAttached;
751    }
752
753    /**
754     * Return the password.
755     *
756     * @return Password
757     */
758    public function getPasswordObject() {
759        $this->loadState();
760        return $this->getPasswordFromString( $this->mPassword, '' );
761    }
762
763    /**
764     * Return the global-login token for this account.
765     * @return string
766     */
767    public function getAuthToken() {
768        global $wgAuthenticationTokenVersion;
769
770        $this->loadState();
771
772        if ( !$this->mAuthToken ) {
773            $this->resetAuthToken();
774        }
775
776        if ( $wgAuthenticationTokenVersion === null ) {
777            return $this->mAuthToken;
778        } else {
779            $ret = MWCryptHash::hmac( $wgAuthenticationTokenVersion, $this->mAuthToken, false );
780
781            // The raw hash can be overly long. Shorten it up.
782            if ( strlen( $ret ) < 32 ) {
783                // Should never happen, even md5 is 128 bits
784                throw new \UnexpectedValueException( 'Hmac returned less than 128 bits' );
785            }
786            return substr( $ret, -32 );
787        }
788    }
789
790    /**
791     * Check whether a global user account for this name exists yet.
792     * If migration state is set for pass 1, this may trigger lazy
793     * evaluation of automatic migration for the account.
794     *
795     * @return bool
796     */
797    public function exists() {
798        return (bool)$this->getId();
799    }
800
801    /**
802     * Returns whether the account is
803     * locked.
804     * @return bool
805     */
806    public function isLocked() {
807        $this->loadState();
808        return (bool)$this->mLocked;
809    }
810
811    /**
812     * Returns whether user name should not
813     * be shown in public lists.
814     * @return bool
815     */
816    public function isHidden() {
817        $this->loadState();
818        return $this->mHiddenLevel !== self::HIDDEN_LEVEL_NONE;
819    }
820
821    /**
822     * Returns whether user's name should
823     * be hidden from all public views because
824     * of privacy issues.
825     * @return bool
826     */
827    public function isSuppressed() {
828        $this->loadState();
829        return $this->mHiddenLevel == self::HIDDEN_LEVEL_SUPPRESSED;
830    }
831
832    /**
833     * Returns an array of blocks per wiki the user is attached to.
834     * @throws LocalUserNotFoundException
835     * @return array
836     */
837    public function getBlocks(): array {
838        $blocksByWikiId = [];
839
840        $mwServices = MediaWikiServices::getInstance();
841        $dbm = CentralAuthServices::getDatabaseManager();
842
843        $wikis = $this->queryAttachedBasic();
844        foreach ( $wikis as $wikiId => $_ ) {
845            $db = $dbm->getLocalDB( DB_REPLICA, $wikiId );
846            $fields = [ 'user_id' ];
847            $conds = [ 'user_name' => $this->mName ];
848
849            $row = $db->newSelectQueryBuilder()
850                ->select( $fields )
851                ->from( 'user' )
852                ->where( $conds )
853                ->caller( __METHOD__ )
854                ->fetchRow();
855            if ( !$row ) {
856                # Row missing from replica, try the primary database instead
857                $db = $dbm->getLocalDB( DB_PRIMARY, $wikiId );
858                $row = $db->newSelectQueryBuilder()
859                    ->select( $fields )
860                    ->from( 'user' )
861                    ->where( $conds )
862                    ->caller( __METHOD__ )
863                    ->fetchRow();
864            }
865            if ( !$row ) {
866                $ex = new LocalUserNotFoundException(
867                    "Could not find local user data for {$this->mName}@{$wikiId}"
868                );
869                $this->logger->warning(
870                    'Could not find local user data for {username}@{wikiId}',
871                    [
872                        'username' => $this->mName,
873                        'wikiId' => $wikiId,
874                        'exception' => $ex,
875                    ]
876                );
877                throw $ex;
878            }
879            // if the current wiki is the local wiki, then the BlockStore expect "WikiAwareEntity::LOCAL" as value;
880            if ( $wikiId === WikiMap::getCurrentWikiId() ) {
881                $wikiId = WikiAwareEntity::LOCAL;
882            }
883
884            $blockStore = $mwServices
885                ->getDatabaseBlockStoreFactory()
886                ->getDatabaseBlockStore( $wikiId );
887            $blocksByWikiId[$wikiId] = $blockStore->newListFromConds(
888                [ 'bt_user' => $row->user_id ]
889            );
890        }
891
892        return $blocksByWikiId;
893    }
894
895    /**
896     * Returns true if the account has any blocks on any wikis.
897     * @throws LocalUserNotFoundException
898     * @return bool
899     */
900    public function isBlocked(): bool {
901        $blocksByWikiId = $this->getBlocks();
902
903        foreach ( $blocksByWikiId as $wikiId => $blocks ) {
904            if ( count( $blocks ) > 0 ) {
905                return true;
906            }
907        }
908
909        return false;
910    }
911
912    /**
913     * Returns true if the account has any public logs.
914     *
915     * By default this method ignores 'newusers' logs as almost all users will
916     * have a log record of that type.
917     *
918     * @param array $excludeTypes is an array of log types to ignore
919     * @return bool
920     */
921    public function hasPublicLogs( $excludeTypes = [ 'newusers' ] ): bool {
922        $services = MediaWikiServices::getInstance();
923        $dbm = CentralAuthServices::getDatabaseManager();
924
925        $wikis = $this->queryAttachedBasic();
926        foreach ( $wikis as $wikiId => $_ ) {
927            $dbr = $dbm->getLocalDB( DB_REPLICA, $wikiId );
928
929            $actorStore = $services->getActorStoreFactory()->getActorStore( $wikiId );
930            $user = $actorStore->getUserIdentityByName( $this->getName() );
931            if ( !$user ) {
932                continue;
933            }
934            $actorId = $actorStore->acquireActorId( $user, $dbr );
935
936            $conds = [
937                'log_actor' => $actorId,
938                $dbr->expr( 'log_type', '!=', $excludeTypes ),
939            ];
940            $row = $dbr->newSelectQueryBuilder()
941                ->select( [ 'log_id' ] )
942                ->from( 'logging' )
943                ->where( $conds )
944                ->caller( __METHOD__ )
945                ->fetchRow();
946            if ( $row ) {
947                return true;
948            }
949        }
950
951        return false;
952    }
953
954    /**
955     * Returns the hidden level of the account.
956     * @throws Exception for now
957     * @return never
958     * @deprecated use getHiddenLevelInt() instead
959     */
960    public function getHiddenLevel(): int {
961        // Have it like this for one train, then rename getHiddenLevelInt to this
962        throw new BadMethodCallException( 'Nothing should call this!' );
963    }
964
965    /**
966     * Temporary name, will be getHiddenLevel() when migration is complete
967     * @return int one of self::HIDDEN_LEVEL_* constants
968     */
969    public function getHiddenLevelInt(): int {
970        $this->loadState();
971        return $this->mHiddenLevel;
972    }
973
974    /**
975     * @return string timestamp
976     */
977    public function getRegistration() {
978        $this->loadState();
979        return wfTimestamp( TS_MW, $this->mRegistration );
980    }
981
982    /**
983     * Return the id of the user's home wiki.
984     *
985     * @return string|null Null if the account has no attached wikis
986     */
987    public function getHomeWiki() {
988        $this->loadState();
989
990        if ( $this->mHomeWiki !== null && $this->mHomeWiki !== '' ) {
991            return $this->mHomeWiki;
992        }
993
994        $attached = $this->queryAttachedBasic();
995
996        if ( !count( $attached ) ) {
997            return null;
998        }
999
1000        foreach ( $attached as $wiki => $acc ) {
1001            if ( $acc['attachedMethod'] == 'primary' || $acc['attachedMethod'] == 'new' ) {
1002                $this->mHomeWiki = $wiki;
1003                break;
1004            }
1005        }
1006
1007        if ( $this->mHomeWiki === null || $this->mHomeWiki === '' ) {
1008            // Still null... try harder.
1009            $attached = $this->queryAttached();
1010            // Make sure we always have some value
1011            $this->mHomeWiki = key( $attached );
1012            $maxEdits = -1;
1013            foreach ( $attached as $wiki => $acc ) {
1014                if ( isset( $acc['editCount'] ) && $acc['editCount'] > $maxEdits ) {
1015                    $this->mHomeWiki = $wiki;
1016                    $maxEdits = $acc['editCount'];
1017                }
1018            }
1019        }
1020
1021        return $this->mHomeWiki;
1022    }
1023
1024    /**
1025     * @return int total number of edits for all wikis
1026     */
1027    public function getGlobalEditCount() {
1028        if ( $this->mGlobalEditCount === null ) {
1029            $this->mGlobalEditCount = CentralAuthServices::getEditCounter()
1030                ->getCount( $this );
1031        }
1032        return $this->mGlobalEditCount;
1033    }
1034
1035    /**
1036     * Register a new, not previously existing, central user account
1037     * Remaining fields are expected to be filled out shortly...
1038     * eeeyuck
1039     *
1040     * @param string|null $password
1041     * @param string $email
1042     * @return bool
1043     */
1044    public function register( $password, $email ) {
1045        $this->checkWriteMode();
1046        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
1047        $hash = $this->saltedPassword( $password );
1048        if ( !$this->mAuthToken ) {
1049            $this->mAuthToken = MWCryptRand::generateHex( 32 );
1050        }
1051
1052        $data = [
1053            'gu_name'  => $this->mName,
1054
1055            'gu_email' => $email,
1056            'gu_email_authenticated' => null,
1057
1058            'gu_password' => $hash,
1059
1060            'gu_auth_token' => $this->mAuthToken,
1061
1062            'gu_locked' => 0,
1063            'gu_hidden_level' => self::HIDDEN_LEVEL_NONE,
1064
1065            'gu_registration' => $dbw->timestamp(),
1066        ];
1067
1068        $dbw->newInsertQueryBuilder()
1069            ->insertInto( 'globaluser' )
1070            ->ignore()
1071            ->row( $data )
1072            ->caller( __METHOD__ )
1073            ->execute();
1074
1075        $ok = $dbw->affectedRows() === 1;
1076        $this->logger->info(
1077            $ok
1078                ? 'registered global account "{user}"'
1079                : 'registration failed for global account "{user}"',
1080            [ 'user' => $this->mName ]
1081        );
1082
1083        if ( $ok ) {
1084            // Avoid lazy initialisation of edit count
1085            $dbw->newInsertQueryBuilder()
1086                ->insertInto( 'global_edit_count' )
1087                ->row( [
1088                    'gec_user' => $dbw->insertId(),
1089                    'gec_count' => 0
1090                ] )
1091                ->caller( __METHOD__ )
1092                ->execute();
1093        }
1094
1095        // Kill any cache entries saying we don't exist
1096        $this->invalidateCache();
1097        return $ok;
1098    }
1099
1100    /**
1101     * For use in migration pass zero.
1102     * Store local user data into the auth server's migration table.
1103     * @param string $wiki Source wiki ID
1104     * @param array $users Associative array of ids => names
1105     */
1106    public static function storeMigrationData( $wiki, $users ) {
1107        if ( !$users ) {
1108            return;
1109        }
1110
1111        $globalTuples = [];
1112        $tuples = [];
1113        foreach ( $users as $name ) {
1114            $globalTuples[] = [ 'gn_name' => $name ];
1115            $tuples[] = [
1116                'ln_wiki' => $wiki,
1117                'ln_name' => $name
1118            ];
1119        }
1120
1121        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
1122        $dbw->newInsertQueryBuilder()
1123            ->insertInto( 'globalnames' )
1124            ->ignore()
1125            ->rows( $globalTuples )
1126            ->caller( __METHOD__ )
1127            ->execute();
1128        $dbw->newInsertQueryBuilder()
1129            ->insertInto( 'localnames' )
1130            ->ignore()
1131            ->rows( $tuples )
1132            ->caller( __METHOD__ )
1133            ->execute();
1134    }
1135
1136    /**
1137     * Store global user data in the auth server's main table.
1138     *
1139     * @param string $salt
1140     * @param string $hash
1141     * @param string $email
1142     * @param string $emailAuth timestamp
1143     * @return bool Whether we were successful or not.
1144     */
1145    protected function storeGlobalData( $salt, $hash, $email, $emailAuth ) {
1146        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
1147        $data = [
1148            'gu_name' => $this->mName,
1149            'gu_password' => $hash,
1150            // So it doesn't have to be done later
1151            'gu_auth_token' => MWCryptRand::generateHex( 32 ),
1152            'gu_email' => $email,
1153            'gu_email_authenticated' => $dbw->timestampOrNull( $emailAuth ),
1154            'gu_registration' => $dbw->timestamp(),
1155            'gu_locked' => 0,
1156            'gu_hidden_level' => self::HIDDEN_LEVEL_NONE,
1157        ];
1158
1159        $dbw->newInsertQueryBuilder()
1160            ->insertInto( 'globaluser' )
1161            ->ignore()
1162            ->row( $data )
1163            ->caller( __METHOD__ )
1164            ->execute();
1165
1166        $this->resetState();
1167        return $dbw->affectedRows() != 0;
1168    }
1169
1170    /**
1171     * @param string[] $passwords
1172     * @param bool $sendToRC
1173     * @param bool $safe Only allow migration if all users can be migrated
1174     * @param bool $checkHome Re-check the user's ownership of the home wiki
1175     * @return bool
1176     */
1177    public function storeAndMigrate(
1178        $passwords = [], $sendToRC = true, $safe = false, $checkHome = false
1179    ) {
1180        $ret = $this->attemptAutoMigration( $passwords, $sendToRC, $safe, $checkHome );
1181        if ( $ret === true ) {
1182            $this->recordAntiSpoof();
1183        }
1184
1185        return $ret;
1186    }
1187
1188    /**
1189     * Record the current username in the AntiSpoof system
1190     */
1191    protected function recordAntiSpoof() {
1192        $spoof = CentralAuthServices::getAntiSpoofManager()->getSpoofUser( $this->mName );
1193        $spoof->record();
1194    }
1195
1196    /**
1197     * Remove the current username from the AntiSpoof system
1198     */
1199    public function removeAntiSpoof() {
1200        $spoof = CentralAuthServices::getAntiSpoofManager()->getSpoofUser( $this->mName );
1201        $spoof->remove();
1202    }
1203
1204    /**
1205     * Out of the given set of local account data, pick which will be the
1206     * initially-assigned home wiki.
1207     *
1208     * This will be the account with the highest edit count, either out of
1209     * all privileged accounts or all accounts if none are privileged.
1210     *
1211     * @param array $migrationSet
1212     * @throws Exception
1213     * @return string|null
1214     */
1215    public function chooseHomeWiki( $migrationSet ) {
1216        if ( !$migrationSet ) {
1217            throw new LogicException( 'Logic error -- empty migration set in chooseHomeWiki' );
1218        }
1219
1220        // Sysops get priority
1221        $found = [];
1222        $priorityGroups = [ 'checkuser', 'suppress', 'bureaucrat', 'sysop' ];
1223        foreach ( $priorityGroups as $group ) {
1224            foreach ( $migrationSet as $wiki => $local ) {
1225                if ( isset( $local['groupMemberships'][$group] ) ) {
1226                    $found[] = $wiki;
1227                }
1228            }
1229            if ( count( $found ) === 1 ) {
1230                // Easy!
1231                return $found[0];
1232            } elseif ( $found ) {
1233                // We'll check edit counts now...
1234                break;
1235            }
1236        }
1237
1238        if ( !$found ) {
1239            // No privileged accounts; look among the plebes...
1240            $found = array_keys( $migrationSet );
1241        }
1242
1243        $maxEdits = -1;
1244        $homeWiki = null;
1245        foreach ( $found as $wiki ) {
1246            $count = $migrationSet[$wiki]['editCount'];
1247            if ( $count > $maxEdits ) {
1248                $homeWiki = $wiki;
1249                $maxEdits = $count;
1250            } elseif ( $count === $maxEdits ) {
1251                // Tie, check earlier registration
1252                // Note that registration might be "null", which means they're a super old account.
1253                if ( !$homeWiki || $migrationSet[$wiki]['registration'] <
1254                    $migrationSet[$homeWiki]['registration']
1255                ) {
1256                    $homeWiki = $wiki;
1257                } elseif ( $migrationSet[$wiki]['registration'] ===
1258                    $migrationSet[$homeWiki]['registration']
1259                ) {
1260                    // Another tie? Screw it, pick one randomly.
1261                    $wikis = [ $wiki, $homeWiki ];
1262                    $homeWiki = $wikis[mt_rand( 0, 1 )];
1263                }
1264            }
1265        }
1266
1267        return $homeWiki;
1268    }
1269
1270    /**
1271     * Go through a list of migration data looking for those which
1272     * can be automatically migrated based on the available criteria.
1273     *
1274     * @param array[] $migrationSet
1275     * @param string[] $passwords Optional, pre-authenticated passwords.
1276     *     Should match an account which is known to be attached.
1277     * @return string[] Array of <wiki> => <authentication method>
1278     */
1279    public function prepareMigration( $migrationSet, $passwords = [] ) {
1280        // If the primary account has an email address set,
1281        // we can use it to match other accounts. If it doesn't,
1282        // we can't be sure that the other accounts with no mail
1283        // are the same person, so err on the side of caution.
1284        // For additional safety, we'll only let the mail check
1285        // propagate from a confirmed account
1286        $passingMail = [];
1287        if ( $this->mEmail != '' && $this->mEmailAuthenticated ) {
1288            $passingMail[$this->mEmail] = true;
1289        }
1290
1291        $passwordConfirmed = [];
1292        // If we've got an authenticated password to work with, we can
1293        // also assume their email addresses are useful for this purpose...
1294        if ( $passwords ) {
1295            foreach ( $migrationSet as $wiki => $local ) {
1296                if ( $local['email'] && $local['emailAuthenticated'] &&
1297                    !isset( $passingMail[$local['email']] )
1298                ) {
1299                    // Test passwords only once here as comparing hashes is very expensive
1300                    $passwordConfirmed[$wiki] = $this->matchHashes(
1301                        $passwords,
1302                        $this->getPasswordFromString( $local['password'], $local['id'] )
1303                    );
1304
1305                    if ( $passwordConfirmed[$wiki] ) {
1306                        $passingMail[$local['email']] = true;
1307                    }
1308                }
1309            }
1310        }
1311
1312        $attach = [];
1313        foreach ( $migrationSet as $wiki => $local ) {
1314            $localName = "$this->mName@$wiki";
1315            if ( $wiki == $this->mHomeWiki ) {
1316                // Primary account holder... duh
1317                $method = 'primary';
1318            } elseif ( $local['emailAuthenticated'] && isset( $passingMail[$local['email']] ) ) {
1319                // Same email address as the primary account, or the same email address as another
1320                // password confirmed account, means we know they could reset their password, so we
1321                // give them the account.
1322                // Authenticated email addresses only to prevent merges with malicious users
1323                $method = 'mail';
1324            } elseif (
1325                ( isset( $passwordConfirmed[$wiki] ) && $passwordConfirmed[$wiki] ) ||
1326                ( !isset( $passwordConfirmed[$wiki] ) &&
1327                    $this->matchHashes(
1328                        $passwords,
1329                        $this->getPasswordFromString( $local['password'], $local['id'] )
1330                    ) )
1331            ) {
1332                // Matches the pre-authenticated password, yay!
1333                $method = 'password';
1334            } else {
1335                // Can't automatically resolve this account.
1336                // If the password matches, it will be automigrated
1337                // at next login. If no match, user will have to input
1338                // the conflicting password or deal with the conflict.
1339                $this->logger->info( 'unresolvable {user}', [ 'user' => $localName ] );
1340                continue;
1341            }
1342            $this->logger->info( '$method {user}', [ 'user' => $localName ] );
1343            $attach[$wiki] = $method;
1344        }
1345
1346        return $attach;
1347    }
1348
1349    /**
1350     * Do a dry run -- pick a winning primary account and try to auto-merge
1351     * as many as possible, but don't perform any actions yet.
1352     *
1353     * @param string[] $passwords
1354     * @param string|false &$home set to false if no permission to do checks
1355     * @param array &$attached on success, list of wikis which will be auto-attached
1356     * @param array &$unattached on success, list of wikis which won't be auto-attached
1357     * @param array &$methods on success, associative array of each wiki's attachment method
1358     * @return Status
1359     */
1360    public function migrationDryRun( $passwords, &$home, &$attached, &$unattached, &$methods ) {
1361        // Because it messes with $this->mEmail and so on
1362        $this->checkWriteMode();
1363
1364        $home = false;
1365        $attached = [];
1366        $unattached = [];
1367
1368        // First, make sure we were given the current wiki's password.
1369        $self = $this->localUserData( WikiMap::getCurrentWikiId() );
1370        $selfPassword = $this->getPasswordFromString( $self['password'], $self['id'] );
1371        if ( !$this->matchHashes( $passwords, $selfPassword ) ) {
1372            $this->logger->info( 'dry run: failed self-password check' );
1373            return Status::newFatal( 'wrongpassword' );
1374        }
1375
1376        $migrationSet = $this->queryUnattached();
1377        if ( !$migrationSet ) {
1378            $this->logger->info( 'dry run: no accounts to merge, failed migration' );
1379            return Status::newFatal( 'centralauth-merge-no-accounts' );
1380        }
1381        $home = $this->chooseHomeWiki( $migrationSet );
1382        $local = $migrationSet[$home];
1383
1384        // And we need to match the home wiki before proceeding...
1385        $localPassword = $this->getPasswordFromString( $local['password'], $local['id'] );
1386        if ( $this->matchHashes( $passwords, $localPassword ) ) {
1387            $this->logger->info(
1388                'dry run: passed password match to home {home}',
1389                [ 'home' => $home ]
1390            );
1391        } else {
1392            $this->logger->info(
1393                'dry run: failed password match to home {home}',
1394                [ 'home' => $home ]
1395            );
1396            return Status::newFatal( 'centralauth-merge-home-password' );
1397        }
1398
1399        $this->mHomeWiki = $home;
1400        $this->mEmail = $local['email'];
1401        $this->mEmailAuthenticated = $local['emailAuthenticated'];
1402        $attach = $this->prepareMigration( $migrationSet, $passwords );
1403
1404        $all = array_keys( $migrationSet );
1405        $attached = array_keys( $attach );
1406        $unattached = array_diff( $all, $attached );
1407        $methods = $attach;
1408
1409        sort( $attached );
1410        sort( $unattached );
1411        ksort( $methods );
1412
1413        return Status::newGood();
1414    }
1415
1416    /**
1417     * Promote an unattached account to a
1418     * global one, using the provided homewiki
1419     *
1420     * @param string $wiki
1421     * @return Status
1422     */
1423    public function promoteToGlobal( $wiki ) {
1424        $unattached = $this->queryUnattached();
1425        if ( !isset( $unattached[$wiki] ) ) {
1426            return Status::newFatal( 'promote-not-on-wiki' );
1427        }
1428
1429        $info = $unattached[$wiki];
1430
1431        if ( $this->exists() ) {
1432            return Status::newFatal( 'promote-already-exists' );
1433        }
1434
1435        $ok = $this->storeGlobalData(
1436            $info['id'],
1437            $info['password'],
1438            $info['email'],
1439            $info['emailAuthenticated']
1440        );
1441        if ( !$ok ) {
1442            // Race condition?
1443            return Status::newFatal( 'promote-already-exists' );
1444        }
1445
1446        $this->attach( $wiki, 'primary' );
1447
1448        $this->recordAntiSpoof();
1449
1450        return Status::newGood();
1451    }
1452
1453    /**
1454     * Choose an email address to use from an array as obtained via self::queryUnattached.
1455     *
1456     * @param array[] $wikisToAttach
1457     */
1458    private function chooseEmail( array $wikisToAttach ) {
1459        $this->checkWriteMode();
1460
1461        if ( $this->mEmail ) {
1462            return;
1463        }
1464
1465        foreach ( $wikisToAttach as $attachWiki ) {
1466            if ( $attachWiki['email'] ) {
1467                $this->mEmail = $attachWiki['email'];
1468                $this->mEmailAuthenticated = $attachWiki['emailAuthenticated'];
1469                if ( $attachWiki['emailAuthenticated'] ) {
1470                    // If the email is authenticated, stop searching
1471                    return;
1472                }
1473            }
1474        }
1475    }
1476
1477    /**
1478     * Pick a winning primary account and try to auto-merge as many as possible.
1479     * @fixme add some locking or something
1480     *
1481     * @param string[] $passwords
1482     * @param bool $sendToRC
1483     * @param bool $safe Only migrate if all accounts can be merged
1484     * @param bool $checkHome Re-check the user's ownership of the home wiki
1485     * @return bool Whether full automatic migration completed successfully.
1486     */
1487    protected function attemptAutoMigration(
1488        $passwords = [], $sendToRC = true, $safe = false, $checkHome = false
1489    ) {
1490        $this->checkWriteMode();
1491        $migrationSet = $this->queryUnattached();
1492        $logger = $this->logger;
1493        if ( !$migrationSet ) {
1494            $logger->info( 'no accounts to merge, failed migration' );
1495            return false;
1496        }
1497
1498        if ( $this->mHomeWiki !== null ) {
1499            if ( !array_key_exists( $this->mHomeWiki, $migrationSet ) ) {
1500                $logger->info(
1501                    'Invalid home wiki specification \'{user}@{home}\'',
1502                    [ 'user' => $this->mName, 'home' => $this->mHomeWiki ]
1503                );
1504                return false;
1505            }
1506        } else {
1507            $this->mHomeWiki = $this->chooseHomeWiki( $migrationSet );
1508        }
1509
1510        $home = $migrationSet[$this->mHomeWiki];
1511
1512        // Check home wiki when the user is initiating this merge, just
1513        // like we did in migrationDryRun
1514        $homePassword = $this->getPasswordFromString( $home['password'], $home['id'] );
1515        if ( $checkHome && !$this->matchHashes( $passwords, $homePassword ) ) {
1516            $logger->info(
1517                'auto migrate: failed password match to home {home}',
1518                [ 'home' => $this->mHomeWiki ]
1519            );
1520            return false;
1521        }
1522
1523        $this->mEmail = $home['email'];
1524        $this->mEmailAuthenticated = $home['emailAuthenticated'];
1525
1526        // Pick all the local accounts matching the "primary" home account
1527        $attach = $this->prepareMigration( $migrationSet, $passwords );
1528
1529        if ( $safe && count( $attach ) !== count( $migrationSet ) ) {
1530            $logger->info(
1531                'Safe auto-migration for \'{user}\' failed',
1532                [ 'user' => $this->mName ]
1533            );
1534            return false;
1535        }
1536
1537        $wikisToAttach = array_intersect_key( $migrationSet, $attach );
1538
1539        // The home wiki might not have an email set, but maybe an other account has one?
1540        $this->chooseEmail( $wikisToAttach );
1541
1542        // storeGlobalData clears $this->mHomeWiki
1543        $homeWiki = $this->mHomeWiki;
1544        // Actually do the migration
1545        $ok = $this->storeGlobalData(
1546            $home['id'],
1547            $home['password'],
1548            $this->mEmail,
1549            $this->mEmailAuthenticated
1550        );
1551
1552        if ( !$ok ) {
1553            $logger->info(
1554                'attemptedAutoMigration for existing entry \'{user}\'',
1555                [ 'user' => $this->mName ]
1556            );
1557            return false;
1558        }
1559
1560        if ( count( $attach ) < count( $migrationSet ) ) {
1561            $logger->info(
1562                'Incomplete migration for \'{user}\'',
1563                [ 'user' => $this->mName ]
1564            );
1565        } else {
1566            if ( count( $migrationSet ) == 1 ) {
1567                $logger->info(
1568                    'Singleton migration for \'{user}\' on {home}',
1569                    [ 'user' => $this->mName, 'home' => $homeWiki ]
1570                );
1571            } else {
1572                $logger->info(
1573                    'Full automatic migration for \'{user}\'',
1574                    [ 'user' => $this->mName ]
1575                );
1576            }
1577        }
1578
1579        // Don't purge the cache 50 times.
1580        $this->startTransaction();
1581
1582        foreach ( $attach as $wiki => $method ) {
1583            $this->attach( $wiki, $method, $sendToRC );
1584        }
1585
1586        $this->endTransaction();
1587
1588        return count( $attach ) == count( $migrationSet );
1589    }
1590
1591    /**
1592     * Attempt to migrate any remaining unattached accounts by virtue of
1593     * the password check.
1594     *
1595     * @param string $password plaintext password to try matching
1596     * @param string[] &$migrated Array of wiki IDs for records which were
1597     *                  successfully migrated by this operation
1598     * @param string[] &$remaining Array of wiki IDs for records which are still
1599     *                   unattached after the operation
1600     * @return bool true if all accounts are migrated at the end
1601     */
1602    public function attemptPasswordMigration( $password, &$migrated = [], &$remaining = [] ) {
1603        $rows = $this->queryUnattached();
1604        $logger = $this->logger;
1605
1606        if ( count( $rows ) == 0 ) {
1607            $logger->info(
1608                'Already fully migrated user \'{user}\'',
1609                [ 'user' => $this->mName ]
1610            );
1611            return true;
1612        }
1613
1614        $migrated = [];
1615        $remaining = [];
1616
1617        // Don't invalidate the cache 50 times
1618        $this->startTransaction();
1619
1620        // Look for accounts we can match by password
1621        foreach ( $rows as $row ) {
1622            $wiki = $row['wiki'];
1623            if ( $this->matchHash( $password,
1624                $this->getPasswordFromString( $row['password'], $row['id'] ) )->isGood()
1625            ) {
1626                $logger->info(
1627                    'Attaching \'{user}\' on {wiki} by password',
1628                    [
1629                        'user' => $this->mName,
1630                        'wiki' => $wiki
1631                    ]
1632                );
1633                $this->attach( $wiki, 'password' );
1634                $migrated[] = $wiki;
1635            } else {
1636                $logger->info(
1637                    'No password match for \'{user}\' on {wiki}',
1638                    [
1639                        'user' => $this->mName,
1640                        'wiki' => $wiki
1641                    ]
1642                );
1643                $remaining[] = $wiki;
1644            }
1645        }
1646
1647        $this->endTransaction();
1648
1649        if ( count( $remaining ) == 0 ) {
1650            $logger->info(
1651                'Successful auto migration for \'{user}\'',
1652                [ 'user' => $this->mName ]
1653            );
1654            return true;
1655        }
1656
1657        $logger->info(
1658            'Incomplete migration for \'{user}\'',
1659            [ 'user' => $this->mName ]
1660        );
1661        return false;
1662    }
1663
1664    /**
1665     * @param string[] $list
1666     * @return array An associative array containing the valid and invalid entries.
1667     */
1668    protected static function validateList( $list ) {
1669        $unique = array_unique( $list );
1670        $wikiList = CentralAuthServices::getWikiListService()->getWikiList();
1671        $valid = array_intersect( $unique, $wikiList );
1672        $invalid = array_diff( $unique, $valid );
1673
1674        // Return valid and invalid entries
1675        return [ 'valid' => $valid, 'invalid' => $invalid ];
1676    }
1677
1678    /**
1679     * Unattach a list of local accounts from the global account
1680     * @param array $list List of wiki names
1681     * @return Status
1682     */
1683    public function adminUnattach( $list ) {
1684        $this->checkWriteMode();
1685        if ( !count( $list ) ) {
1686            return Status::newFatal( 'centralauth-admin-none-selected' );
1687        }
1688
1689        $status = new Status;
1690
1691        // Get valid and invalid wikis
1692        [ 'valid' => $valid, 'invalid' => $invalid ] = $this->validateList( $list );
1693
1694        // Handle invalid wikis
1695        foreach ( $invalid as $wikiName ) {
1696            $status->error( 'centralauth-invalid-wiki', $wikiName );
1697            $status->failCount++;
1698        }
1699
1700        // Proceed with valid wikis
1701        $databaseManager = CentralAuthServices::getDatabaseManager();
1702        $dbcw = $databaseManager->getCentralPrimaryDB();
1703        $password = $this->getPassword();
1704
1705        foreach ( $valid as $wikiName ) {
1706            // Delete the user from the central localuser table
1707            $dbcw->newDeleteQueryBuilder()
1708                ->deleteFrom( 'localuser' )
1709                ->where( [
1710                    'lu_name' => $this->mName,
1711                    'lu_wiki' => $wikiName
1712                ] )
1713                ->caller( __METHOD__ )
1714                ->execute();
1715
1716            if ( !$dbcw->affectedRows() ) {
1717                $wiki = WikiMap::getWiki( $wikiName );
1718                $status->error( 'centralauth-admin-already-unmerged', $wiki->getDisplayName() );
1719                $status->failCount++;
1720                continue;
1721            }
1722
1723            // Touch the local user row, update the password
1724            $dblw = $databaseManager->getLocalDB( DB_PRIMARY, $wikiName );
1725            $dblw->newUpdateQueryBuilder()
1726                ->update( 'user' )
1727                ->set( [
1728                    'user_touched' => $dblw->timestamp(),
1729                    'user_password' => $password
1730                ] )
1731                ->where( [ 'user_name' => $this->mName ] )
1732                ->caller( __METHOD__ )
1733                ->execute();
1734
1735            $userRow = $dblw->newSelectQueryBuilder()
1736                ->select( [ 'user_id', 'user_editcount' ] )
1737                ->from( 'user' )
1738                ->where( [ 'user_name' => $this->mName ] )
1739                ->caller( __METHOD__ )
1740                ->fetchRow();
1741
1742            // Remove the edits from the global edit count
1743            $counter = CentralAuthServices::getEditCounter();
1744            $counter->increment( $this, -(int)$userRow->user_editcount );
1745            $this->clearLocalUserCache( $wikiName, $userRow->user_id );
1746            $status->successCount++;
1747        }
1748
1749        if ( in_array( WikiMap::getCurrentWikiId(), $valid ) ) {
1750            $this->resetState();
1751        }
1752
1753        $this->invalidateCache();
1754        return $status;
1755    }
1756
1757    /**
1758     * Queue a job to unattach this user from a named wiki.
1759     *
1760     * @param string $wikiId
1761     */
1762    protected function queueAdminUnattachJob( $wikiId ) {
1763        $services = MediaWikiServices::getInstance();
1764
1765        $job = $services->getJobFactory()->newJob(
1766            'CentralAuthUnattachUserJob',
1767            [
1768                'username' => $this->getName(),
1769                'wiki' => $wikiId,
1770            ]
1771        );
1772
1773        $services->getJobQueueGroupFactory()->makeJobQueueGroup( $wikiId )->lazyPush( $job );
1774    }
1775
1776    /**
1777     * Delete a global account and log what happened
1778     *
1779     * @param string $reason Reason for the deletion
1780     * @param UserIdentity $deleter User doing the deletion
1781     * @return Status
1782     */
1783    public function adminDelete( $reason, UserIdentity $deleter ) {
1784        $this->checkWriteMode();
1785
1786        $this->logger->info(
1787            'Deleting global account for user \'{user}\'',
1788            [ 'user' => $this->mName ]
1789        );
1790        $databaseManager = CentralAuthServices::getDatabaseManager();
1791        $centralDB = $databaseManager->getCentralPrimaryDB();
1792
1793        # Synchronise passwords
1794        $password = $this->getPassword();
1795        $localUserRes = $centralDB->newSelectQueryBuilder()
1796            ->select( 'lu_wiki' )
1797            ->from( 'localuser' )
1798            ->where( [ 'lu_name' => $this->mName ] )
1799            ->caller( __METHOD__ )
1800            ->fetchFieldValues();
1801        foreach ( $localUserRes as $wiki ) {
1802            $this->logger->debug( __METHOD__ . ": Fixing password on $wiki\n" );
1803            $localDB = $databaseManager->getLocalDB( DB_PRIMARY, $wiki );
1804            $localDB->newUpdateQueryBuilder()
1805                ->update( 'user' )
1806                ->set( [ 'user_password' => $password ] )
1807                ->where( [ 'user_name' => $this->mName ] )
1808                ->caller( __METHOD__ )
1809                ->execute();
1810
1811            $id = $localDB->newSelectQueryBuilder()
1812                ->select( 'user_id' )
1813                ->from( 'user' )
1814                ->where( [ 'user_name' => $this->mName ] )
1815                ->caller( __METHOD__ )
1816                ->fetchField();
1817
1818            $this->clearLocalUserCache( $wiki, $id );
1819        }
1820        $wasSuppressed = $this->isSuppressed();
1821
1822        $centralDB->startAtomic( __METHOD__ );
1823        # Delete and lock the globaluser row
1824        $centralDB->newDeleteQueryBuilder()
1825            ->deleteFrom( 'globaluser' )
1826            ->where( [ 'gu_name' => $this->mName ] )
1827            ->caller( __METHOD__ )
1828            ->execute();
1829        if ( !$centralDB->affectedRows() ) {
1830            $centralDB->endAtomic( __METHOD__ );
1831            return Status::newFatal( 'centralauth-admin-delete-nonexistent', $this->mName );
1832        }
1833        # Delete all global user groups for the user
1834        $centralDB->newDeleteQueryBuilder()
1835            ->deleteFrom( 'global_user_groups' )
1836            ->where( [ 'gug_user' => $this->getId() ] )
1837            ->caller( __METHOD__ )
1838            ->execute();
1839        # Delete the localuser rows
1840        $centralDB->newDeleteQueryBuilder()
1841            ->deleteFrom( 'localuser' )
1842            ->where( [ 'lu_name' => $this->mName ] )
1843            ->caller( __METHOD__ )
1844            ->execute();
1845        $centralDB->endAtomic( __METHOD__ );
1846
1847        if ( $wasSuppressed ) {
1848            // "suppress/delete" is taken by core, so use "cadelete"
1849            $this->logAction(
1850                'cadelete',
1851                $deleter,
1852                $reason,
1853                [],
1854                true
1855            );
1856        } else {
1857            $this->logAction(
1858                'delete',
1859                $deleter,
1860                $reason,
1861                [],
1862            );
1863        }
1864        $this->invalidateCache();
1865
1866        return Status::newGood();
1867    }
1868
1869    /**
1870     * Lock a global account
1871     *
1872     * @return Status
1873     */
1874    public function adminLock() {
1875        $this->checkWriteMode();
1876
1877        $services = MediaWikiServices::getInstance();
1878        if ( $services->getTempUserConfig()->isTempName( $this->mName ) ) {
1879            return Status::newFatal( 'centralauth-admin-cannot-lock-temporary-account' );
1880        }
1881
1882        $dbw = CentralAuthServices::getDatabaseManager( $services )->getCentralPrimaryDB();
1883        $dbw->newUpdateQueryBuilder()
1884            ->update( 'globaluser' )
1885            ->set( [ 'gu_locked' => 1 ] )
1886            ->where( [
1887                'gu_name' => $this->mName,
1888                // Necessary so that the call to IDatabase::affectedRows will return 0 if no update was made.
1889                'gu_locked' => 0,
1890            ] )
1891            ->caller( __METHOD__ )
1892            ->execute();
1893        if ( !$dbw->affectedRows() ) {
1894            return Status::newFatal( 'centralauth-state-mismatch' );
1895        }
1896
1897        $this->invalidateCache();
1898        $user = User::newFromName( $this->mName );
1899        SessionManager::singleton()->invalidateSessionsForUser( $user );
1900
1901        // T375870: Track rate of locks for monitoring of anti-abuse tool usage.
1902        $services->getStatsFactory()->withComponent( 'CentralAuth' )
1903            ->getCounter( 'account_lock' )
1904            ->increment();
1905
1906        return Status::newGood();
1907    }
1908
1909    /**
1910     * Unlock a global account
1911     *
1912     * @return Status
1913     */
1914    public function adminUnlock() {
1915        $this->checkWriteMode();
1916        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
1917        $dbw->newUpdateQueryBuilder()
1918            ->update( 'globaluser' )
1919            ->set( [ 'gu_locked' => 0 ] )
1920            ->where( [
1921                'gu_name' => $this->mName,
1922                // Necessary so that the call to IDatabase::affectedRows will return 0 if no update was made.
1923                'gu_locked' => 1,
1924            ] )
1925            ->caller( __METHOD__ )
1926            ->execute();
1927        if ( !$dbw->affectedRows() ) {
1928            return Status::newFatal( 'centralauth-state-mismatch' );
1929        }
1930
1931        $this->invalidateCache();
1932
1933        return Status::newGood();
1934    }
1935
1936    /**
1937     * Change account hiding level.
1938     *
1939     * @param int $level CentralAuthUser::HIDDEN_LEVEL_* class constant
1940     * @return Status
1941     */
1942    public function adminSetHidden( int $level ) {
1943        $this->checkWriteMode();
1944
1945        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
1946        $dbw->newUpdateQueryBuilder()
1947            ->update( 'globaluser' )
1948            ->set( [ 'gu_hidden_level' => $level ] )
1949            ->where( [
1950                'gu_name' => $this->mName,
1951                // Necessary so that the call to IDatabase::affectedRows will return 0 if no update was made.
1952                $dbw->expr( 'gu_hidden_level', '!=', $level ),
1953            ] )
1954            ->caller( __METHOD__ )
1955            ->execute();
1956        if ( !$dbw->affectedRows() ) {
1957            return Status::newFatal( 'centralauth-state-mismatch' );
1958        }
1959
1960        $this->invalidateCache();
1961
1962        return Status::newGood();
1963    }
1964
1965    /**
1966     * Set locking and hiding settings for a Global User and log the changes made.
1967     *
1968     * @param bool|null $setLocked
1969     *  true = lock
1970     *  false = unlock
1971     *  null = don't change
1972     * @param int|null $setHidden
1973     *  hidden level, one of the HIDDEN_ constants
1974     *  null = don't change
1975     * @param string $reason reason for hiding
1976     * @param IContextSource $context
1977     * @param bool $markAsBot Whether to mark the log entry in RC with the bot flag
1978     * @return Status
1979     */
1980    public function adminLockHide(
1981        ?bool $setLocked, ?int $setHidden, string $reason, IContextSource $context, bool $markAsBot = false
1982    ) {
1983        $isLocked = $this->isLocked();
1984        $oldHiddenLevel = $this->getHiddenLevelInt();
1985        $lockStatus = null;
1986        $added = [];
1987        $removed = [];
1988        $user = $context->getUser();
1989
1990        if ( $setLocked === null ) {
1991            $setLocked = $isLocked;
1992        } elseif ( !$context->getAuthority()->isAllowed( 'centralauth-lock' ) ) {
1993            return Status::newFatal( 'centralauth-admin-not-authorized' );
1994        }
1995
1996        if ( $setHidden === null ) {
1997            $setHidden = $oldHiddenLevel;
1998        } elseif (
1999            $setHidden !== self::HIDDEN_LEVEL_NONE
2000            || $oldHiddenLevel !== self::HIDDEN_LEVEL_NONE
2001        ) {
2002            if ( !$context->getAuthority()->isAllowed( 'centralauth-suppress' ) ) {
2003                return Status::newFatal( 'centralauth-admin-not-authorized' );
2004            } elseif ( $this->getGlobalEditCount() > self::HIDE_CONTRIBLIMIT ) {
2005                return Status::newFatal(
2006                    $context->msg( 'centralauth-admin-too-many-edits', $this->mName )
2007                        ->numParams( self::HIDE_CONTRIBLIMIT )
2008                );
2009            }
2010        }
2011
2012        $returnStatus = Status::newGood();
2013
2014        $hiddenLevels = [
2015            self::HIDDEN_LEVEL_NONE,
2016            self::HIDDEN_LEVEL_LISTS,
2017            self::HIDDEN_LEVEL_SUPPRESSED,
2018        ];
2019
2020        // if not a known value, default to none
2021        if ( !in_array( $setHidden, $hiddenLevels ) ) {
2022            $setHidden = self::HIDDEN_LEVEL_NONE;
2023        }
2024
2025        if ( !$isLocked && $setLocked ) {
2026            $lockStatus = $this->adminLock();
2027            $added[] = 'locked';
2028        } elseif ( $isLocked && !$setLocked ) {
2029            $lockStatus = $this->adminUnlock();
2030            $removed[] = 'locked';
2031        }
2032
2033        // Return early if the locked state of the user was requested to be changed, but this operation failed as we
2034        // do not want to perform any status change for the global account if any of the changes fail (T380421).
2035        if ( $lockStatus && !$lockStatus->isGood() ) {
2036            return $lockStatus;
2037        }
2038
2039        if ( $oldHiddenLevel != $setHidden ) {
2040            $hideStatus = $this->adminSetHidden( $setHidden );
2041
2042            // Return early if the locked state of the user was requested to be changed, but this operation failed
2043            // as we do not want to perform any status change for the global account if any of the changes fail
2044            // (T380421).
2045            if ( !$hideStatus->isGood() ) {
2046                // Undo any changes to the locked status of the global account if the suppression failed as we won't
2047                // be creating a log entry for the change.
2048                if ( !$isLocked && $setLocked ) {
2049                    $this->adminUnlock();
2050                } elseif ( $isLocked && !$setLocked ) {
2051                    $this->adminLock();
2052                }
2053
2054                return $hideStatus;
2055            }
2056
2057            switch ( $setHidden ) {
2058                case self::HIDDEN_LEVEL_NONE:
2059                    $removed[] = $oldHiddenLevel === self::HIDDEN_LEVEL_SUPPRESSED ?
2060                        'oversighted' :
2061                        'hidden';
2062                    break;
2063                case self::HIDDEN_LEVEL_LISTS:
2064                    $added[] = 'hidden';
2065                    if ( $oldHiddenLevel === self::HIDDEN_LEVEL_SUPPRESSED ) {
2066                        $removed[] = 'oversighted';
2067                    }
2068                    break;
2069                case self::HIDDEN_LEVEL_SUPPRESSED:
2070                    $added[] = 'oversighted';
2071                    if ( $oldHiddenLevel === self::HIDDEN_LEVEL_LISTS ) {
2072                        $removed[] = 'hidden';
2073                    }
2074                    break;
2075            }
2076
2077            $userName = $user->getName();
2078            if ( $setHidden === self::HIDDEN_LEVEL_SUPPRESSED ) {
2079                $this->suppress( $userName, $reason );
2080            } elseif ( $oldHiddenLevel === self::HIDDEN_LEVEL_SUPPRESSED ) {
2081                $this->unsuppress( $userName, $reason );
2082            }
2083        }
2084
2085        // Setup Status object to return all of the information for logging
2086        if ( count( $added ) || count( $removed ) ) {
2087            $returnStatus->successCount = count( $added ) + count( $removed );
2088            $this->logAction(
2089                'setstatus',
2090                $context->getUser(),
2091                $reason,
2092                [ 'added' => $added, 'removed' => $removed ],
2093                $setHidden !== self::HIDDEN_LEVEL_NONE,
2094                $markAsBot
2095            );
2096        }
2097
2098        return $returnStatus;
2099    }
2100
2101    /**
2102     * Suppresses all user accounts in all wikis.
2103     * @param string $name
2104     * @param string $reason
2105     */
2106    public function suppress( $name, $reason ) {
2107        $this->doCrosswikiSuppression( true, $name, $reason );
2108    }
2109
2110    /**
2111     * Unsuppresses all user accounts in all wikis.
2112     * @param string $name
2113     * @param string $reason
2114     */
2115    public function unsuppress( $name, $reason ) {
2116        $this->doCrosswikiSuppression( false, $name, $reason );
2117    }
2118
2119    /**
2120     * @param bool $suppress
2121     * @param string $by
2122     * @param string $reason
2123     */
2124    protected function doCrosswikiSuppression( $suppress, $by, $reason ) {
2125        global $wgCentralAuthWikisPerSuppressJob;
2126        $this->loadAttached();
2127        if ( count( $this->mAttachedArray ) <= $wgCentralAuthWikisPerSuppressJob ) {
2128            foreach ( $this->mAttachedArray as $wiki ) {
2129                $this->doLocalSuppression( $suppress, $wiki, $by, $reason );
2130            }
2131        } else {
2132            $jobParams = [
2133                'username' => $this->getName(),
2134                'suppress' => $suppress,
2135                'by' => $by,
2136                'reason' => $reason,
2137            ];
2138            $jobs = [];
2139
2140            $services = MediaWikiServices::getInstance();
2141            $jobFactory = $services->getJobFactory();
2142
2143            $chunks = array_chunk( $this->mAttachedArray, $wgCentralAuthWikisPerSuppressJob );
2144            foreach ( $chunks as $wikis ) {
2145                $jobParams['wikis'] = $wikis;
2146                $jobs[] = $jobFactory->newJob(
2147                    'crosswikiSuppressUser',
2148                    $jobParams
2149                );
2150            }
2151
2152            // Push the jobs right before COMMIT (which is likely to succeed).
2153            // If the job push fails, then the transaction will roll back.
2154            $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
2155            $dbw->onTransactionPreCommitOrIdle( static function () use ( $services, $jobs ) {
2156                $services->getJobQueueGroup()->push( $jobs );
2157            }, __METHOD__ );
2158        }
2159    }
2160
2161    /**
2162     * Suppresses a local account of a user.
2163     *
2164     * @param bool $suppress
2165     * @param string $wiki
2166     * @param string $by
2167     * @param string $reason
2168     * @return array|null Error array on failure
2169     */
2170    public function doLocalSuppression( $suppress, $wiki, $by, $reason ) {
2171        global $wgConf, $wgCentralAuthGlobalBlockInterwikiPrefix;
2172
2173        $databaseManager = CentralAuthServices::getDatabaseManager();
2174        $dbw = $databaseManager->getLocalDB( DB_PRIMARY, $wiki );
2175        $data = $this->localUserData( $wiki );
2176
2177        $wikiId = $wiki === WikiMap::getCurrentWikiId() ? WikiAwareEntity::LOCAL : $wiki;
2178
2179        $blockStore = MediaWikiServices::getInstance()
2180            ->getDatabaseBlockStoreFactory()
2181            ->getDatabaseBlockStore( $wikiId );
2182
2183        if ( $suppress ) {
2184            [ , $lang ] = $wgConf->siteFromDB( $wiki );
2185            if ( !MediaWikiServices::getInstance()->getLanguageNameUtils()->isSupportedLanguage( $lang ?? '' ) ) {
2186                $lang = 'en';
2187            }
2188            $blockReason = wfMessage( 'centralauth-admin-suppressreason', $by, $reason )
2189                ->inLanguage( $lang )->text();
2190
2191            // TODO DatabaseBlock is not @newable
2192            $block = new DatabaseBlock( [
2193                'address' => UserIdentityValue::newRegistered( $data['id'], $this->mName, $wikiId ),
2194                'wiki' => $wikiId,
2195                'reason' => $blockReason,
2196                'timestamp' => wfTimestampNow(),
2197                'expiry' => $dbw->getInfinity(),
2198                'createAccount' => true,
2199                'enableAutoblock' => true,
2200                'hideName' => true,
2201                'blockEmail' => true,
2202                'by' => UserIdentityValue::newExternal(
2203                    $wgCentralAuthGlobalBlockInterwikiPrefix, $by, $wikiId
2204                )
2205            ] );
2206
2207            # On normal block, BlockIp hook would be run here, but doing
2208            # that from CentralAuth doesn't seem a good idea...
2209
2210            if ( !$blockStore->insertBlock( $block ) ) {
2211                return [ 'ipb_already_blocked' ];
2212            }
2213            # Ditto for BlockIpComplete hook.
2214
2215            RevisionDeleteUser::suppressUserName( $this->mName, $data['id'], $dbw );
2216
2217            # Locally log to suppress ?
2218        } else {
2219            $affected = $blockStore->deleteBlocksMatchingConds( [
2220                'bt_user' => $data['id'],
2221                // Our blocks don't have a user associated
2222                'bl_by' => null,
2223                'bl_deleted' => true,
2224            ] );
2225
2226            // Unsuppress only if unblocked
2227            if ( $affected ) {
2228                RevisionDeleteUser::unsuppressUserName( $this->mName, $data['id'], $dbw );
2229            }
2230        }
2231        return null;
2232    }
2233
2234    /**
2235     * Add a local account record for the given wiki to the central database.
2236     * @param string $wikiID
2237     * @param string $method Migration method. Will be displayed on Special:CentralAuth. One of:
2238     *   - primary: This account existed before central accounts were introduced, and got
2239     *     picked as the central account owner. (See {@link CentralAuthUser::chooseHomeWiki()}.)
2240     *   - new: Created during signup (ie. the user registered when central accounts were already
2241     *     enabled, and this is their first account).
2242     *   - empty: Not used anymore - probably accounts with zero edits that got merged into the
2243     *     global account by the same name, regardless of email etc?
2244     *   - mail: Merged because it had the same email as the central account, or a connected
2245     *     local account.
2246     *   - password: Merged (on login or via Special:MergeAccount) because it had the same
2247     *     password as the central account. (Or in some historical edge cases because it had no
2248     *     password - T63876.)
2249     *   - admin: Merged by an admin.
2250     *   - login: Autocreated when the central user visited a wiki with no local account.
2251     *   See also {@link \MediaWiki\Extension\CentralAuth\Special\SpecialCentralAuth::formatMergeMethod()}.
2252     * @param bool $sendToRC Whether to send to the IRC recent changes feed
2253     * @param string|int $ts MediaWiki timestamp or 0 for current time
2254     *
2255     * Prerequisites:
2256     * - completed migration state
2257     */
2258    public function attach( $wikiID, $method = 'new', $sendToRC = true, $ts = 0 ) {
2259        global $wgCentralAuthRC;
2260
2261        $this->checkWriteMode();
2262
2263        $dbcw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
2264        $dbcw->newInsertQueryBuilder()
2265            ->insertInto( 'localuser' )
2266            ->ignore()
2267            ->row( [
2268                'lu_wiki'               => $wikiID,
2269                'lu_name'               => $this->mName,
2270                'lu_attached_timestamp' => $dbcw->timestamp( $ts ),
2271                'lu_attached_method'    => $method,
2272                'lu_local_id'           => $this->getLocalId( $wikiID ),
2273                'lu_global_id'          => $this->getId()
2274            ] )
2275            ->caller( __METHOD__ )
2276            ->execute();
2277        $success = $dbcw->affectedRows() === 1;
2278
2279        if ( $wikiID === WikiMap::getCurrentWikiId() ) {
2280            $this->resetState();
2281        }
2282
2283        $this->invalidateCache();
2284
2285        if ( !$success ) {
2286            $this->logger->info(
2287                'Race condition? Already attached {user}@{wiki}, just tried by \'{method}\'',
2288                [ 'user' => $this->mName, 'wiki' => $wikiID, 'method' => $method ]
2289            );
2290            return;
2291        }
2292
2293        $this->logger->info(
2294            'Attaching local user {user}@{wiki} by \'{method}\'',
2295            [ 'user' => $this->mName, 'wiki' => $wikiID, 'method' => $method ]
2296        );
2297
2298        $this->addLocalEdits( $wikiID );
2299
2300        if ( $sendToRC ) {
2301            $userpage = Title::makeTitleSafe( NS_USER, $this->mName );
2302
2303            foreach ( $wgCentralAuthRC as $rc ) {
2304                $engine = RCFeed::factory( $rc );
2305                if ( !( $engine instanceof FormattedRCFeed ) ) {
2306                    throw new RuntimeException(
2307                        'wgCentralAuthRC only supports feeds that use FormattedRCFeed, got '
2308                        . get_class( $engine ) . ' instead'
2309                    );
2310                }
2311
2312                /** @var CARCFeedFormatter $formatter */
2313                $formatter = new $rc['formatter']();
2314
2315                $engine->send( $rc, $formatter->getLine( $userpage, $wikiID ) );
2316            }
2317        }
2318    }
2319
2320    /**
2321     * Add edits from a wiki to the global edit count
2322     *
2323     * @param string $wikiID
2324     */
2325    protected function addLocalEdits( $wikiID ) {
2326        $dblw = CentralAuthServices::getDatabaseManager()->getLocalDB( DB_PRIMARY, $wikiID );
2327        $editCount = $dblw->newSelectQueryBuilder()
2328            ->select( 'user_editcount' )
2329            ->from( 'user' )
2330            ->where( [ 'user_name' => $this->mName ] )
2331            ->caller( __METHOD__ )
2332            ->fetchField();
2333        $counter = CentralAuthServices::getEditCounter();
2334        $counter->increment( $this, $editCount );
2335    }
2336
2337    /**
2338     * If the user provides the correct password, would we let them log in?
2339     * This encompasses checks on missing and locked accounts, at present.
2340     * @return bool|string true if login available, or const authenticate status
2341     */
2342    public function canAuthenticate() {
2343        if ( !$this->getId() ) {
2344            $this->logger->info(
2345                "authentication for '{user}' failed due to missing account",
2346                [ 'user' => $this->mName ]
2347            );
2348            return self::AUTHENTICATE_NO_USER;
2349        }
2350
2351        // If the global account has been locked, we don't want to spam
2352        // other wikis with local account creations.
2353        if ( $this->isLocked() ) {
2354            return self::AUTHENTICATE_LOCKED;
2355        }
2356
2357        // Don't allow users to autocreate if they are oversighted.
2358        // If they do, their name will appear on local user list
2359        // (and since it contains private info, its unacceptable).
2360        if ( $this->isSuppressed() ) {
2361            // Avoid unnecessary database connections by only loading the user
2362            // details if the account is suppressed, since that's a very small minority
2363            // of login attempts for non-locked users.
2364            $userIdentity = MediaWikiServices::getInstance()->getUserIdentityLookup()
2365                ->getUserIdentityByName( $this->getName() );
2366            if ( !$userIdentity || !$userIdentity->isRegistered() ) {
2367                return self::AUTHENTICATE_LOCKED;
2368            }
2369        }
2370
2371        return true;
2372    }
2373
2374    /**
2375     * Attempt to authenticate the global user account with the given password
2376     * @param string $password
2377     * @return string[] status represented by const(s) AUTHENTICATE_LOCKED,
2378     *  AUTHENTICATE_NO_USER, AUTHENTICATE_BAD_PASSWORD
2379     *  and AUTHENTICATE_OK
2380     */
2381    public function authenticate( $password ) {
2382        $canAuthenticate = $this->canAuthenticate();
2383        if (
2384            $canAuthenticate !== true &&
2385            $canAuthenticate !== self::AUTHENTICATE_LOCKED
2386        ) {
2387            return [ $canAuthenticate ];
2388        }
2389
2390        $passwordMatchStatus = $this->matchHash( $password, $this->getPasswordObject() );
2391
2392        if ( $canAuthenticate === true ) {
2393            if ( $passwordMatchStatus->isGood() ) {
2394                $this->logger->info( "authentication for '{user}' succeeded", [ 'user' => $this->mName ] );
2395
2396                $config = RequestContext::getMain()->getConfig();
2397                $passwordFactory = new PasswordFactory(
2398                    $config->get( MainConfigNames::PasswordConfig ),
2399                    $config->get( MainConfigNames::PasswordDefault )
2400                );
2401
2402                if ( $passwordFactory->needsUpdate( $passwordMatchStatus->getValue() ) ) {
2403                    DeferredUpdates::addCallableUpdate( function () use ( $password ) {
2404                        if ( CentralAuthServices::getDatabaseManager()->isReadOnly() ) {
2405                            return;
2406                        }
2407
2408                        $centralUser = CentralAuthUser::newPrimaryInstanceFromId( $this->getId() );
2409                        if ( $centralUser ) {
2410                            // Don't bother resetting the auth token for a hash
2411                            // upgrade. It's not really a password *change*, and
2412                            // since this is being done post-send it'll cause the
2413                            // user to be logged out when they just tried to log in
2414                            // since it can't update the just-sent session cookies.
2415                            $centralUser->setPassword( $password, false );
2416                            $centralUser->saveSettings();
2417                        }
2418                    } );
2419                }
2420
2421                return [ self::AUTHENTICATE_OK ];
2422            } else {
2423                $this->logger->info( "authentication for '{user}' failed, bad pass", [ 'user' => $this->mName ] );
2424                return [ self::AUTHENTICATE_BAD_PASSWORD ];
2425            }
2426        } else {
2427            if ( $passwordMatchStatus->isGood() ) {
2428                $this->logger->info(
2429                    "authentication for '{user}' failed, correct pass but locked",
2430                    [ 'user' => $this->mName ]
2431                );
2432                return [ self::AUTHENTICATE_LOCKED ];
2433            } else {
2434                $this->logger->info(
2435                    "authentication for '{user}' failed, locked with wrong password",
2436                    [ 'user' => $this->mName ]
2437                );
2438                return [ self::AUTHENTICATE_BAD_PASSWORD, self::AUTHENTICATE_LOCKED ];
2439            }
2440        }
2441    }
2442
2443    /**
2444     * Attempt to authenticate the global user account with the given global authtoken
2445     * @param string $token
2446     * @return string status, one of: AUTHENTICATE_LOCKED,
2447     *  AUTHENTICATE_NO_USER, AUTHENTICATE_BAD_TOKEN
2448     *  and AUTHENTICATE_OK
2449     */
2450    public function authenticateWithToken( $token ) {
2451        $canAuthenticate = $this->canAuthenticate();
2452        if ( $canAuthenticate !== true ) {
2453            return $canAuthenticate;
2454        }
2455
2456        return $this->validateAuthToken( $token ) ? self::AUTHENTICATE_OK : self::AUTHENTICATE_BAD_TOKEN;
2457    }
2458
2459    /**
2460     * @param string $plaintext User-provided password plaintext.
2461     * @param Password $password Password to check against
2462     *
2463     * @return Status
2464     */
2465    protected function matchHash( $plaintext, Password $password ) {
2466        $matched = false;
2467
2468        if ( $password->verify( $plaintext ) ) {
2469            $matched = true;
2470        } elseif ( !( $password instanceof AbstractPbkdf2Password ) && function_exists( 'iconv' ) ) {
2471            // Some wikis were converted from ISO 8859-1 to UTF-8;
2472            // retained hashes may contain non-latin chars.
2473            AtEase::suppressWarnings();
2474            $latin1 = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $plaintext );
2475            AtEase::restoreWarnings();
2476            if ( $latin1 !== false && $password->verify( $latin1 ) ) {
2477                $matched = true;
2478            }
2479        }
2480
2481        if ( $matched ) {
2482            return Status::newGood( $password );
2483        } else {
2484            return Status::newFatal( 'bad' );
2485        }
2486    }
2487
2488    /**
2489     * @param string[] $passwords
2490     * @param Password $password Password to check against
2491     *
2492     * @return bool
2493     */
2494    protected function matchHashes( array $passwords, Password $password ) {
2495        foreach ( $passwords as $plaintext ) {
2496            if ( $this->matchHash( $plaintext, $password )->isGood() ) {
2497                return true;
2498            }
2499        }
2500
2501        return false;
2502    }
2503
2504    /**
2505     * @param string $encrypted Fully salted and hashed database crypto text from db.
2506     * @param string $salt The hash "salt", eg a local id for migrated passwords.
2507     *
2508     * @return Password
2509     * @throws PasswordError
2510     */
2511    private function getPasswordFromString( $encrypted, $salt ) {
2512        $config = RequestContext::getMain()->getConfig();
2513        $passwordFactory = new PasswordFactory(
2514            $config->get( MainConfigNames::PasswordConfig ),
2515            $config->get( MainConfigNames::PasswordDefault )
2516        );
2517
2518        if ( preg_match( '/^[0-9a-f]{32}$/', $encrypted ) ) {
2519            $encrypted = ":B:{$salt}:{$encrypted}";
2520        }
2521
2522        return $passwordFactory->newFromCiphertext( $encrypted );
2523    }
2524
2525    /**
2526     * Fetch a list of databases where this account name is registered,
2527     * but not yet attached to the global account. It would be used for
2528     * an alert or management system to show which accounts have still
2529     * to be dealt with.
2530     *
2531     * @return string[] of database name strings
2532     */
2533    public function listUnattached() {
2534        if ( IPUtils::isIPAddress( $this->mName ) ) {
2535            // don't bother with primary database queries
2536            return [];
2537        }
2538
2539        return $this->doListUnattached();
2540    }
2541
2542    /**
2543     * @return string[]
2544     */
2545    private function doListUnattached() {
2546        $databaseManager = CentralAuthServices::getDatabaseManager();
2547        // Make sure lazy-loading in listUnattached() works, as we
2548        // may need to *switch* to using the primary DB for this query
2549        $db = $databaseManager->centralLBHasRecentPrimaryChanges()
2550            ? $databaseManager->getCentralPrimaryDB()
2551            : $this->getSafeReadDB();
2552
2553        $result = $db->newSelectQueryBuilder()
2554            ->select( 'ln_wiki' )
2555            ->from( 'localnames' )
2556            ->leftJoin( 'localuser', null, [ 'ln_wiki=lu_wiki', 'ln_name=lu_name' ] )
2557            ->where( [ 'ln_name' => $this->mName, 'lu_name' => null ] )
2558            ->caller( __METHOD__ )
2559            ->fetchFieldValues();
2560
2561        $wikis = [];
2562        foreach ( $result as $wiki ) {
2563            if ( !WikiMap::getWiki( $wiki ) ) {
2564                $this->logger->warning( __METHOD__ . ': invalid wiki in localnames: ' . $wiki );
2565                continue;
2566            }
2567
2568            $wikis[] = $wiki;
2569        }
2570
2571        return $wikis;
2572    }
2573
2574    /**
2575     * @param string $wikiID
2576     */
2577    public function addLocalName( $wikiID ) {
2578        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
2579        $dbw->newInsertQueryBuilder()
2580            ->insertInto( 'localnames' )
2581            ->ignore()
2582            ->row( [
2583                'ln_wiki' => $wikiID,
2584                'ln_name' => $this->mName
2585            ] )
2586            ->caller( __METHOD__ )
2587            ->execute();
2588    }
2589
2590    /**
2591     * @param string $wikiID
2592     */
2593    public function removeLocalName( $wikiID ) {
2594        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
2595        $dbw->newDeleteQueryBuilder()
2596            ->deleteFrom( 'localnames' )
2597            ->where( [
2598                'ln_wiki' => $wikiID,
2599                'ln_name' => $this->mName
2600            ] )
2601            ->caller( __METHOD__ )
2602            ->execute();
2603    }
2604
2605    /**
2606     * Updates the localname table after a rename
2607     * @param string $wikiID
2608     * @param string $newname
2609     */
2610    public function updateLocalName( $wikiID, $newname ) {
2611        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
2612        $dbw->newUpdateQueryBuilder()
2613            ->update( 'localnames' )
2614            ->set( [ 'ln_name' => $newname ] )
2615            ->where( [ 'ln_wiki' => $wikiID, 'ln_name' => $this->mName ] )
2616            ->caller( __METHOD__ )
2617            ->execute();
2618    }
2619
2620    /**
2621     * Load the list of databases where this account has been successfully
2622     * attached
2623     */
2624    public function loadAttached() {
2625        if ( $this->mAttachedArray !== null ) {
2626            // Already loaded
2627            return;
2628        }
2629
2630        if ( $this->mAttachedList !== null && $this->mAttachedList !== '' ) {
2631            // We have a list already, probably from the cache.
2632            $this->mAttachedArray = explode( "\n", $this->mAttachedList );
2633
2634            return;
2635        }
2636
2637        $this->logger->debug(
2638            "Loading attached wiki list for global user {$this->mName} from DB"
2639        );
2640
2641        $db = $this->getSafeReadDB();
2642
2643        $wikis = $db->newSelectQueryBuilder()
2644            ->select( 'lu_wiki' )
2645            ->from( 'localuser' )
2646            ->where( [ 'lu_name' => $this->mName ] )
2647            ->caller( __METHOD__ )
2648            ->fetchFieldValues();
2649
2650        $this->mAttachedArray = $wikis;
2651        $this->mAttachedList = implode( "\n", $wikis );
2652    }
2653
2654    /**
2655     * Fetch a list of databases where this account has been successfully
2656     * attached.
2657     *
2658     * @return string[] Database name strings
2659     */
2660    public function listAttached() {
2661        $this->loadAttached();
2662
2663        return $this->mAttachedArray;
2664    }
2665
2666    /**
2667     * Same as $this->renameInProgress, but only checks one wiki
2668     * Not cached
2669     * @see CentralAuthUser::renameInProgress
2670     * @param string $wiki
2671     * @param int $recency Bitfield of IDBAccessObject::READ_* constants
2672     * @return string[]|false
2673     */
2674    public function renameInProgressOn( string $wiki, int $recency = IDBAccessObject::READ_NORMAL ) {
2675        return CentralAuthServices::getGlobalRenameFactory()
2676            ->newGlobalRenameUserStatus( $this->mName )
2677            ->getNames(
2678                $wiki,
2679                $recency
2680            ) ?: false;
2681    }
2682
2683    /**
2684     * Check if a rename from the old name is in progress
2685     * @return string[] (oldname, newname) if being renamed, or empty if not
2686     */
2687    public function renameInProgress() {
2688        $this->loadState();
2689        if ( $this->mBeingRenamedArray === null ) {
2690            $this->mBeingRenamedArray = $this->mBeingRenamed === ''
2691                ? [] : explode( '|', $this->mBeingRenamed );
2692        }
2693
2694        return $this->mBeingRenamedArray;
2695    }
2696
2697    /**
2698     * Returns a list of all groups where the user is a member of the group on at
2699     * least one wiki where their account is attached.
2700     * @return string[] List of group names where the user is a member on at least one wiki
2701     */
2702    public function getLocalGroups() {
2703        $localgroups = [];
2704        foreach ( $this->queryAttached() as $local ) {
2705            $localgroups = array_unique( array_merge(
2706                $localgroups, array_keys( $local['groupMemberships'] )
2707            ) );
2708        }
2709        return $localgroups;
2710    }
2711
2712    /**
2713     * Get information about each local user attached to this account
2714     *
2715     * @return array[] Map of database name to property table with members:
2716     *    wiki                  The wiki ID (database name)
2717     *    attachedTimestamp     The MW timestamp when the account was attached
2718     *    attachedMethod        Attach method: see {@link CentralAuthUser::attach()}.
2719     *    ...                   All information returned by localUserData()
2720     */
2721    public function queryAttached() {
2722        // Cache $wikis to avoid expensive query whenever possible
2723        // mAttachedInfo is shared with queryAttachedBasic(); check whether it contains partial data
2724        if (
2725            $this->mAttachedInfo !== null
2726            && ( !$this->mAttachedInfo || array_key_exists( 'id', reset( $this->mAttachedInfo ) ) )
2727        ) {
2728            return $this->mAttachedInfo;
2729        }
2730
2731        $wikis = $this->queryAttachedBasic();
2732
2733        foreach ( $wikis as $wikiId => $_ ) {
2734            try {
2735                $localUser = $this->localUserData( $wikiId );
2736                $wikis[$wikiId] = array_merge( $wikis[$wikiId], $localUser );
2737            } catch ( LocalUserNotFoundException $e ) {
2738                // T119736: localuser table told us that the user was attached
2739                // from $wikiId but there is no data in the primary database or replicas
2740                // that corroborates that.
2741                unset( $wikis[$wikiId] );
2742                // Queue a job to delete the bogus attachment record.
2743                $this->queueAdminUnattachJob( $wikiId );
2744            }
2745        }
2746
2747        $this->mAttachedInfo = $wikis;
2748
2749        return $wikis;
2750    }
2751
2752    /**
2753     * Helper method for queryAttached().
2754     *
2755     * Does the cheap part of the lookup by checking the cross-wiki localuser table,
2756     * and returns attach time and method.
2757     *
2758     * @return array[]
2759     */
2760    public function queryAttachedBasic() {
2761        if ( $this->mAttachedInfo !== null ) {
2762            return $this->mAttachedInfo;
2763        }
2764
2765        $db = $this->getSafeReadDB();
2766
2767        $result = $db->newSelectQueryBuilder()
2768            ->select( [
2769                'lu_wiki',
2770                'lu_attached_timestamp',
2771                'lu_attached_method',
2772            ] )
2773            ->from( 'localuser' )
2774            ->where( [ 'lu_name' => $this->mName, ] )
2775            ->caller( __METHOD__ )
2776            ->fetchResultSet();
2777
2778        $wikis = [];
2779        foreach ( $result as $row ) {
2780            /** @var stdClass $row */
2781
2782            if ( !WikiMap::getWiki( $row->lu_wiki ) ) {
2783                $this->logger->warning( __METHOD__ . ': invalid wiki in localuser: ' . $row->lu_wiki );
2784                continue;
2785            }
2786
2787            $wikis[$row->lu_wiki] = [
2788                'wiki' => $row->lu_wiki,
2789                'attachedTimestamp' => wfTimestampOrNull( TS_MW, $row->lu_attached_timestamp ),
2790                'attachedMethod' => $row->lu_attached_method,
2791            ];
2792        }
2793
2794        $this->mAttachedInfo = $wikis;
2795
2796        return $wikis;
2797    }
2798
2799    /**
2800     * Find any remaining migration records for this username which haven't gotten attached to
2801     * some global account.
2802     * Formatted as associative array with some data.
2803     *
2804     * @throws Exception
2805     * @return array[]
2806     */
2807    public function queryUnattached() {
2808        $wikiIDs = $this->listUnattached();
2809
2810        $items = [];
2811        foreach ( $wikiIDs as $wikiID ) {
2812            try {
2813                $items[$wikiID] = $this->localUserData( $wikiID );
2814            } catch ( LocalUserNotFoundException $e ) {
2815                // T119736: localnames table told us that the name was
2816                // unattached on $wikiId but there is no data in the primary database
2817                // or replicas that corroborates that.
2818                // Queue a job to delete the bogus record.
2819                $this->queueAdminUnattachJob( $wikiID );
2820            }
2821        }
2822
2823        return $items;
2824    }
2825
2826    /**
2827     * Fetch a row of user data needed for migration.
2828     *
2829     * Returns most data in the user and ipblocks tables, user groups, and editcount.
2830     *
2831     * @param string $wikiID
2832     * @throws LocalUserNotFoundException if local user not found
2833     * @return array
2834     */
2835    protected function localUserData( $wikiID ) {
2836        $mwServices = MediaWikiServices::getInstance();
2837        $databaseManager = CentralAuthServices::getDatabaseManager();
2838
2839        $db = $databaseManager->getLocalDB( DB_REPLICA, $wikiID );
2840        $fields = [
2841            'user_id',
2842            'user_email',
2843            'user_name',
2844            'user_email_authenticated',
2845            'user_password',
2846            'user_editcount',
2847            'user_registration',
2848        ];
2849        $conds = [ 'user_name' => $this->mName ];
2850        $row = $db->newSelectQueryBuilder()
2851            ->select( $fields )
2852            ->from( 'user' )
2853            ->where( $conds )
2854            ->caller( __METHOD__ )
2855            ->fetchRow();
2856        if ( !$row ) {
2857            # Row missing from replica, try the primary database instead
2858            $db = $databaseManager->getLocalDB( DB_PRIMARY, $wikiID );
2859            $row = $db->newSelectQueryBuilder()
2860                ->select( $fields )
2861                ->from( 'user' )
2862                ->where( $conds )
2863                ->caller( __METHOD__ )
2864                ->fetchRow();
2865        }
2866        if ( !$row ) {
2867            $ex = new LocalUserNotFoundException(
2868                "Could not find local user data for {$this->mName}@{$wikiID}"
2869            );
2870            $this->logger->warning(
2871                'Could not find local user data for {username}@{wikiId}',
2872                [
2873                    'username' => $this->mName,
2874                    'wikiId' => $wikiID,
2875                    'exception' => $ex,
2876                ]
2877            );
2878            throw $ex;
2879        }
2880
2881        $data = [
2882            'wiki' => $wikiID,
2883            'id' => $row->user_id,
2884            'name' => $row->user_name,
2885            'email' => $row->user_email,
2886            'emailAuthenticated' => wfTimestampOrNull( TS_MW, $row->user_email_authenticated ),
2887            'registration' => wfTimestampOrNull( TS_MW, $row->user_registration ),
2888            'password' => $row->user_password,
2889            'editCount' => $row->user_editcount,
2890            // array of (group name => UserGroupMembership object)
2891            'groupMemberships' => [],
2892            'blocked' => false,
2893        ];
2894
2895        // Edit count field may not be initialized...
2896        if ( $row->user_editcount === null ) {
2897            $data['editCount'] = $db->newSelectQueryBuilder()
2898                ->select( 'COUNT(*)' )
2899                ->from( 'revision' )
2900                ->where( [ 'actor_user' => $data['id'] ] )
2901                ->join( 'actor', null, 'actor_id = rev_actor' )
2902                ->caller( __METHOD__ )
2903                ->fetchField();
2904        }
2905
2906        // And we have to fetch groups separately, sigh...
2907        $data['groupMemberships'] = $mwServices
2908            ->getUserGroupManagerFactory()
2909            ->getUserGroupManager( $wikiID )
2910            ->getUserGroupMemberships(
2911                new UserIdentityValue(
2912                    (int)$data['id'],
2913                    $data['name'],
2914                    $wikiID === WikiMap::getCurrentWikiId() ? UserIdentity::LOCAL : $wikiID
2915                )
2916            );
2917
2918        // And while we're in here, look for user blocks :D
2919        $blockStore = $mwServices
2920            ->getDatabaseBlockStoreFactory()
2921            ->getDatabaseBlockStore( $wikiID );
2922        $blocks = $blockStore->newListFromConds( [ 'bt_user' => $data['id'] ] );
2923        foreach ( $blocks as $block ) {
2924            $data['block-expiry'] = $block->getExpiry();
2925            $data['block-reason'] = $block->getReasonComment()->text;
2926            $data['block-anononly'] = !$block->isHardblock();
2927            $data['block-nocreate'] = $block->isCreateAccountBlocked();
2928            $data['block-noautoblock'] = !$block->isAutoblocking();
2929            // Poorly named database column
2930            $data['block-nousertalk'] = !$block->isUsertalkEditAllowed();
2931            $data['block-noemail'] = $block->isEmailBlocked();
2932            $data['block-sitewide'] = $block->isSitewide();
2933            $data['block-restrictions'] = $block->getRestrictions();
2934            $data['blocked'] = true;
2935        }
2936
2937        return $data;
2938    }
2939
2940    /**
2941     * @return string
2942     */
2943    public function getEmail(): string {
2944        $this->loadState();
2945        return $this->mEmail ?? '';
2946    }
2947
2948    /**
2949     * @return string
2950     */
2951    public function getEmailAuthenticationTimestamp() {
2952        $this->loadState();
2953        return $this->mAuthenticationTimestamp;
2954    }
2955
2956    /**
2957     * @param string $email
2958     * @return void
2959     */
2960    public function setEmail( $email ) {
2961        $this->checkWriteMode();
2962        $this->loadState();
2963        if ( $this->mEmail !== $email ) {
2964            $this->mEmail = $email;
2965            $this->mStateDirty = true;
2966        }
2967    }
2968
2969    /**
2970     * @param string|null $ts
2971     * @return void
2972     */
2973    public function setEmailAuthenticationTimestamp( $ts ) {
2974        $this->checkWriteMode();
2975        $this->loadState();
2976        if ( $this->mAuthenticationTimestamp !== $ts ) {
2977            $this->mAuthenticationTimestamp = $ts;
2978            $this->mStateDirty = true;
2979        }
2980    }
2981
2982    /**
2983     * Salt and hash a new plaintext password.
2984     * @param string|null $password plaintext
2985     * @return string Password in the form :<TYPE>:<PARAM1>:<PARAM2>:...:<HASH>
2986     */
2987    protected function saltedPassword( $password ) {
2988        $config = RequestContext::getMain()->getConfig();
2989        $passwordFactory = new PasswordFactory(
2990            $config->get( MainConfigNames::PasswordConfig ),
2991            $config->get( MainConfigNames::PasswordDefault )
2992        );
2993
2994        return $passwordFactory->newFromPlaintext( $password )->toString();
2995    }
2996
2997    /**
2998     * Set the account's password
2999     * @param string|null $password plaintext
3000     * @param bool $resetAuthToken if we should reset the login token
3001     * @return bool true
3002     */
3003    public function setPassword( $password, $resetAuthToken = true ) {
3004        $this->checkWriteMode();
3005
3006        // Make sure state is loaded before updating ->mPassword
3007        $this->loadState();
3008
3009        $hash = $this->saltedPassword( $password );
3010
3011        $this->mPassword = $hash;
3012
3013        if ( $this->getId() ) {
3014            $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
3015            $dbw->newUpdateQueryBuilder()
3016                ->update( 'globaluser' )
3017                ->set( [ 'gu_password' => $hash ] )
3018                ->where( [ 'gu_id' => $this->getId(), ] )
3019                ->caller( __METHOD__ )
3020                ->execute();
3021
3022            $this->logger->info( "Set global password for {user}", [ 'user' => $this->mName ] );
3023        } else {
3024            $this->logger->warning( "Tried changing password for user that doesn't exist {user}",
3025                [ 'user' => $this->mName ] );
3026        }
3027
3028        if ( $resetAuthToken ) {
3029            $this->resetAuthToken();
3030        }
3031        $this->invalidateCache();
3032        return true;
3033    }
3034
3035    /**
3036     * Get the password hash.
3037     * Automatically converts to a new-style hash
3038     * @return string
3039     */
3040    public function getPassword() {
3041        $this->loadState();
3042        return $this->mPassword;
3043    }
3044
3045    /**
3046     * @return CentralAuthSessionProvider
3047     */
3048    private static function getSessionProvider(): CentralAuthSessionProvider {
3049        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
3050        return SessionManager::singleton()->getProvider( CentralAuthSessionProvider::class );
3051    }
3052
3053    /**
3054     * Get the domain parameter for setting a global cookie.
3055     * This allows other extensions to easily set global cookies without directly relying on
3056     * $wgCentralAuthCookieDomain (in case CentralAuth's implementation changes at some point).
3057     *
3058     * @return string
3059     */
3060    public static function getCookieDomain() {
3061        $provider = self::getSessionProvider();
3062        return $provider->getCentralCookieDomain();
3063    }
3064
3065    /**
3066     * Check a global auth token against the one we know of in the database.
3067     *
3068     * @param string $token
3069     * @return bool
3070     */
3071    public function validateAuthToken( $token ) {
3072        return hash_equals( $this->getAuthToken(), $token );
3073    }
3074
3075    /**
3076     * Generate a new random auth token, and store it in the database.
3077     * Should be called as often as possible, to the extent that it will
3078     * not randomly log users out (so on logout, as is done currently, is a good time).
3079     */
3080    public function resetAuthToken() {
3081        $this->checkWriteMode();
3082
3083        // Load state, since its hard to reset the token without it
3084        $this->loadState();
3085
3086        // Generate a random token.
3087        $this->mAuthToken = MWCryptRand::generateHex( 32 );
3088        $this->mStateDirty = true;
3089
3090        // Save it.
3091        $this->saveSettings();
3092    }
3093
3094    public function saveSettings() {
3095        $this->checkWriteMode();
3096
3097        if ( !$this->mStateDirty ) {
3098            return;
3099        }
3100        $this->mStateDirty = false;
3101
3102        $databaseManager = CentralAuthServices::getDatabaseManager();
3103        if ( $databaseManager->isReadOnly() ) {
3104            return;
3105        }
3106
3107        $this->loadState();
3108        if ( !$this->mGlobalId ) {
3109            return;
3110        }
3111
3112        $newCasToken = $this->mCasToken + 1;
3113
3114        $dbw = $databaseManager->getCentralPrimaryDB();
3115
3116        $toSet = [
3117            'gu_password' => $this->mPassword,
3118            'gu_auth_token' => $this->mAuthToken,
3119            'gu_locked' => $this->mLocked,
3120            'gu_hidden_level' => $this->getHiddenLevelInt(),
3121            'gu_email' => $this->mEmail,
3122            'gu_email_authenticated' =>
3123                $dbw->timestampOrNull( $this->mAuthenticationTimestamp ),
3124            'gu_home_db' => $this->getHomeWiki(),
3125            'gu_cas_token' => $newCasToken
3126        ];
3127
3128        $dbw->newUpdateQueryBuilder()
3129            ->update( 'globaluser' )
3130            ->set( $toSet )
3131            ->where( [
3132                'gu_id' => $this->mGlobalId,
3133                'gu_cas_token' => $this->mCasToken
3134            ] )
3135            ->caller( __METHOD__ )
3136            ->execute();
3137
3138        if ( !$dbw->affectedRows() ) {
3139            // Maybe the problem was a missed cache update; clear it to be safe
3140            $this->invalidateCache();
3141            // User was changed in the meantime or loaded with stale data
3142            $from = ( $this->mFromPrimary ) ? 'primary' : 'replica';
3143            $this->logger->warning(
3144                "CAS update failed on gu_cas_token for user ID '{globalId}' " .
3145                "(read from {from}); the version of the user to be saved is older than " .
3146                "the current version.",
3147                [
3148                    'globalId' => $this->mGlobalId,
3149                    'from' => $from,
3150                    'exception' => new RuntimeException,
3151                ]
3152            );
3153            return;
3154        }
3155
3156        $this->mCasToken = $newCasToken;
3157        $this->invalidateCache();
3158    }
3159
3160    /**
3161     * @return string[]
3162     */
3163    public function getGlobalGroups() {
3164        return array_keys( $this->getGlobalGroupsWithExpiration() );
3165    }
3166
3167    /**
3168     * @return array<string,?string> of [group name => expiration timestamp or null if permanent]
3169     */
3170    public function getGlobalGroupsWithExpiration() {
3171        $this->loadGroups();
3172
3173        $groupExpirations = [];
3174        foreach ( $this->mGroups as [ 'group' => $group, 'expiry' => $expiration ] ) {
3175            $groupExpirations[ $group ] = $expiration;
3176        }
3177        ksort( $groupExpirations );
3178
3179        return $groupExpirations;
3180    }
3181
3182    /**
3183     * Return the user's groups that are active on the current wiki (according to WikiSet settings).
3184     *
3185     * @return string[]
3186     */
3187    public function getActiveGlobalGroups() {
3188        $this->loadGroups();
3189
3190        $groups = [];
3191        $sets = [];
3192        foreach ( $this->mGroups as [ 'group' => $group, 'set' => $setId ] ) {
3193            if ( $setId ) {
3194                $sets[$setId] ??= WikiSet::newFromID( $setId );
3195                if ( !$sets[$setId]->inSet() ) {
3196                    continue;
3197                }
3198            }
3199            $groups[] = $group;
3200        }
3201        sort( $groups );
3202        return $groups;
3203    }
3204
3205    /**
3206     * @return string[]
3207     */
3208    public function getGlobalRights() {
3209        $this->loadGroups();
3210
3211        $rights = [];
3212        $sets = [];
3213        foreach ( $this->mRights as [ 'right' => $right, 'set' => $setId ] ) {
3214            if ( $setId ) {
3215                $sets[$setId] ??= WikiSet::newFromID( $setId );
3216                if ( !$sets[$setId]->inSet() ) {
3217                    continue;
3218                }
3219            }
3220            $rights[] = $right;
3221        }
3222        return $rights;
3223    }
3224
3225    /**
3226     * @param string $groups
3227     * @return void
3228     */
3229    public function removeFromGlobalGroups( $groups ) {
3230        $this->checkWriteMode();
3231        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
3232
3233        # Delete from the DB
3234        $dbw->newDeleteQueryBuilder()
3235            ->deleteFrom( 'global_user_groups' )
3236            ->where( [ 'gug_user' => $this->getId(), 'gug_group' => $groups ] )
3237            ->caller( __METHOD__ )
3238            ->execute();
3239
3240        $this->invalidateCache();
3241    }
3242
3243    /**
3244     * @param string $group
3245     * @param string|null $expiry Timestamp of membership expiry in TS_MW format, or null if no expiry
3246     * @return void
3247     */
3248    public function addToGlobalGroup( string $group, ?string $expiry = null ) {
3249        $this->checkWriteMode();
3250        $dbw = CentralAuthServices::getDatabaseManager()->getCentralPrimaryDB();
3251
3252        # Replace into the DB
3253        $dbw->newReplaceQueryBuilder()
3254            ->replaceInto( 'global_user_groups' )
3255            ->uniqueIndexFields( [ 'gug_user', 'gug_group' ] )
3256            ->row( [
3257                'gug_user' => $this->getId(),
3258                'gug_group' => $group,
3259                'gug_expiry' => $dbw->timestampOrNull( $expiry )
3260            ] )
3261            ->caller( __METHOD__ )
3262            ->execute();
3263
3264        $this->invalidateCache();
3265    }
3266
3267    /**
3268     * @param string $perm
3269     * @return bool
3270     */
3271    public function hasGlobalPermission( $perm ) {
3272        return in_array( $perm, $this->getGlobalRights() );
3273    }
3274
3275    public function invalidateCache() {
3276        if ( !$this->mDelayInvalidation ) {
3277            $this->logger->debug( "Updating cache for global user {$this->mName}" );
3278            // Purge the cache
3279            $this->quickInvalidateCache();
3280            // Reload the state
3281            $this->loadStateNoCache();
3282        } else {
3283            $this->logger->debug( "Deferring cache invalidation because we're in a transaction" );
3284        }
3285    }
3286
3287    /**
3288     * For when speed is of the essence (e.g. when batch-purging users after rights changes)
3289     */
3290    public function quickInvalidateCache() {
3291        $this->logger->debug(
3292            "Quick cache invalidation for global user {$this->mName}"
3293        );
3294
3295        CentralAuthServices::getDatabaseManager()
3296            ->getCentralPrimaryDB()
3297            ->onTransactionPreCommitOrIdle( function () {
3298                $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
3299                $cache->delete( $this->getCacheKey( $cache ) );
3300            }, __METHOD__ );
3301    }
3302
3303    /**
3304     * End a "transaction".
3305     * A transaction delays cache invalidation until after
3306     * some operation which would otherwise repeatedly do so.
3307     * Intended to be used for things like migration.
3308     */
3309    public function endTransaction() {
3310        $this->logger->debug( 'End CentralAuthUser cache-invalidating transaction' );
3311        $this->mDelayInvalidation = false;
3312        $this->invalidateCache();
3313    }
3314
3315    /**
3316     * Start a "transaction".
3317     * A transaction delays cache invalidation until after
3318     * some operation which would otherwise repeatedly do so.
3319     * Intended to be used for things like migration.
3320     */
3321    public function startTransaction() {
3322        $this->logger->debug( 'Start CentralAuthUser cache-invalidating transaction' );
3323        // Delay cache invalidation
3324        $this->mDelayInvalidation = 1;
3325    }
3326
3327    /**
3328     * Check if the user is attached on a given wiki id.
3329     *
3330     * @param string $wiki
3331     * @return bool
3332     */
3333    public function attachedOn( $wiki ) {
3334        $this->loadAttached();
3335        return $this->exists() && in_array( $wiki, $this->mAttachedArray );
3336    }
3337
3338    /**
3339     * Get a hash representing the user/locked/hidden state of this user,
3340     * used to check for edit conflicts
3341     *
3342     * @param bool $recache Force a reload of the user from the database
3343     * @return string
3344     */
3345    public function getStateHash( bool $recache = false ) {
3346        $this->loadState( $recache );
3347
3348        return md5( $this->mGlobalId . ':' . $this->mName . ':' . $this->mHiddenLevel . ':' .
3349            ( $this->mLocked ? '1' : '0' ) );
3350    }
3351
3352    /**
3353     * Log an action for the current user
3354     *
3355     * @param string $action
3356     * @param UserIdentity $user
3357     * @param string $reason
3358     * @param array $params
3359     * @param bool $suppressLog
3360     * @param bool $markAsBot If true, log entry is marked as made by a bot. If false, default
3361     * behavior is observed.
3362     */
3363    public function logAction(
3364        $action,
3365        UserIdentity $user,
3366        $reason = '',
3367        $params = [],
3368        bool $suppressLog = false,
3369        bool $markAsBot = false
3370    ) {
3371        $nsUser = MediaWikiServices::getInstance()
3372            ->getNamespaceInfo()
3373            ->getCanonicalName( NS_USER );
3374        // Not centralauth because of some weird length limitations
3375        $logType = $suppressLog ? 'suppress' : 'globalauth';
3376        $entry = new ManualLogEntry( $logType, $action );
3377        $entry->setTarget( Title::newFromText( "$nsUser:{$this->mName}@global" ) );
3378        $entry->setPerformer( $user );
3379        $entry->setComment( $reason );
3380        $entry->setParameters( $params );
3381        if ( $markAsBot ) {
3382            // NOTE: This is intentionally called conditionally, to respect default behavior when
3383            // $markAsBot is set to false.
3384            $entry->setForceBotFlag( $markAsBot );
3385        }
3386        $logid = $entry->insert();
3387        $entry->publish( $logid );
3388    }
3389
3390    /**
3391     * @param string $wikiId
3392     * @param int $userId
3393     */
3394    private function clearLocalUserCache( $wikiId, $userId ) {
3395        User::purge( $wikiId, $userId );
3396    }
3397}