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