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