Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.35% |
110 / 113 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
CentralAuthIpReputationPreAuthenticationProvider | |
97.35% |
110 / 113 |
|
66.67% |
2 / 3 |
22 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
testForAccountCreation | |
94.74% |
54 / 57 |
|
0.00% |
0 / 1 |
14.03 | |||
getIpoidDataFor | |
100.00% |
51 / 51 |
|
100.00% |
1 / 1 |
7 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\CentralAuth; |
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 | * @see https://wikitech.wikimedia.org/wiki/Service/IPoid |
21 | */ |
22 | class CentralAuthIpReputationPreAuthenticationProvider extends AbstractPreAuthenticationProvider { |
23 | |
24 | private HttpRequestFactory $httpRequestFactory; |
25 | private StatsdDataFactoryInterface $statsDataFactory; |
26 | private WANObjectCache $cache; |
27 | private FormatterFactory $formatterFactory; |
28 | private PermissionManager $permissionManager; |
29 | |
30 | public function __construct( |
31 | FormatterFactory $formatterFactory, |
32 | HttpRequestFactory $httpRequestFactory, |
33 | WANObjectCache $cache, |
34 | StatsdDataFactoryInterface $statsDataFactory, |
35 | PermissionManager $permissionManager |
36 | ) { |
37 | $this->formatterFactory = $formatterFactory; |
38 | $this->httpRequestFactory = $httpRequestFactory; |
39 | $this->cache = $cache; |
40 | $this->statsDataFactory = $statsDataFactory; |
41 | $this->permissionManager = $permissionManager; |
42 | } |
43 | |
44 | /** @inheritDoc */ |
45 | public function testForAccountCreation( $user, $creator, array $reqs ) { |
46 | // If feature flag is off, don't do any checks, let the user proceed. |
47 | if ( !$this->config->get( 'CentralAuthIpoidCheckAtAccountCreation' ) ) { |
48 | return StatusValue::newGood(); |
49 | } |
50 | |
51 | if ( |
52 | $this->permissionManager->userHasAnyRight( |
53 | $creator, |
54 | 'ipblock-exempt', |
55 | ) |
56 | ) { |
57 | return StatusValue::newGood(); |
58 | } |
59 | |
60 | $this->logger = LoggerFactory::getInstance( 'IpReputation' ); |
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, so let the authentication request proceed. |
67 | return StatusValue::newGood(); |
68 | } |
69 | |
70 | $shouldLogOnly = $this->config->get( 'CentralAuthIpoidCheckAtAccountCreationLogOnly' ); |
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( 'CentralAuthIpoidDenyAccountCreationRiskTypes' ); |
83 | $tunnelTypesToBlock = $this->config->get( 'CentralAuthIpoidDenyAccountCreationTunnelTypes' ); |
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 | // $wgCentralAuthIpoidDenyAccountCreationRiskTypes = [ 'TUNNEL', 'CALLBACK_PROXY', ... ]; |
96 | // $wgCentralAuthIpoidDenyAccountCreationTunnelTypes = [ '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 | $this->logger->notice( |
124 | 'Blocking account creation for user {user} as IP {ip} is known to iPoid ' |
125 | . 'with risks {riskTypes} (blocked due to {blockedRiskTypes})', |
126 | [ |
127 | 'user' => $user->getName(), |
128 | 'ip' => $ip, |
129 | 'riskTypes' => implode( ' ', $risks ), |
130 | 'blockedRiskTypes' => implode( ' ', $blockedRisks ), |
131 | 'ipoidData' => json_encode( $data ), |
132 | ] |
133 | ); |
134 | |
135 | $statsdKey = $shouldLogOnly ? 'DenyCreationLogOnly' : 'DenyCreation'; |
136 | $this->statsDataFactory->increment( "CentralAuth.IpReputation.$statsdKey." . implode( '_', $risks ) ); |
137 | |
138 | if ( $shouldLogOnly ) { |
139 | return StatusValue::newGood(); |
140 | } |
141 | |
142 | return StatusValue::newFatal( ApiMessage::create( 'centralauth-blocked-ip-reputation', 'autoblocked' ) ); |
143 | } |
144 | |
145 | return StatusValue::newGood(); |
146 | } |
147 | |
148 | /** |
149 | * @param UserIdentity $user |
150 | * @param string $ip |
151 | * |
152 | * @return array|null iPoid data for the specific address, or null if there is no data |
153 | */ |
154 | private function getIpoidDataFor( UserIdentity $user, string $ip ): ?array { |
155 | $data = $this->cache->getWithSetCallback( |
156 | $this->cache->makeGlobalKey( 'centralauth-ipoid', $ip ), |
157 | // ipoid data is refreshed every 24 hours and roughly 10% of its IPs drop out |
158 | // of the database each 24-hour cycle. A one hour TTL seems reasonable to allow |
159 | // no longer problematic IPs to get evicted from the cache relatively quickly, |
160 | // and also means that IPs for e.g. residential proxies are updated in our cache |
161 | // relatively quickly. |
162 | $this->cache::TTL_HOUR, |
163 | function () use ( $ip, $user ) { |
164 | // If ipoid URL isn't configured, don't do any checks, let the user proceed. |
165 | $baseUrl = $this->config->get( 'CentralAuthIpoidUrl' ); |
166 | if ( !$baseUrl ) { |
167 | $this->logger->warning( |
168 | 'Configured to check IP reputation on signup, but no iPoid URL configured' |
169 | ); |
170 | // Don't cache this. |
171 | return false; |
172 | } |
173 | |
174 | $timeout = $this->config->get( 'CentralAuthIpoidRequestTimeoutSeconds' ); |
175 | // Convert IPv6 to lowercase, to match ipoid storage format. |
176 | $url = $baseUrl . '/feed/v1/ip/' . IPUtils::prettifyIP( $ip ); |
177 | $request = $this->httpRequestFactory->create( $url, [ |
178 | 'method' => 'GET', |
179 | 'timeout' => $timeout, |
180 | 'connectTimeout' => 1, |
181 | ] ); |
182 | $response = $request->execute(); |
183 | if ( !$response->isOK() ) { |
184 | // Probably a 404, which means ipoid doesn't know about the IP. |
185 | // If not a 404, log it, so we can figure out what happened. |
186 | if ( $request->getStatus() !== 404 ) { |
187 | $statusFormatter = $this->formatterFactory->getStatusFormatter( RequestContext::getMain() ); |
188 | [ $errorText, $context ] = $statusFormatter->getPsr3MessageAndContext( $response ); |
189 | $this->logger->error( $errorText, $context ); |
190 | } |
191 | |
192 | return null; |
193 | } |
194 | |
195 | $data = json_decode( $request->getContent(), true ); |
196 | |
197 | if ( !$data ) { |
198 | // Malformed data. |
199 | $this->logger->error( |
200 | 'Got invalid JSON data while checking user {user} with IP {ip}', |
201 | [ |
202 | 'ip' => $ip, |
203 | 'user' => $user->getName(), |
204 | 'response' => $request->getContent() |
205 | ] |
206 | ); |
207 | return null; |
208 | } |
209 | |
210 | // ipoid will return the IP in lower case format, and we are searching for the |
211 | // indexed value in the returned array. |
212 | if ( !isset( $data[IPUtils::prettifyIP( $ip )] ) ) { |
213 | // IP should always be set in the data array, but just to be safe. |
214 | $this->logger->error( |
215 | 'Got JSON data with no IP {ip} present while checking user {user}', |
216 | [ |
217 | 'ip' => $ip, |
218 | 'user' => $user->getName(), |
219 | 'response' => $request->getContent() |
220 | ] |
221 | ); |
222 | return null; |
223 | } |
224 | |
225 | // We have a match and valid data structure; |
226 | // return the values for this IP for storage in the cache. |
227 | return $data[$ip]; |
228 | } |
229 | ); |
230 | |
231 | // Unlike null, false tells cache not to cache something. Normalize both to null before returning. |
232 | if ( $data === false ) { |
233 | return null; |
234 | } |
235 | |
236 | return $data; |
237 | } |
238 | } |