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