Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.66% covered (warning)
79.66%
380 / 477
64.00% covered (warning)
64.00%
32 / 50
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoginNotify
79.66% covered (warning)
79.66%
380 / 477
64.00% covered (warning)
64.00%
32 / 50
291.54
0.00% covered (danger)
0.00%
0 / 1
 getInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIPNetwork
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 getSalt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isKnownSystemFast
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
6.02
 isKnownSystemSlow
61.90% covered (warning)
61.90%
13 / 21
0.00% covered (danger)
0.00%
0 / 1
2.22
 userIsInCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 userIsInSeenTable
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 userIsInCurrentSeenBucket
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
 getSeenHash
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 packedSignedInt64ToDecimal
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
19.47
 getSeenReplicaDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSeenPrimaryDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMinBucket
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentBucket
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFakeTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaybeCentralId
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 checkUserAllWikis
31.82% covered (danger)
31.82%
14 / 44
0.00% covered (danger)
0.00%
0 / 1
108.60
 checkUserOneWiki
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 userHasCheckUserData
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 hasCheckUserTables
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 isCheckUserInstalled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLoginCookie
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 recordKnownWithCookie
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 recordKnown
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 cacheLoginIP
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 recordUserInSeenTable
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
6.01
 getMinExpiredId
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 purgeSeen
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 mergeResults
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 userIsInCookie
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 getPrevLoginCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkAndGenerateCookie
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 isUserRecordGivenCookie
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 validateCookieRecord
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 generateUserCookieRecord
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 recordLoginFailureFromUnknownSystem
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 recordLoginFailureFromKnownSystem
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 sendNotice
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 checkAndIncKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 clearCounters
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 recordFailure
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
5.23
 recordFailureDeferred
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sendSuccessNotice
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 sendSuccessNoticeDeferred
36.36% covered (danger)
36.36%
4 / 11
0.00% covered (danger)
0.00%
0 / 1
3.03
 createJob
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 incrStats
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Body of LoginNotify extension
4 *
5 * @file
6 * @ingroup Extensions
7 */
8
9namespace LoginNotify;
10
11use BagOStuff;
12use ExtensionRegistry;
13use IBufferingStatsdDataFactory;
14use JobQueueGroup;
15use JobSpecification;
16use LogicException;
17use MediaWiki\Auth\AuthManager;
18use MediaWiki\Config\ServiceOptions;
19use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
20use MediaWiki\Extension\Notifications\Model\Event;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\Request\WebRequest;
23use MediaWiki\User\CentralId\CentralIdLookup;
24use MediaWiki\User\User;
25use MediaWiki\WikiMap\WikiMap;
26use MWCryptRand;
27use Psr\Log\LoggerAwareInterface;
28use Psr\Log\LoggerInterface;
29use UnexpectedValueException;
30use Wikimedia\Assert\Assert;
31use Wikimedia\IPUtils;
32use Wikimedia\Rdbms\IDatabase;
33use Wikimedia\Rdbms\IExpression;
34use Wikimedia\Rdbms\IMaintainableDatabase;
35use Wikimedia\Rdbms\IReadableDatabase;
36use Wikimedia\Rdbms\LBFactory;
37use Wikimedia\Rdbms\LikeValue;
38
39/**
40 * Handle sending notifications on login from unknown source.
41 *
42 * @author Brian Wolff
43 */
44class 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}