Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
79.66% |
380 / 477 |
|
64.00% |
32 / 50 |
CRAP | |
0.00% |
0 / 1 |
LoginNotify | |
79.66% |
380 / 477 |
|
64.00% |
32 / 50 |
291.54 | |
0.00% |
0 / 1 |
getInstance | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__construct | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
setLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getIPNetwork | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
getSalt | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
isKnownSystemFast | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
6.02 | |||
isKnownSystemSlow | |
61.90% |
13 / 21 |
|
0.00% |
0 / 1 |
2.22 | |||
userIsInCache | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
userIsInSeenTable | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
4.00 | |||
userIsInCurrentSeenBucket | |
86.67% |
13 / 15 |
|
0.00% |
0 / 1 |
3.02 | |||
getSeenHash | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
packedSignedInt64ToDecimal | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
19.47 | |||
getSeenReplicaDb | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSeenPrimaryDb | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMinBucket | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getCurrentBucket | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCurrentTime | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setFakeTime | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMaybeCentralId | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
checkUserAllWikis | |
31.82% |
14 / 44 |
|
0.00% |
0 / 1 |
108.60 | |||
checkUserOneWiki | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
userHasCheckUserData | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
hasCheckUserTables | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
isCheckUserInstalled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setLoginCookie | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
recordKnownWithCookie | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
recordKnown | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
cacheLoginIP | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
recordUserInSeenTable | |
94.44% |
34 / 36 |
|
0.00% |
0 / 1 |
6.01 | |||
getMinExpiredId | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
purgeSeen | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
mergeResults | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
userIsInCookie | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
getPrevLoginCookie | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
checkAndGenerateCookie | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
7 | |||
isUserRecordGivenCookie | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
validateCookieRecord | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
generateUserCookieRecord | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
getKey | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
recordLoginFailureFromUnknownSystem | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
recordLoginFailureFromKnownSystem | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
sendNotice | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
checkAndIncKey | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
clearCounters | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
recordFailure | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
5.23 | |||
recordFailureDeferred | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
sendSuccessNotice | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
sendSuccessNoticeDeferred | |
36.36% |
4 / 11 |
|
0.00% |
0 / 1 |
3.03 | |||
createJob | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
1 | |||
incrStats | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * Body of LoginNotify extension |
4 | * |
5 | * @file |
6 | * @ingroup Extensions |
7 | */ |
8 | |
9 | namespace LoginNotify; |
10 | |
11 | use BagOStuff; |
12 | use ExtensionRegistry; |
13 | use IBufferingStatsdDataFactory; |
14 | use JobQueueGroup; |
15 | use JobSpecification; |
16 | use LogicException; |
17 | use MediaWiki\Auth\AuthManager; |
18 | use MediaWiki\Config\ServiceOptions; |
19 | use MediaWiki\Extension\CentralAuth\User\CentralAuthUser; |
20 | use MediaWiki\Extension\Notifications\Model\Event; |
21 | use MediaWiki\MediaWikiServices; |
22 | use MediaWiki\Request\WebRequest; |
23 | use MediaWiki\User\CentralId\CentralIdLookup; |
24 | use MediaWiki\User\User; |
25 | use MediaWiki\WikiMap\WikiMap; |
26 | use MWCryptRand; |
27 | use Psr\Log\LoggerAwareInterface; |
28 | use Psr\Log\LoggerInterface; |
29 | use UnexpectedValueException; |
30 | use Wikimedia\Assert\Assert; |
31 | use Wikimedia\IPUtils; |
32 | use Wikimedia\Rdbms\IDatabase; |
33 | use Wikimedia\Rdbms\IExpression; |
34 | use Wikimedia\Rdbms\IMaintainableDatabase; |
35 | use Wikimedia\Rdbms\IReadableDatabase; |
36 | use Wikimedia\Rdbms\LBFactory; |
37 | use Wikimedia\Rdbms\LikeValue; |
38 | |
39 | /** |
40 | * Handle sending notifications on login from unknown source. |
41 | * |
42 | * @author Brian Wolff |
43 | */ |
44 | class LoginNotify implements LoggerAwareInterface { |
45 | |
46 | public const CONSTRUCTOR_OPTIONS = [ |
47 | 'LoginNotifyAttemptsKnownIP', |
48 | 'LoginNotifyAttemptsNewIP', |
49 | 'LoginNotifyCacheLoginIPExpiry', |
50 | 'LoginNotifyCheckKnownIPs', |
51 | 'LoginNotifyCookieDomain', |
52 | 'LoginNotifyCookieExpire', |
53 | 'LoginNotifyEnableOnSuccess', |
54 | 'LoginNotifyExpiryKnownIP', |
55 | 'LoginNotifyExpiryNewIP', |
56 | 'LoginNotifyMaxCookieRecords', |
57 | 'LoginNotifySecretKey', |
58 | 'LoginNotifySeenBucketSize', |
59 | 'LoginNotifySeenExpiry', |
60 | 'LoginNotifyUseCheckUser', |
61 | 'LoginNotifyUseSeenTable', |
62 | 'LoginNotifyUseCentralId', |
63 | 'SecretKey', |
64 | 'UpdateRowsPerQuery' |
65 | ]; |
66 | |
67 | private const COOKIE_NAME = 'loginnotify_prevlogins'; |
68 | |
69 | // The following 3 constants specify outcomes of user search |
70 | /** User's system is known to us */ |
71 | public const USER_KNOWN = 'known'; |
72 | /** User's system is new for us, based on our data */ |
73 | public const USER_NOT_KNOWN = 'not known'; |
74 | /** We don't have data to confirm or deny this is a known system */ |
75 | public const USER_NO_INFO = 'no info'; |
76 | |
77 | /** @var BagOStuff */ |
78 | private $cache; |
79 | /** @var ServiceOptions */ |
80 | private $config; |
81 | /** @var LoggerInterface Usually instance of LoginNotify log */ |
82 | private $log; |
83 | /** @var string Salt for cookie hash. DON'T USE DIRECTLY, use getSalt() */ |
84 | private $salt; |
85 | /** @var string */ |
86 | private $secret; |
87 | /** @var IBufferingStatsdDataFactory */ |
88 | private $stats; |
89 | /** @var LBFactory */ |
90 | private $lbFactory; |
91 | /** @var JobQueueGroup */ |
92 | private $jobQueueGroup; |
93 | /** @var CentralIdLookup */ |
94 | private $centralIdLookup; |
95 | /** @var AuthManager */ |
96 | private $authManager; |
97 | /** @var int|null */ |
98 | private $fakeTime; |
99 | |
100 | public static function getInstance(): self { |
101 | return MediaWikiServices::getInstance()->get( 'LoginNotify.LoginNotify' ); |
102 | } |
103 | |
104 | /** |
105 | * @param ServiceOptions $options |
106 | * @param BagOStuff $cache |
107 | * @param LoggerInterface $log |
108 | * @param IBufferingStatsdDataFactory $stats |
109 | * @param LBFactory $lbFactory |
110 | * @param JobQueueGroup $jobQueueGroup |
111 | * @param CentralIdLookup $centralIdLookup |
112 | * @param AuthManager $authManager |
113 | */ |
114 | public function __construct( |
115 | ServiceOptions $options, |
116 | BagOStuff $cache, |
117 | LoggerInterface $log, |
118 | IBufferingStatsdDataFactory $stats, |
119 | LBFactory $lbFactory, |
120 | JobQueueGroup $jobQueueGroup, |
121 | CentralIdLookup $centralIdLookup, |
122 | AuthManager $authManager |
123 | ) { |
124 | $this->config = $options; |
125 | $this->cache = $cache; |
126 | |
127 | if ( $this->config->get( 'LoginNotifySecretKey' ) !== null ) { |
128 | $this->secret = $this->config->get( 'LoginNotifySecretKey' ); |
129 | } else { |
130 | $globalSecret = $this->config->get( 'SecretKey' ); |
131 | $this->secret = hash( 'sha256', $globalSecret . 'LoginNotify' ); |
132 | } |
133 | $this->log = $log; |
134 | $this->stats = $stats; |
135 | $this->lbFactory = $lbFactory; |
136 | $this->jobQueueGroup = $jobQueueGroup; |
137 | $this->centralIdLookup = $centralIdLookup; |
138 | $this->authManager = $authManager; |
139 | } |
140 | |
141 | /** |
142 | * Set the logger. |
143 | * @param LoggerInterface $logger The logger object. |
144 | */ |
145 | public function setLogger( LoggerInterface $logger ) { |
146 | $this->log = $logger; |
147 | } |
148 | |
149 | /** |
150 | * Get just network part of an IP (assuming /24 or /64) |
151 | * |
152 | * It would be nice if we could use IPUtils::getSubnet(), which also gets |
153 | * the /24 or /64 network in support of a similar use case, but its |
154 | * behaviour is broken for IPv6 addresses, returning the hex range start |
155 | * rather than the prefix. (T344963) |
156 | * |
157 | * @param string $ip Either IPv4 or IPv6 address |
158 | * @return string Just the network part (e.g. 127.0.0.) |
159 | */ |
160 | private function getIPNetwork( $ip ) { |
161 | $ip = IPUtils::sanitizeIP( $ip ); |
162 | if ( IPUtils::isIPv6( $ip ) ) { |
163 | // Match against the /64 |
164 | $subnetRegex = '/[0-9A-F]+:[0-9A-F]+:[0-9A-F]+:[0-9A-F]+$/i'; |
165 | } elseif ( IPUtils::isIPv4( $ip ) ) { |
166 | // match against the /24 |
167 | $subnetRegex = '/\d+$/'; |
168 | } else { |
169 | throw new UnexpectedValueException( "Unrecognized IP address: $ip" ); |
170 | } |
171 | $prefix = preg_replace( $subnetRegex, '', $ip ); |
172 | if ( !is_string( $prefix ) ) { |
173 | throw new LogicException( __METHOD__ . " Regex failed on '$ip'!?" ); |
174 | } |
175 | return $prefix; |
176 | } |
177 | |
178 | /** |
179 | * Returns lazy-initialized salt |
180 | * |
181 | * @return string |
182 | */ |
183 | private function getSalt() { |
184 | // Generate salt just once to avoid duplicate cookies |
185 | if ( $this->salt === null ) { |
186 | $this->salt = \Wikimedia\base_convert( MWCryptRand::generateHex( 8 ), 16, 36 ); |
187 | } |
188 | |
189 | return $this->salt; |
190 | } |
191 | |
192 | /** |
193 | * Is the current computer known to be used by the current user (fast checks) |
194 | * To be used for checks that are fast enough to be run at the moment the user logs in. |
195 | * |
196 | * @param User $user User in question |
197 | * @param WebRequest $request |
198 | * @return string One of USER_* constants |
199 | */ |
200 | private function isKnownSystemFast( User $user, WebRequest $request ) { |
201 | $logContext = [ 'user' => $user->getName() ]; |
202 | $result = $this->userIsInCookie( $user, $request ); |
203 | if ( $result === self::USER_KNOWN ) { |
204 | $this->log->debug( 'Found user {user} in cookie', $logContext ); |
205 | return $result; |
206 | } |
207 | |
208 | if ( $this->config->get( 'LoginNotifyUseSeenTable' ) ) { |
209 | $id = $this->getMaybeCentralId( $user ); |
210 | $hash = $this->getSeenHash( $request, $id ); |
211 | $result = $this->mergeResults( $result, $this->userIsInSeenTable( $id, $hash ) ); |
212 | if ( $result === self::USER_KNOWN ) { |
213 | $this->log->debug( 'Found user {user} in table', $logContext ); |
214 | return $result; |
215 | } |
216 | } |
217 | |
218 | // No need for caching unless CheckUser will be used |
219 | if ( $this->config->get( 'LoginNotifyUseCheckUser' ) ) { |
220 | $result = $this->mergeResults( $result, $this->userIsInCache( $user, $request ) ); |
221 | if ( $result === self::USER_KNOWN ) { |
222 | $this->log->debug( 'Found user {user} in cache', $logContext ); |
223 | return $result; |
224 | } |
225 | } else { |
226 | $result = self::USER_NOT_KNOWN; |
227 | } |
228 | |
229 | $this->log->debug( 'Fast checks for {user}: {result}', [ |
230 | 'user' => $user->getName(), |
231 | 'result' => $result, |
232 | ] ); |
233 | |
234 | return $result; |
235 | } |
236 | |
237 | /** |
238 | * Is the current computer known to be used by the current user (slow checks) |
239 | * These checks are slow enough to be run via the job queue |
240 | * |
241 | * @param User $user User in question |
242 | * @param string $subnet User's current subnet |
243 | * @param string $resultSoFar Value returned by isKnownSystemFast() or null if |
244 | * not available. |
245 | * @return bool true if the user has used this computer before |
246 | */ |
247 | private function isKnownSystemSlow( User $user, $subnet, $resultSoFar ) { |
248 | $result = $this->checkUserAllWikis( $user, $subnet ); |
249 | |
250 | $this->log->debug( 'Checking user {user} from {subnet} (result so far: {soFar}): {result}', |
251 | [ |
252 | 'function' => __METHOD__, |
253 | 'user' => $user->getName(), |
254 | 'subnet' => $subnet, |
255 | 'result' => $result, |
256 | 'soFar' => json_encode( $resultSoFar ), |
257 | ] |
258 | ); |
259 | |
260 | $result = $this->mergeResults( $result, $resultSoFar ); |
261 | |
262 | // If we have no CheckUser data for the user, and there was no cookie |
263 | // supplied, then treat the computer as known. |
264 | if ( $result === self::USER_NO_INFO ) { |
265 | // We have to be careful here. Whether $cookieResult is |
266 | // self::USER_NO_INFO, is under control of the attacker. |
267 | // If checking CheckUser is disabled, then we should not |
268 | // hit this branch. |
269 | |
270 | $this->log->info( |
271 | "Assuming the user {user} is from a known IP since no info is available", |
272 | [ |
273 | 'method' => __METHOD__, |
274 | 'user' => $user->getName() |
275 | ] |
276 | ); |
277 | return true; |
278 | } |
279 | |
280 | return $result === self::USER_KNOWN; |
281 | } |
282 | |
283 | /** |
284 | * Check if we cached this user's ip address from last login. |
285 | * |
286 | * @param User $user User in question |
287 | * @param WebRequest $request |
288 | * @return string One of USER_* constants |
289 | */ |
290 | private function userIsInCache( User $user, WebRequest $request ) { |
291 | $ipPrefix = $this->getIPNetwork( $request->getIP() ); |
292 | $key = $this->getKey( $user, 'prevSubnet' ); |
293 | $res = $this->cache->get( $key ); |
294 | if ( $res !== false ) { |
295 | return $res === $ipPrefix ? self::USER_KNOWN : self::USER_NOT_KNOWN; |
296 | } |
297 | return self::USER_NO_INFO; |
298 | } |
299 | |
300 | /** |
301 | * Check if the user is in our own table in a non-expired bucket |
302 | * |
303 | * @param int $centralUserId |
304 | * @param int|string $hash |
305 | * @return string One of USER_* constants |
306 | */ |
307 | private function userIsInSeenTable( int $centralUserId, $hash ) { |
308 | if ( !$centralUserId ) { |
309 | return self::USER_NO_INFO; |
310 | } |
311 | $dbr = $this->getSeenPrimaryDb(); |
312 | $seen = $dbr->newSelectQueryBuilder() |
313 | ->select( '1' ) |
314 | ->from( 'loginnotify_seen_net' ) |
315 | ->where( [ |
316 | 'lsn_user' => $centralUserId, |
317 | 'lsn_subnet' => $hash, |
318 | $dbr->expr( 'lsn_time_bucket', '>=', $this->getMinBucket() ) |
319 | ] ) |
320 | ->caller( __METHOD__ ) |
321 | ->fetchField(); |
322 | if ( $seen ) { |
323 | return self::USER_KNOWN; |
324 | } elseif ( $this->config->get( 'LoginNotifyUseCheckUser' ) ) { |
325 | // We still need to check CheckUser |
326 | return self::USER_NO_INFO; |
327 | } else { |
328 | return self::USER_NOT_KNOWN; |
329 | } |
330 | } |
331 | |
332 | /** |
333 | * Check if the user is in our table in the current bucket |
334 | * |
335 | * @param int $centralUserId |
336 | * @param string $hash |
337 | * @param bool $usePrimary |
338 | * @return bool |
339 | */ |
340 | private function userIsInCurrentSeenBucket( int $centralUserId, $hash, $usePrimary = false ) { |
341 | if ( !$centralUserId ) { |
342 | return false; |
343 | } |
344 | if ( $usePrimary ) { |
345 | $dbr = $this->getSeenPrimaryDb(); |
346 | } else { |
347 | $dbr = $this->getSeenReplicaDb(); |
348 | } |
349 | return (bool)$dbr->newSelectQueryBuilder() |
350 | ->select( '1' ) |
351 | ->from( 'loginnotify_seen_net' ) |
352 | ->where( [ |
353 | 'lsn_user' => $centralUserId, |
354 | 'lsn_subnet' => $hash, |
355 | 'lsn_time_bucket' => $this->getCurrentBucket(), |
356 | ] ) |
357 | ->caller( __METHOD__ ) |
358 | ->fetchField(); |
359 | } |
360 | |
361 | /** |
362 | * Combine the user ID and IP prefix into a 64-bit hash. Return the hash |
363 | * as either an integer or a decimal string. |
364 | * |
365 | * @param WebRequest $request |
366 | * @param int $centralUserId |
367 | * @return int|string |
368 | */ |
369 | private function getSeenHash( WebRequest $request, int $centralUserId ) { |
370 | $ipPrefix = $this->getIPNetwork( $request->getIP() ); |
371 | $hash = hash_hmac( 'sha1', "$centralUserId|$ipPrefix", $this->secret, true ); |
372 | // Truncate to 64 bits |
373 | return self::packedSignedInt64ToDecimal( substr( $hash, 0, 8 ) ); |
374 | } |
375 | |
376 | /** |
377 | * Convert an 8-byte string to a 64-bit integer, and return it either as a |
378 | * native integer, or if PHP integers are 32 bits, as a decimal string. |
379 | * |
380 | * Signed 64-bit integers are a compact and portable way to store a 64-bit |
381 | * hash in a DBMS. On a 64-bit platform, PHP can easily generate and handle |
382 | * such integers, but on a 32-bit platform it is a bit awkward. |
383 | * |
384 | * @param string $str |
385 | * @return int|string |
386 | */ |
387 | private static function packedSignedInt64ToDecimal( $str ) { |
388 | if ( PHP_INT_SIZE >= 8 ) { |
389 | // The manual is confusing -- this does in fact return a signed number |
390 | return unpack( 'Jv', $str )['v']; |
391 | } else { |
392 | // PHP has precious few facilities for manipulating 64-bit numbers on a |
393 | // 32-bit platform. String bitwise operators are a nice hack though. |
394 | if ( ( $str[0] & "\x80" ) !== "\x00" ) { |
395 | // The number is negative. Find 2's complement and add minus sign. |
396 | $sign = '-'; |
397 | $str = ~$str; |
398 | $carry = 1; |
399 | // Add with carry in big endian order |
400 | for ( $i = 7; $i >= 0 && $carry; $i-- ) { |
401 | $sum = ord( $str[$i] ) + $carry; |
402 | $carry = ( $sum & 0x100 ) >> 8; |
403 | $str[$i] = chr( $sum & 0xff ); |
404 | } |
405 | } else { |
406 | $sign = ''; |
407 | } |
408 | return $sign . \Wikimedia\base_convert( bin2hex( $str ), 16, 10 ); |
409 | } |
410 | } |
411 | |
412 | /** |
413 | * Get read a connection to the database holding the loginnotify_seen_net table. |
414 | * |
415 | * @return IReadableDatabase |
416 | */ |
417 | private function getSeenReplicaDb(): IReadableDatabase { |
418 | return $this->lbFactory->getReplicaDatabase( 'virtual-LoginNotify' ); |
419 | } |
420 | |
421 | /** |
422 | * Get a write connection to the database holding the loginnotify_seen_net table. |
423 | * |
424 | * @return IDatabase |
425 | */ |
426 | private function getSeenPrimaryDb(): IDatabase { |
427 | return $this->lbFactory->getPrimaryDatabase( 'virtual-LoginNotify' ); |
428 | } |
429 | |
430 | /** |
431 | * Get the lowest time bucket index which is not expired. |
432 | * |
433 | * @return int |
434 | */ |
435 | private function getMinBucket() { |
436 | $now = $this->getCurrentTime(); |
437 | $expiry = $this->config->get( 'LoginNotifySeenExpiry' ); |
438 | $size = $this->config->get( 'LoginNotifySeenBucketSize' ); |
439 | return (int)( ( $now - $expiry ) / $size ); |
440 | } |
441 | |
442 | /** |
443 | * Get the current time bucket index. |
444 | * |
445 | * @return int |
446 | */ |
447 | private function getCurrentBucket() { |
448 | return (int)( $this->getCurrentTime() / $this->config->get( 'LoginNotifySeenBucketSize' ) ); |
449 | } |
450 | |
451 | /** |
452 | * Get the current UNIX time |
453 | * |
454 | * @return int |
455 | */ |
456 | private function getCurrentTime() { |
457 | return $this->fakeTime ?? time(); |
458 | } |
459 | |
460 | /** |
461 | * Set a fake time to be returned by getCurrentTime(), for testing. |
462 | * |
463 | * @param int|null $time |
464 | */ |
465 | public function setFakeTime( $time ) { |
466 | $this->fakeTime = $time; |
467 | } |
468 | |
469 | /** |
470 | * If LoginNotifyUseCentralId is true, indicating a shared table, |
471 | * get the central user ID. Otherwise, get the local user ID. |
472 | * |
473 | * If CentralAuth is not installed, $this->centralIdLookup will be a |
474 | * LocalIdLookup and the local user ID will be returned regardless. But |
475 | * using CentralIdLookup unconditionally can fail if CentralAuth is |
476 | * installed but no users are attached to it, as is the case in CI. |
477 | * |
478 | * @param User $user |
479 | * @return int |
480 | */ |
481 | private function getMaybeCentralId( User $user ) { |
482 | if ( $this->config->get( 'LoginNotifyUseCentralId' ) ) { |
483 | return $this->centralIdLookup->centralIdFromLocalUser( $user ); |
484 | } else { |
485 | return $user->getId(); |
486 | } |
487 | } |
488 | |
489 | /** |
490 | * Is the subnet of the current IP in the CheckUser data for the user. |
491 | * |
492 | * If CentralAuth is installed, this will check not only the current wiki, |
493 | * but also the ten wikis where user has most edits on. |
494 | * |
495 | * @param User $user User in question |
496 | * @param string $subnet User's current subnet |
497 | * @return string One of USER_* constants |
498 | */ |
499 | private function checkUserAllWikis( User $user, $subnet ) { |
500 | Assert::parameter( $user->isRegistered(), '$user', 'User must be logged in' ); |
501 | |
502 | if ( !$this->config->get( 'LoginNotifyCheckKnownIPs' ) |
503 | || !$this->isCheckUserInstalled() |
504 | ) { |
505 | // CheckUser checks disabled. |
506 | // Note: It's important this be USER_NOT_KNOWN and not USER_NO_INFO. |
507 | return self::USER_NOT_KNOWN; |
508 | } |
509 | |
510 | $dbr = $this->lbFactory->getReplicaDatabase(); |
511 | $result = $this->checkUserOneWiki( $user->getId(), $subnet, $dbr ); |
512 | if ( $result === self::USER_KNOWN ) { |
513 | return $result; |
514 | } |
515 | |
516 | if ( $result === self::USER_NO_INFO |
517 | && $this->userHasCheckUserData( $user->getId(), $dbr ) |
518 | ) { |
519 | $result = self::USER_NOT_KNOWN; |
520 | } |
521 | |
522 | // Also check checkuser table on the top ten wikis where this user has |
523 | // edited the most. We only do top ten, to limit the worst-case where the |
524 | // user has accounts on 800 wikis. |
525 | if ( ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) { |
526 | $globalUser = CentralAuthUser::getInstance( $user ); |
527 | if ( $globalUser->exists() ) { |
528 | // This is expensive, up to ~5 seconds (T167731) |
529 | $info = $globalUser->queryAttached(); |
530 | // Already checked the local wiki. |
531 | unset( $info[WikiMap::getCurrentWikiId()] ); |
532 | usort( $info, |
533 | static function ( $a, $b ) { |
534 | // descending order |
535 | return $b['editCount'] - $a['editCount']; |
536 | } |
537 | ); |
538 | $count = 0; |
539 | foreach ( $info as $localInfo ) { |
540 | if ( !isset( $localInfo['id'] ) || !isset( $localInfo['wiki'] ) ) { |
541 | break; |
542 | } |
543 | if ( $count > 10 || $localInfo['editCount'] < 1 ) { |
544 | break; |
545 | } |
546 | |
547 | $wiki = $localInfo['wiki']; |
548 | $lb = $this->lbFactory->getMainLB( $wiki ); |
549 | $dbrLocal = $lb->getMaintenanceConnectionRef( DB_REPLICA, [], $wiki ); |
550 | |
551 | if ( !$this->hasCheckUserTables( $dbrLocal ) ) { |
552 | // Skip this wiki, no CheckUser table. |
553 | continue; |
554 | } |
555 | $res = $this->checkUserOneWiki( |
556 | $localInfo['id'], |
557 | $subnet, |
558 | $dbrLocal |
559 | ); |
560 | |
561 | if ( $res === self::USER_KNOWN ) { |
562 | return $res; |
563 | } |
564 | if ( $result === self::USER_NO_INFO |
565 | && $this->userHasCheckUserData( $user->getId(), $dbr ) |
566 | ) { |
567 | $result = self::USER_NOT_KNOWN; |
568 | } |
569 | $count++; |
570 | } |
571 | } |
572 | } |
573 | return $result; |
574 | } |
575 | |
576 | /** |
577 | * Actually do the query of the CheckUser table. |
578 | * |
579 | * @note This catches and ignores database errors. |
580 | * @param int $userId User ID number (Not necessarily for the local wiki) |
581 | * @param string $ipFragment Prefix to match against cuc_ip (from $this->getIPNetwork()) |
582 | * @param IReadableDatabase $dbr A database connection (possibly foreign) |
583 | * @return string One of USER_* constants |
584 | */ |
585 | private function checkUserOneWiki( $userId, $ipFragment, IReadableDatabase $dbr ) { |
586 | // The index is on (cuc_actor, cuc_ip, cuc_timestamp), instead of |
587 | // cuc_ip_hex which would be ideal, but CheckUser was not designed for |
588 | // this specific use case and we couldn't be bothered to update it. |
589 | // Although it would be 100x faster to use a single global summary |
590 | // table instead of connecting to the database of each wiki separately. |
591 | $IPHasBeenUsedBefore = $dbr->newSelectQueryBuilder() |
592 | ->select( '1' ) |
593 | ->from( 'cu_changes' ) |
594 | ->join( 'actor', null, 'actor_id = cuc_actor' ) |
595 | ->where( [ |
596 | 'actor_user' => $userId, |
597 | $dbr->expr( 'cuc_ip', IExpression::LIKE, new LikeValue( |
598 | $ipFragment, |
599 | $dbr->anyString() |
600 | ) ) |
601 | ] ) |
602 | ->caller( __METHOD__ ) |
603 | ->fetchField(); |
604 | return $IPHasBeenUsedBefore ? self::USER_KNOWN : self::USER_NO_INFO; |
605 | } |
606 | |
607 | /** |
608 | * Check if we have any CheckUser info for this user |
609 | * |
610 | * If we have no info for user, we maybe don't treat it as |
611 | * an unknown IP, since user has no known IPs. |
612 | * |
613 | * @param int $userId User id number (possibly on foreign wiki) |
614 | * @param IReadableDatabase $dbr DB connection (possibly to foreign wiki) |
615 | * @return bool |
616 | */ |
617 | private function userHasCheckUserData( $userId, IReadableDatabase $dbr ) { |
618 | $haveIPInfo = $dbr->newSelectQueryBuilder() |
619 | ->select( '1' ) |
620 | ->from( 'cu_changes' ) |
621 | ->join( 'actor', null, 'actor_id = cuc_actor' ) |
622 | ->where( [ 'actor_user' => $userId ] ) |
623 | ->caller( __METHOD__ ) |
624 | ->fetchField(); |
625 | |
626 | return (bool)$haveIPInfo; |
627 | } |
628 | |
629 | /** |
630 | * Does this wiki have a CheckUser table? |
631 | * |
632 | * @param IMaintainableDatabase $dbr Database to check |
633 | * @return bool |
634 | */ |
635 | private function hasCheckUserTables( IMaintainableDatabase $dbr ) { |
636 | if ( !$dbr->tableExists( 'cu_changes', __METHOD__ ) ) { |
637 | $this->log->warning( "No CheckUser table on {wikiId}", [ |
638 | 'method' => __METHOD__, |
639 | 'wikiId' => $dbr->getDomainID() |
640 | ] ); |
641 | return false; |
642 | } |
643 | return true; |
644 | } |
645 | |
646 | /** |
647 | * Whether CheckUser extension is installed |
648 | * @return bool |
649 | */ |
650 | private function isCheckUserInstalled() { |
651 | return ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ); |
652 | } |
653 | |
654 | /** |
655 | * Give the user a cookie saying that they've previously logged in from this computer. |
656 | * |
657 | * @note If user already has a cookie, this will refresh it. |
658 | * @param User $user User in question who just logged in. |
659 | */ |
660 | private function setLoginCookie( User $user ) { |
661 | $cookie = $this->getPrevLoginCookie( $user->getRequest() ); |
662 | [ , $newCookie ] = $this->checkAndGenerateCookie( $user, $cookie ); |
663 | $expire = $this->getCurrentTime() + $this->config->get( 'LoginNotifyCookieExpire' ); |
664 | $resp = $user->getRequest()->response(); |
665 | $resp->setCookie( |
666 | self::COOKIE_NAME, |
667 | $newCookie, |
668 | $expire, |
669 | [ |
670 | 'domain' => $this->config->get( 'LoginNotifyCookieDomain' ), |
671 | // Allow sharing this cookie between wikis |
672 | 'prefix' => '' |
673 | ] |
674 | ); |
675 | } |
676 | |
677 | /** |
678 | * Give the user a cookie and store the address in memcached and the DB. |
679 | * |
680 | * It is expected this be called upon successful log in. |
681 | * |
682 | * @param User $user The user in question. |
683 | */ |
684 | public function recordKnownWithCookie( User $user ) { |
685 | if ( !$user->isNamed() ) { |
686 | return; |
687 | } |
688 | $this->setLoginCookie( $user ); |
689 | $this->recordKnown( $user ); |
690 | } |
691 | |
692 | /** |
693 | * Store the user's IP address in memcached and the DB |
694 | * |
695 | * @param User $user |
696 | * @return void |
697 | */ |
698 | public function recordKnown( User $user ) { |
699 | if ( !$user->isNamed() ) { |
700 | return; |
701 | } |
702 | $this->cacheLoginIP( $user ); |
703 | $this->recordUserInSeenTable( $user ); |
704 | |
705 | $this->log->debug( 'Recording user {user} as known', |
706 | [ |
707 | 'function' => __METHOD__, |
708 | 'user' => $user->getName(), |
709 | ] |
710 | ); |
711 | } |
712 | |
713 | /** |
714 | * Cache the current IP subnet as being a known location for the given user. |
715 | * |
716 | * @param User $user The user. |
717 | */ |
718 | private function cacheLoginIP( User $user ) { |
719 | // For simplicity, this only stores the last IP subnet used. |
720 | // It's assumed that most of the time, we'll be able to rely on |
721 | // the cookie or CheckUser data. |
722 | $expiry = $this->config->get( 'LoginNotifyCacheLoginIPExpiry' ); |
723 | $useCU = $this->config->get( 'LoginNotifyUseCheckUser' ); |
724 | if ( $useCU && $expiry !== false ) { |
725 | $ipPrefix = $this->getIPNetwork( $user->getRequest()->getIP() ); |
726 | $key = $this->getKey( $user, 'prevSubnet' ); |
727 | $this->cache->set( $key, $ipPrefix, $expiry ); |
728 | } |
729 | } |
730 | |
731 | /** |
732 | * If the user/subnet combination is not already in the database, add it. |
733 | * Also queue a job to clean up expired rows, if necessary. |
734 | * |
735 | * @param User $user |
736 | * @return void |
737 | */ |
738 | private function recordUserInSeenTable( User $user ) { |
739 | if ( !$this->config->get( 'LoginNotifyUseSeenTable' ) ) { |
740 | return; |
741 | } |
742 | $id = $this->getMaybeCentralId( $user ); |
743 | if ( !$id ) { |
744 | return; |
745 | } |
746 | |
747 | $request = $user->getRequest(); |
748 | $hash = $this->getSeenHash( $request, $id ); |
749 | |
750 | // Check if the user/hash is in the replica DB |
751 | if ( $this->userIsInCurrentSeenBucket( $id, $hash ) ) { |
752 | return; |
753 | } |
754 | |
755 | // Check whether purging is required |
756 | if ( !mt_rand( 0, (int)( $this->config->get( 'UpdateRowsPerQuery' ) / 4 ) ) ) { |
757 | $minId = $this->getMinExpiredId(); |
758 | if ( $minId !== null ) { |
759 | $this->log->debug( 'Queueing purge job starting from lsn_id={minId}', |
760 | [ 'minId' => $minId ] ); |
761 | // Deferred call to purgeSeen() |
762 | // removeDuplicates effectively limits concurrency to 1, since |
763 | // no more work will be queued until the DELETE is committed. |
764 | $job = new JobSpecification( |
765 | 'LoginNotifyPurgeSeen', |
766 | [ 'minId' => $minId ], |
767 | [ 'removeDuplicates' => true ] |
768 | ); |
769 | $this->jobQueueGroup->push( $job ); |
770 | } |
771 | } |
772 | |
773 | // Insert a row |
774 | $dbw = $this->getSeenPrimaryDb(); |
775 | $fname = __METHOD__; |
776 | $dbw->onTransactionCommitOrIdle( |
777 | function () use ( $dbw, $id, $hash, $fname ) { |
778 | $dbw->newInsertQueryBuilder() |
779 | ->insert( 'loginnotify_seen_net' ) |
780 | ->ignore() |
781 | ->row( [ |
782 | 'lsn_time_bucket' => $this->getCurrentBucket(), |
783 | 'lsn_user' => $id, |
784 | 'lsn_subnet' => $hash |
785 | ] ) |
786 | ->caller( $fname ) |
787 | ->execute(); |
788 | } |
789 | ); |
790 | } |
791 | |
792 | /** |
793 | * Estimate the minimum lsn_id which has an expired time bucket. |
794 | * |
795 | * The primary key is approximately monotonic in time. Guess whether |
796 | * purging is required by looking at the first row ordered by |
797 | * primary key. If this check misses a row, it will be cleaned up |
798 | * when the next bucket expires. |
799 | * |
800 | * @return int|null |
801 | */ |
802 | public function getMinExpiredId() { |
803 | $minRow = $this->getSeenPrimaryDb()->newSelectQueryBuilder() |
804 | ->select( [ 'lsn_id', 'lsn_time_bucket' ] ) |
805 | ->from( 'loginnotify_seen_net' ) |
806 | ->orderBy( 'lsn_id' ) |
807 | ->limit( 1 ) |
808 | ->caller( __METHOD__ ) |
809 | ->fetchRow(); |
810 | |
811 | if ( $minRow && ( $minRow->lsn_time_bucket < $this->getMinBucket() ) ) { |
812 | return (int)$minRow->lsn_id; |
813 | } |
814 | |
815 | return null; |
816 | } |
817 | |
818 | /** |
819 | * Purge rows from the loginnotify_seen_net table that are expired. |
820 | * |
821 | * @param int $minId The lsn_id to start at |
822 | * @return int|null The lsn_id to continue at, or null if no more expired |
823 | * rows are expected. |
824 | */ |
825 | public function purgeSeen( $minId ) { |
826 | $dbw = $this->getSeenPrimaryDb(); |
827 | $maxId = $minId + $this->config->get( 'UpdateRowsPerQuery' ); |
828 | |
829 | $dbw->newDeleteQueryBuilder() |
830 | ->delete( 'loginnotify_seen_net' ) |
831 | ->where( [ |
832 | $dbw->expr( 'lsn_id', '>=', $minId ), |
833 | $dbw->expr( 'lsn_id', '<', $maxId ), |
834 | $dbw->expr( 'lsn_time_bucket', '<', $this->getMinBucket() ) |
835 | ] ) |
836 | ->caller( __METHOD__ ) |
837 | ->execute(); |
838 | |
839 | // If there were affected rows, tell the maintenance script to keep looking |
840 | if ( $dbw->affectedRows() ) { |
841 | return $maxId; |
842 | } else { |
843 | return null; |
844 | } |
845 | } |
846 | |
847 | /** |
848 | * Merges results of various isKnownSystem*() checks |
849 | * |
850 | * @param string $x One of USER_* constants |
851 | * @param string $y One of USER_* constants |
852 | * @return string |
853 | */ |
854 | private function mergeResults( $x, $y ) { |
855 | if ( $x === self::USER_KNOWN || $y === self::USER_KNOWN ) { |
856 | return self::USER_KNOWN; |
857 | } |
858 | if ( $x === self::USER_NOT_KNOWN || $y === self::USER_NOT_KNOWN ) { |
859 | return self::USER_NOT_KNOWN; |
860 | } |
861 | return self::USER_NO_INFO; |
862 | } |
863 | |
864 | /** |
865 | * Check if a certain user is in the cookie. |
866 | * |
867 | * @param User $user User in question |
868 | * @param WebRequest $request |
869 | * @return string One of USER_* constants |
870 | */ |
871 | private function userIsInCookie( User $user, WebRequest $request ) { |
872 | $cookie = $this->getPrevLoginCookie( $request ); |
873 | |
874 | if ( $cookie === '' ) { |
875 | $result = self::USER_NO_INFO; |
876 | } else { |
877 | [ $userKnown, ] = $this->checkAndGenerateCookie( $user, $cookie ); |
878 | $result = $userKnown ? self::USER_KNOWN : self::USER_NOT_KNOWN; |
879 | } |
880 | |
881 | return $result; |
882 | } |
883 | |
884 | /** |
885 | * Get the cookie with previous login names in it |
886 | * |
887 | * @param WebRequest $req |
888 | * @return string The cookie. Empty string if no cookie. |
889 | */ |
890 | private function getPrevLoginCookie( WebRequest $req ) { |
891 | return $req->getCookie( self::COOKIE_NAME, '', '' ); |
892 | } |
893 | |
894 | /** |
895 | * Check if user is in cookie, and generate a new cookie with user record |
896 | * |
897 | * When generating a new cookie, it will add the current user to the top, |
898 | * remove any previous instances of the current user, and remove older user |
899 | * references, if there are too many records. |
900 | * |
901 | * @param User $user User that person is attempting to log in as. |
902 | * @param string $cookie A cookie, which has records separated by '.'. |
903 | * @return array Element 0 is boolean (user seen before?), 1 is the new cookie value. |
904 | */ |
905 | private function checkAndGenerateCookie( User $user, $cookie ) { |
906 | $userSeenBefore = false; |
907 | if ( $cookie === '' ) { |
908 | $cookieRecords = []; |
909 | } else { |
910 | $cookieRecords = explode( '.', $cookie ); |
911 | } |
912 | $newCookie = $this->generateUserCookieRecord( $user->getName() ); |
913 | $maxCookieRecords = $this->config->get( 'LoginNotifyMaxCookieRecords' ); |
914 | |
915 | foreach ( $cookieRecords as $i => $cookieRecord ) { |
916 | if ( !$this->validateCookieRecord( $cookieRecord ) ) { |
917 | // Skip invalid or old cookie records. |
918 | continue; |
919 | } |
920 | $curUser = $this->isUserRecordGivenCookie( $user, $cookieRecord ); |
921 | $userSeenBefore = $userSeenBefore || $curUser; |
922 | if ( $i < $maxCookieRecords && !$curUser ) { |
923 | $newCookie .= '.' . $cookieRecord; |
924 | } |
925 | } |
926 | return [ $userSeenBefore, $newCookie ]; |
927 | } |
928 | |
929 | /** |
930 | * See if a specific cookie record is for a specific user. |
931 | * |
932 | * Cookie record format is: Year - 32-bit salt - hash |
933 | * where hash is sha1-HMAC of username + | + year + salt |
934 | * Salt and hash is base 36 encoded. |
935 | * |
936 | * The point of the salt is to ensure that a given user creates |
937 | * different cookies on different machines, so that nobody |
938 | * can after the fact figure out a single user has used both |
939 | * machines. |
940 | * |
941 | * @param User $user |
942 | * @param string $cookieRecord |
943 | * @return bool |
944 | */ |
945 | private function isUserRecordGivenCookie( User $user, $cookieRecord ) { |
946 | if ( !$this->validateCookieRecord( $cookieRecord ) ) { |
947 | // Most callers will probably already check this, but |
948 | // doesn't hurt to be careful. |
949 | return false; |
950 | } |
951 | $parts = explode( "-", $cookieRecord, 3 ); |
952 | $hash = $this->generateUserCookieRecord( $user->getName(), $parts[0], $parts[1] ); |
953 | return hash_equals( $hash, $cookieRecord ); |
954 | } |
955 | |
956 | /** |
957 | * Check if cookie is valid (Is not too old, has 3 fields) |
958 | * |
959 | * @param string $cookieRecord Cookie record |
960 | * @return bool true if valid |
961 | */ |
962 | private function validateCookieRecord( $cookieRecord ) { |
963 | $parts = explode( "-", $cookieRecord, 3 ); |
964 | if ( count( $parts ) !== 3 || strlen( $parts[0] ) !== 4 ) { |
965 | $this->log->warning( "Got cookie with invalid format", |
966 | [ |
967 | 'method' => __METHOD__, |
968 | 'cookieRecord' => $cookieRecord |
969 | ] |
970 | ); |
971 | return false; |
972 | } |
973 | if ( (int)$parts[0] < (int)gmdate( 'Y' ) - 3 ) { |
974 | // Record is too old. If user hasn't logged in from this |
975 | // computer in two years, should probably not consider it trusted. |
976 | return false; |
977 | } |
978 | return true; |
979 | } |
980 | |
981 | /** |
982 | * Generate a single record for use in the previous login cookie |
983 | * |
984 | * The format is YYYY-SSSSSSS-HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH |
985 | * where Y is the year, S is a 32-bit salt, H is an sha1-hmac. |
986 | * Both S and H are base-36 encoded. The actual cookie consists |
987 | * of several of these records separated by a ".". |
988 | * |
989 | * When checking if a hash is valid, provide all three arguments. |
990 | * When generating a new hash, only use the first argument. |
991 | * |
992 | * @param string $username Username, |
993 | * @param string|false $year [Optional] Year. Default to current year |
994 | * @param string|false $salt [Optional] Salt (expected to be base-36 encoded) |
995 | * @return string A record for the cookie |
996 | */ |
997 | private function generateUserCookieRecord( $username, $year = false, $salt = false ) { |
998 | if ( $year === false ) { |
999 | $year = gmdate( 'Y' ); |
1000 | } |
1001 | |
1002 | if ( $salt === false ) { |
1003 | $salt = $this->getSalt(); |
1004 | } |
1005 | |
1006 | // TODO: would be nice to truncate the hash, but we would need b/c |
1007 | $res = hash_hmac( 'sha1', $username . '|' . $year . $salt, $this->secret ); |
1008 | '@phan-var string|false $res'; |
1009 | if ( !is_string( $res ) ) { |
1010 | // Throws ValueError under php8 in case of error, remove this when mininum is php8 |
1011 | throw new UnexpectedValueException( "Hash failed" ); |
1012 | } |
1013 | $encoded = $year . '-' . $salt . '-' . \Wikimedia\base_convert( $res, 16, 36 ); |
1014 | return $encoded; |
1015 | } |
1016 | |
1017 | /** |
1018 | * Get the cache key for the counter. |
1019 | * |
1020 | * @param User $user |
1021 | * @param string $type 'known' or 'new' |
1022 | * @return string The cache key |
1023 | */ |
1024 | private function getKey( User $user, $type ) { |
1025 | $userHash = \Wikimedia\base_convert( sha1( $user->getName() ), 16, 36, 31 ); |
1026 | return $this->cache->makeGlobalKey( |
1027 | 'loginnotify', $type, $userHash |
1028 | ); |
1029 | } |
1030 | |
1031 | /** |
1032 | * Increment hit counters for a failed login from an unknown computer. |
1033 | * |
1034 | * If a sufficient number of hits have accumulated, send an echo notice. |
1035 | * |
1036 | * @param User $user |
1037 | */ |
1038 | private function recordLoginFailureFromUnknownSystem( User $user ) { |
1039 | $key = $this->getKey( $user, 'new' ); |
1040 | $count = $this->checkAndIncKey( |
1041 | $key, |
1042 | $this->config->get( 'LoginNotifyAttemptsNewIP' ), |
1043 | $this->config->get( 'LoginNotifyExpiryNewIP' ) |
1044 | ); |
1045 | $message = '{count} failed login attempts for {user} from an unknown system'; |
1046 | if ( $count ) { |
1047 | $this->incrStats( 'fail.unknown.notifications' ); |
1048 | $this->sendNotice( $user, 'login-fail-new', $count ); |
1049 | $message .= ', sending notification'; |
1050 | } |
1051 | |
1052 | $this->log->debug( $message, |
1053 | [ |
1054 | 'function' => __METHOD__, |
1055 | 'count' => $count, |
1056 | 'user' => $user->getName(), |
1057 | ] |
1058 | ); |
1059 | } |
1060 | |
1061 | /** |
1062 | * Increment hit counters for a failed login from a known computer. |
1063 | * |
1064 | * If a sufficient number of hits have accumulated, send an echo notice. |
1065 | * |
1066 | * @param User $user |
1067 | */ |
1068 | private function recordLoginFailureFromKnownSystem( User $user ) { |
1069 | $key = $this->getKey( $user, 'known' ); |
1070 | $count = $this->checkAndIncKey( |
1071 | $key, |
1072 | $this->config->get( 'LoginNotifyAttemptsKnownIP' ), |
1073 | $this->config->get( 'LoginNotifyExpiryKnownIP' ) |
1074 | ); |
1075 | if ( $count ) { |
1076 | $this->incrStats( 'fail.known.notifications' ); |
1077 | $this->sendNotice( $user, 'login-fail-known', $count ); |
1078 | } |
1079 | } |
1080 | |
1081 | /** |
1082 | * Send a notice about login attempts |
1083 | * |
1084 | * @param User $user The account in question |
1085 | * @param string $type 'login-fail-new' or 'login-fail-known' |
1086 | * @param int|null $count [Optional] How many failed attempts |
1087 | */ |
1088 | private function sendNotice( User $user, $type, $count = null ) { |
1089 | $extra = []; |
1090 | if ( $count !== null ) { |
1091 | $extra['count'] = $count; |
1092 | } |
1093 | Event::create( [ |
1094 | 'type' => $type, |
1095 | 'extra' => $extra, |
1096 | 'agent' => $user, |
1097 | ] ); |
1098 | |
1099 | $this->log->info( 'Sending a {notificationtype} notification to {user}', |
1100 | [ |
1101 | 'function' => __METHOD__, |
1102 | 'notificationtype' => $type, |
1103 | 'user' => $user->getName(), |
1104 | ] |
1105 | ); |
1106 | } |
1107 | |
1108 | /** |
1109 | * Check if we've reached the limit, and increment the cache key. |
1110 | * |
1111 | * @param string $key Cache key |
1112 | * @param int $interval The interval of one to send notice |
1113 | * @param int $expiry When to expire cache key. |
1114 | * @return false|int false to not send notice, or number of hits |
1115 | */ |
1116 | private function checkAndIncKey( $key, $interval, $expiry ) { |
1117 | $cache = $this->cache; |
1118 | |
1119 | $cur = $cache->incrWithInit( $key, $expiry ); |
1120 | if ( $cur % $interval === 0 ) { |
1121 | return $cur; |
1122 | } |
1123 | return false; |
1124 | } |
1125 | |
1126 | /** |
1127 | * Clear attempt counter for user. |
1128 | * |
1129 | * When a user successfully logs in, we start back from 0, as |
1130 | * otherwise a mistake here and there will trigger the warning. |
1131 | * |
1132 | * @param User $user The user for whom to clear the attempt counter. |
1133 | */ |
1134 | public function clearCounters( User $user ) { |
1135 | $cache = $this->cache; |
1136 | $keyKnown = $this->getKey( $user, 'known' ); |
1137 | $keyNew = $this->getKey( $user, 'new' ); |
1138 | |
1139 | $cache->delete( $keyKnown ); |
1140 | $cache->delete( $keyNew ); |
1141 | } |
1142 | |
1143 | /** |
1144 | * On login failure, record failure and maybe send notice |
1145 | * |
1146 | * @param User $user User in question |
1147 | */ |
1148 | public function recordFailure( User $user ) { |
1149 | $this->incrStats( 'fail.total' ); |
1150 | |
1151 | if ( $user->isAnon() ) { |
1152 | // Login failed because user doesn't exist |
1153 | // skip this user. |
1154 | $this->log->debug( "Skipping recording failure for {user} - no account", |
1155 | [ 'user' => $user->getName() ] |
1156 | ); |
1157 | return; |
1158 | } |
1159 | |
1160 | // No need to notify if the user can't authenticate (e.g. system or temporary users) |
1161 | if ( !$this->authManager->userCanAuthenticate( $user->getName() ) ) { |
1162 | $this->log->debug( "Skipping recording failure for user {user} - can't authenticate", |
1163 | [ 'user' => $user->getName() ] |
1164 | ); |
1165 | return; |
1166 | } |
1167 | |
1168 | $known = $this->isKnownSystemFast( $user, $user->getRequest() ); |
1169 | if ( $known === self::USER_KNOWN ) { |
1170 | $this->recordLoginFailureFromKnownSystem( $user ); |
1171 | } elseif ( $this->config->get( 'LoginNotifyUseCheckUser' ) ) { |
1172 | $this->createJob( DeferredChecksJob::TYPE_LOGIN_FAILED, |
1173 | $user, $user->getRequest(), $known |
1174 | ); |
1175 | } else { |
1176 | $this->recordLoginFailureFromUnknownSystem( $user ); |
1177 | } |
1178 | } |
1179 | |
1180 | /** |
1181 | * Asynchronous part of recordFailure(), to be called from DeferredChecksJob |
1182 | * |
1183 | * @param User $user User in question |
1184 | * @param string $subnet User's current subnet |
1185 | * @param string $resultSoFar Value returned by isKnownSystemFast() |
1186 | */ |
1187 | public function recordFailureDeferred( User $user, $subnet, $resultSoFar ) { |
1188 | $isKnown = $this->isKnownSystemSlow( $user, $subnet, $resultSoFar ); |
1189 | if ( !$isKnown ) { |
1190 | $this->recordLoginFailureFromUnknownSystem( $user ); |
1191 | } else { |
1192 | $this->recordLoginFailureFromKnownSystem( $user ); |
1193 | } |
1194 | } |
1195 | |
1196 | /** |
1197 | * Send a notice on successful login from an unknown IP |
1198 | * |
1199 | * @param User $user User account in question. |
1200 | */ |
1201 | public function sendSuccessNotice( User $user ) { |
1202 | if ( !$this->config->get( 'LoginNotifyEnableOnSuccess' ) ) { |
1203 | return; |
1204 | } |
1205 | $this->incrStats( 'success.total' ); |
1206 | $result = $this->isKnownSystemFast( $user, $user->getRequest() ); |
1207 | if ( $result === self::USER_KNOWN ) { |
1208 | // No need to notify |
1209 | } elseif ( $this->config->get( 'LoginNotifyUseCheckUser' ) ) { |
1210 | $this->createJob( DeferredChecksJob::TYPE_LOGIN_SUCCESS, |
1211 | $user, $user->getRequest(), $result |
1212 | ); |
1213 | } elseif ( $result === self::USER_NOT_KNOWN ) { |
1214 | $this->incrStats( 'success.notifications' ); |
1215 | $this->sendNotice( $user, 'login-success' ); |
1216 | } |
1217 | } |
1218 | |
1219 | /** |
1220 | * Asynchronous part of sendSuccessNotice(), to be called from DeferredChecksJob |
1221 | * |
1222 | * @param User $user User in question |
1223 | * @param string $subnet User's current subnet |
1224 | * @param string $resultSoFar Value returned by isKnownSystemFast() |
1225 | */ |
1226 | public function sendSuccessNoticeDeferred( User $user, $subnet, $resultSoFar ) { |
1227 | $isKnown = $this->isKnownSystemSlow( $user, $subnet, $resultSoFar ); |
1228 | if ( $isKnown ) { |
1229 | $this->log->debug( 'Found data for user {user} from {subnet}', |
1230 | [ |
1231 | 'function' => __METHOD__, |
1232 | 'user' => $user->getName(), |
1233 | 'subnet' => $subnet, |
1234 | ] |
1235 | ); |
1236 | } else { |
1237 | $this->incrStats( 'success.notifications' ); |
1238 | $this->sendNotice( $user, 'login-success' ); |
1239 | } |
1240 | } |
1241 | |
1242 | /** |
1243 | * Create and enqueue a job to do asynchronous processing of user login success/failure |
1244 | * |
1245 | * @param string $type Job type, one of DeferredChecksJob::TYPE_* constants |
1246 | * @param User $user User in question |
1247 | * @param WebRequest $request |
1248 | * @param string $resultSoFar Value returned by isKnownSystemFast() |
1249 | */ |
1250 | private function createJob( $type, User $user, WebRequest $request, $resultSoFar ) { |
1251 | $subnet = $this->getIPNetwork( $request->getIP() ); |
1252 | $job = new JobSpecification( 'LoginNotifyChecks', |
1253 | [ |
1254 | 'checkType' => $type, |
1255 | 'userId' => $user->getId(), |
1256 | 'subnet' => $subnet, |
1257 | 'resultSoFar' => $resultSoFar, |
1258 | ] |
1259 | ); |
1260 | $this->jobQueueGroup->lazyPush( $job ); |
1261 | |
1262 | $this->log->debug( 'Login {status}, creating a job to verify {user}, result so far: {result}', |
1263 | [ |
1264 | 'function' => __METHOD__, |
1265 | 'status' => $type, |
1266 | 'user' => $user->getName(), |
1267 | 'result' => $resultSoFar, |
1268 | ] |
1269 | ); |
1270 | } |
1271 | |
1272 | /** |
1273 | * Increments the given statistic |
1274 | * |
1275 | * @param string $metric |
1276 | */ |
1277 | private function incrStats( $metric ) { |
1278 | $this->stats->increment( "loginnotify.$metric" ); |
1279 | } |
1280 | } |