Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.68% covered (success)
98.68%
75 / 76
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Throttler
98.68% covered (success)
98.68%
75 / 76
85.71% covered (warning)
85.71%
6 / 7
27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 increase
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
12
 clear
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getThrottleKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 normalizeThrottleConditions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 logRejection
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Auth;
8
9use InvalidArgumentException;
10use MediaWiki\Context\RequestContext;
11use MediaWiki\HookContainer\HookRunner;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\MainConfigNames;
14use MediaWiki\MediaWikiServices;
15use Psr\Log\LoggerAwareInterface;
16use Psr\Log\LoggerInterface;
17use Psr\Log\LogLevel;
18use Wikimedia\ObjectCache\BagOStuff;
19
20/**
21 * A helper class for throttling authentication attempts.
22 *
23 * @ingroup Auth
24 * @since 1.27
25 */
26class Throttler implements LoggerAwareInterface {
27
28    /** @var string */
29    protected $type;
30
31    /**
32     * See documentation of $wgPasswordAttemptThrottle for format. Old (pre-1.27) format is not
33     * allowed here.
34     * @var array[]
35     * @see https://www.mediawiki.org/wiki/Manual:$wgPasswordAttemptThrottle
36     */
37    protected $conditions;
38
39    /** @var int|float */
40    protected $warningLimit;
41
42    protected BagOStuff $cache;
43    protected LoggerInterface $logger;
44    private HookRunner $hookRunner;
45
46    /**
47     * @param array|null $conditions An array of arrays describing throttling conditions.
48     *     Defaults to $wgPasswordAttemptThrottle. See documentation of that variable for format.
49     * @param array $params Parameters (all optional):
50     *   - type: throttle type, used as a namespace for counters,
51     *   - cache: a BagOStuff object where throttle counters are stored.
52     *   - warningLimit: the log level will be raised to warning when rejecting an attempt after
53     *     no less than this many failures.
54     */
55    public function __construct( ?array $conditions = null, array $params = [] ) {
56        $invalidParams = array_diff_key( $params,
57            array_fill_keys( [ 'type', 'cache', 'warningLimit' ], true ) );
58        if ( $invalidParams ) {
59            throw new InvalidArgumentException( 'unrecognized parameters: '
60                . implode( ', ', array_keys( $invalidParams ) ) );
61        }
62
63        $services = MediaWikiServices::getInstance();
64        $this->hookRunner = new HookRunner( $services->getHookContainer() );
65
66        $objectCacheFactory = $services->getObjectCacheFactory();
67
68        if ( $conditions === null ) {
69            $config = $services->getMainConfig();
70            $conditions = $config->get( MainConfigNames::PasswordAttemptThrottle );
71            $params += [
72                'type' => 'password',
73                'cache' => $objectCacheFactory->getLocalClusterInstance(),
74                'warningLimit' => 50,
75            ];
76        } else {
77            $params += [
78                'type' => 'custom',
79                'cache' => $objectCacheFactory->getLocalClusterInstance(),
80                'warningLimit' => INF,
81            ];
82        }
83
84        $this->type = $params['type'];
85        $this->conditions = static::normalizeThrottleConditions( $conditions );
86        $this->cache = $params['cache'];
87        $this->warningLimit = $params['warningLimit'];
88
89        $this->setLogger( LoggerFactory::getInstance( 'throttler' ) );
90    }
91
92    public function setLogger( LoggerInterface $logger ): void {
93        $this->logger = $logger;
94    }
95
96    /**
97     * Increase the throttle counter and return whether the attempt should be throttled.
98     *
99     * Should be called before an authentication attempt.
100     *
101     * @param string|null $username
102     * @param string|null $ip
103     * @param string|null $caller The authentication method from which we were called.
104     * @return array|false False if the attempt should not be throttled, an associative array
105     *   with three keys otherwise:
106     *   - throttleIndex: which throttle condition was met (a key of the conditions array)
107     *   - count: throttle count (ie. number of failed attempts)
108     *   - wait: time in seconds until authentication can be attempted
109     */
110    public function increase( $username = null, $ip = null, $caller = null ) {
111        if ( $username === null && $ip === null ) {
112            throw new InvalidArgumentException( 'Either username or IP must be set for throttling' );
113        }
114
115        $userKey = $username ? md5( $username ) : null;
116        foreach ( $this->conditions as $index => $throttleCondition ) {
117            $ipKey = isset( $throttleCondition['allIPs'] ) ? null : $ip;
118            $count = $throttleCondition['count'];
119            $expiry = $throttleCondition['seconds'];
120
121            // a limit of 0 is used as a disable flag in some throttling configuration settings
122            // throttling the whole world is probably a bad idea
123            if ( !$count || ( $userKey === null && $ipKey === null ) ) {
124                continue;
125            }
126
127            $throttleKey = $this->getThrottleKey( $this->type, $index, $ipKey, $userKey );
128            $throttleCount = $this->cache->get( $throttleKey );
129            if ( $throttleCount && $throttleCount >= $count ) {
130                // Throttle limited reached
131                $this->logRejection( [
132                    'throttle' => $this->type,
133                    'index' => $index,
134                    'ipKey' => $ipKey,
135                    'username' => $username,
136                    'count' => $count,
137                    'expiry' => $expiry,
138                    // @codeCoverageIgnoreStart
139                    'method' => $caller ?: __METHOD__,
140                    // @codeCoverageIgnoreEnd
141                ] );
142
143                // Allow extensions to perform actions when a throttle causes throttling.
144                $this->hookRunner->onAuthenticationAttemptThrottled( $this->type, $username, $ip );
145
146                return [ 'throttleIndex' => $index, 'count' => $count, 'wait' => $expiry ];
147            } else {
148                $this->cache->incrWithInit( $throttleKey, $expiry, 1 );
149            }
150        }
151
152        return false;
153    }
154
155    /**
156     * Clear the throttle counter.
157     *
158     * Should be called after a successful authentication attempt.
159     *
160     * @param string|null $username
161     * @param string|null $ip
162     */
163    public function clear( $username = null, $ip = null ) {
164        $userKey = $username ? md5( $username ) : null;
165        foreach ( $this->conditions as $index => $specificThrottle ) {
166            $ipKey = isset( $specificThrottle['allIPs'] ) ? null : $ip;
167            $throttleKey = $this->getThrottleKey( $this->type, $index, $ipKey, $userKey );
168            $this->cache->delete( $throttleKey );
169        }
170    }
171
172    /**
173     * Construct a cache key for the throttle counter
174     * @param string $type
175     * @param int $index
176     * @param string|null $ipKey
177     * @param string|null $userKey
178     * @return string
179     */
180    private function getThrottleKey( string $type, int $index, ?string $ipKey, ?string $userKey ): string {
181        return $this->cache->makeGlobalKey(
182            'throttler',
183            $type,
184            $index,
185            $ipKey ?? '',
186            $userKey ?? ''
187        );
188    }
189
190    /**
191     * Handles B/C for $wgPasswordAttemptThrottle.
192     * @param array $throttleConditions
193     * @return array[]
194     * @see $wgPasswordAttemptThrottle for structure
195     */
196    protected static function normalizeThrottleConditions( $throttleConditions ) {
197        if ( !is_array( $throttleConditions ) ) {
198            return [];
199        }
200        if ( isset( $throttleConditions['count'] ) ) { // old style
201            $throttleConditions = [ $throttleConditions ];
202        }
203        return $throttleConditions;
204    }
205
206    protected function logRejection( array $context ) {
207        $logMsg = 'Throttle {throttle} hit, throttled for {expiry} seconds due to {count} attempts '
208            . 'from username {username} and IP {ipKey}';
209
210        // If we are hitting a throttle for >= warningLimit attempts, it is much more likely to be
211        // an attack than someone simply forgetting their password, so log it at a higher level.
212        $level = $context['count'] >= $this->warningLimit ? LogLevel::WARNING : LogLevel::INFO;
213
214        // It should be noted that once the throttle is hit, every attempt to login will
215        // generate the log message until the throttle expires, not just the attempt that
216        // puts the throttle over the top.
217        $user = $context['username'] ?
218            MediaWikiServices::getInstance()->getUserFactory()->newFromName( $context['username'] ) :
219            null;
220        $context += RequestContext::getMain()->getRequest()->getSecurityLogContext( $user );
221        $this->logger->log( $level, $logMsg, $context );
222    }
223
224}