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