Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.37% |
111 / 114 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
PreAuthenticationProvider | |
97.37% |
111 / 114 |
|
66.67% |
2 / 3 |
23 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
testForAccountCreation | |
94.74% |
54 / 57 |
|
0.00% |
0 / 1 |
15.03 | |||
getIPoidDataFor | |
100.00% |
51 / 51 |
|
100.00% |
1 / 1 |
7 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\IPReputation; |
4 | |
5 | use ApiMessage; |
6 | use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; |
7 | use MediaWiki\Auth\AbstractPreAuthenticationProvider; |
8 | use MediaWiki\Http\HttpRequestFactory; |
9 | use MediaWiki\Language\FormatterFactory; |
10 | use MediaWiki\Logger\LoggerFactory; |
11 | use MediaWiki\Permissions\PermissionManager; |
12 | use MediaWiki\User\UserIdentity; |
13 | use RequestContext; |
14 | use StatusValue; |
15 | use WANObjectCache; |
16 | use Wikimedia\IPUtils; |
17 | |
18 | /** |
19 | * PreAuthentication provider that checks if an IP address is known to IPoid |
20 | * |
21 | * @see https://wikitech.wikimedia.org/wiki/Service/IPoid |
22 | */ |
23 | class PreAuthenticationProvider extends AbstractPreAuthenticationProvider { |
24 | |
25 | private HttpRequestFactory $httpRequestFactory; |
26 | private StatsdDataFactoryInterface $statsDataFactory; |
27 | private WANObjectCache $cache; |
28 | private FormatterFactory $formatterFactory; |
29 | private PermissionManager $permissionManager; |
30 | |
31 | public function __construct( |
32 | FormatterFactory $formatterFactory, |
33 | HttpRequestFactory $httpRequestFactory, |
34 | WANObjectCache $cache, |
35 | StatsdDataFactoryInterface $statsDataFactory, |
36 | PermissionManager $permissionManager |
37 | ) { |
38 | $this->formatterFactory = $formatterFactory; |
39 | $this->httpRequestFactory = $httpRequestFactory; |
40 | $this->cache = $cache; |
41 | $this->statsDataFactory = $statsDataFactory; |
42 | $this->permissionManager = $permissionManager; |
43 | $this->logger = LoggerFactory::getInstance( 'IPReputation' ); |
44 | } |
45 | |
46 | /** @inheritDoc */ |
47 | public function testForAccountCreation( $user, $creator, array $reqs ) { |
48 | // If feature flag is off, don't do any checks, let the user proceed. |
49 | if ( !$this->config->get( 'IPReputationIPoidCheckAtAccountCreation' ) ) { |
50 | return StatusValue::newGood(); |
51 | } |
52 | |
53 | if ( |
54 | $this->permissionManager->userHasAnyRight( |
55 | $creator, |
56 | 'ipblock-exempt', |
57 | ) |
58 | ) { |
59 | return StatusValue::newGood(); |
60 | } |
61 | |
62 | $ip = $this->manager->getRequest()->getIP(); |
63 | |
64 | $data = $this->getIPoidDataFor( $user, $ip ); |
65 | if ( !$data ) { |
66 | // IPoid doesn't know anything about this IP; let the authentication request proceed. |
67 | return StatusValue::newGood(); |
68 | } |
69 | |
70 | $shouldLogOnly = $this->config->get( 'IPReputationIPoidCheckAtAccountCreationLogOnly' ); |
71 | |
72 | if ( !isset( $data['risks'] ) || !$data['risks'] ) { |
73 | // 'risks' should always be set and populated, but if not set to 'UNKNOWN'. |
74 | $data['risks'] = [ 'UNKNOWN' ]; |
75 | } |
76 | |
77 | if ( !isset( $data['tunnels'] ) ) { |
78 | // 'tunnels' should always be set, but if not set to empty list. |
79 | $data['tunnels'] = []; |
80 | } |
81 | |
82 | $risksToBlock = $this->config->get( 'IPReputationIPoidDenyAccountCreationRiskTypes' ); |
83 | $tunnelTypesToBlock = $this->config->get( 'IPReputationIPoidDenyAccountCreationTunnelTypes' ); |
84 | |
85 | $risks = $data['risks']; |
86 | sort( $risks ); |
87 | |
88 | $tunnels = $data['tunnels']; |
89 | sort( $tunnels ); |
90 | |
91 | // Allow for the possibility to exclude VPN users from having account |
92 | // creation denied, if the only risk type known for the IP is that it's a VPN, |
93 | // and if config is set up to allow VPN tunnel types. |
94 | // That would be done with: |
95 | // $wgIPReputationDenyAccountCreationRiskTypes = [ 'TUNNEL', 'CALLBACK_PROXY', ... ]; |
96 | // $wgIPReputationDenyAccountCreationTunnelTypes = [ 'PROXY', 'UNKNOWN' ]; |
97 | // If the only risk type is a TUNNEL... |
98 | if ( |
99 | $risks === [ 'TUNNEL' ] |
100 | // and there are tunnels listed for the IP |
101 | && count( $tunnels ) |
102 | // and we have configured TUNNEL as a risk type to block |
103 | && in_array( 'TUNNEL', $risksToBlock ) |
104 | // and the configured tunnel types to block are *not* present in the data |
105 | && !array_intersect( $tunnelTypesToBlock, $tunnels ) |
106 | ) { |
107 | $this->logger->debug( |
108 | 'Allowing account creation for user {user} as IP {ip} is known to IPoid ' |
109 | . 'with only non-blocked tunnels ({tunnelTypes})', |
110 | [ |
111 | 'user' => $user->getName(), |
112 | 'ip' => $ip, |
113 | 'tunnelTypes' => implode( ' ', $tunnels ), |
114 | 'IPoidData' => json_encode( $data ), |
115 | ] |
116 | ); |
117 | return StatusValue::newGood(); |
118 | } |
119 | |
120 | // Otherwise, check for other risks. |
121 | $blockedRisks = array_intersect( $risksToBlock, $risks ); |
122 | if ( $blockedRisks ) { |
123 | $prefixText = $shouldLogOnly ? '[log only] Would have blocked ' : 'Blocking '; |
124 | $this->logger->notice( |
125 | $prefixText . 'account creation for user {user} as IP {ip} is known to IPoid ' |
126 | . 'with risks {riskTypes} (blocked due to {blockedRiskTypes})', |
127 | [ |
128 | 'user' => $user->getName(), |
129 | 'ip' => $ip, |
130 | 'riskTypes' => implode( ' ', $risks ), |
131 | 'blockedRiskTypes' => implode( ' ', $blockedRisks ), |
132 | 'IPoidData' => json_encode( $data ), |
133 | ] |
134 | ); |
135 | |
136 | $statsdKey = $shouldLogOnly ? 'DenyAccountCreationLogOnly' : 'DenyAccountCreation'; |
137 | $this->statsDataFactory->increment( "IPReputation.$statsdKey." . implode( '_', $risks ) ); |
138 | |
139 | if ( $shouldLogOnly ) { |
140 | return StatusValue::newGood(); |
141 | } |
142 | |
143 | return StatusValue::newFatal( ApiMessage::create( 'ipreputation-blocked-ip-reputation', 'autoblocked' ) ); |
144 | } |
145 | |
146 | return StatusValue::newGood(); |
147 | } |
148 | |
149 | /** |
150 | * @param UserIdentity $user |
151 | * @param string $ip |
152 | * |
153 | * @return array|null IPoid data for the specific address, or null if there is no data |
154 | */ |
155 | private function getIPoidDataFor( UserIdentity $user, string $ip ): ?array { |
156 | $data = $this->cache->getWithSetCallback( |
157 | $this->cache->makeGlobalKey( 'ipreputation-ipoid', $ip ), |
158 | // IPoid data is refreshed every 24 hours and roughly 10% of its IPs drop out |
159 | // of the database each 24-hour cycle. A one hour TTL seems reasonable to allow |
160 | // no longer problematic IPs to get evicted from the cache relatively quickly, |
161 | // and also means that IPs for e.g. residential proxies are updated in our cache |
162 | // relatively quickly. |
163 | $this->cache::TTL_HOUR, |
164 | function () use ( $ip, $user ) { |
165 | // If IPoid URL isn't configured, don't do any checks, let the user proceed. |
166 | $baseUrl = $this->config->get( 'IPReputationIPoidUrl' ); |
167 | if ( !$baseUrl ) { |
168 | $this->logger->warning( |
169 | 'Configured to check IP reputation on signup, but no IPoid URL configured' |
170 | ); |
171 | // Don't cache this. |
172 | return false; |
173 | } |
174 | |
175 | $timeout = $this->config->get( 'IPReputationIPoidRequestTimeoutSeconds' ); |
176 | // Convert IPv6 to lowercase, to match IPoid storage format. |
177 | $url = $baseUrl . '/feed/v1/ip/' . IPUtils::prettifyIP( $ip ); |
178 | $request = $this->httpRequestFactory->create( $url, [ |
179 | 'method' => 'GET', |
180 | 'timeout' => $timeout, |
181 | 'connectTimeout' => 1, |
182 | ] ); |
183 | $response = $request->execute(); |
184 | if ( !$response->isOK() ) { |
185 | // Probably a 404, which means IPoid doesn't know about the IP. |
186 | // If not a 404, log it, so we can figure out what happened. |
187 | if ( $request->getStatus() !== 404 ) { |
188 | $statusFormatter = $this->formatterFactory->getStatusFormatter( RequestContext::getMain() ); |
189 | [ $errorText, $context ] = $statusFormatter->getPsr3MessageAndContext( $response ); |
190 | $this->logger->error( $errorText, $context ); |
191 | } |
192 | |
193 | return null; |
194 | } |
195 | |
196 | $data = json_decode( $request->getContent(), true ); |
197 | |
198 | if ( !$data ) { |
199 | // Malformed data. |
200 | $this->logger->error( |
201 | 'Got invalid JSON data while checking user {user} with IP {ip}', |
202 | [ |
203 | 'ip' => $ip, |
204 | 'user' => $user->getName(), |
205 | 'response' => $request->getContent() |
206 | ] |
207 | ); |
208 | return null; |
209 | } |
210 | |
211 | // IPoid will return the IP in lower case format, and we are searching for the |
212 | // indexed value in the returned array. |
213 | if ( !isset( $data[IPUtils::prettifyIP( $ip )] ) ) { |
214 | // IP should always be set in the data array, but just to be safe. |
215 | $this->logger->error( |
216 | 'Got JSON data with no IP {ip} present while checking user {user}', |
217 | [ |
218 | 'ip' => $ip, |
219 | 'user' => $user->getName(), |
220 | 'response' => $request->getContent() |
221 | ] |
222 | ); |
223 | return null; |
224 | } |
225 | |
226 | // We have a match and valid data structure; |
227 | // return the values for this IP for storage in the cache. |
228 | return $data[$ip]; |
229 | } |
230 | ); |
231 | |
232 | // Unlike null, false tells cache not to cache something. Normalize both to null before returning. |
233 | if ( $data === false ) { |
234 | return null; |
235 | } |
236 | |
237 | return $data; |
238 | } |
239 | } |