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 Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; |
6 | use MediaWiki\Api\ApiMessage; |
7 | use MediaWiki\Auth\AbstractPreAuthenticationProvider; |
8 | use MediaWiki\Context\RequestContext; |
9 | use MediaWiki\Extension\CentralAuth\Config\CAMainConfigNames; |
10 | use MediaWiki\Http\HttpRequestFactory; |
11 | use MediaWiki\Language\FormatterFactory; |
12 | use MediaWiki\Logger\LoggerFactory; |
13 | use MediaWiki\Permissions\PermissionManager; |
14 | use MediaWiki\User\UserIdentity; |
15 | use StatusValue; |
16 | use Wikimedia\IPUtils; |
17 | use Wikimedia\ObjectCache\WANObjectCache; |
18 | |
19 | /** |
20 | * PreAuthentication provider that checks if an IP address is known to ipoid |
21 | * @see https://wikitech.wikimedia.org/wiki/Service/IPoid |
22 | */ |
23 | class CentralAuthIpReputationPreAuthenticationProvider 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 | } |
44 | |
45 | /** @inheritDoc */ |
46 | public function testForAccountCreation( $user, $creator, array $reqs ) { |
47 | // If feature flag is off, don't do any checks, let the user proceed. |
48 | if ( !$this->config->get( CAMainConfigNames::CentralAuthIpoidCheckAtAccountCreation ) ) { |
49 | return StatusValue::newGood(); |
50 | } |
51 | |
52 | if ( |
53 | $this->permissionManager->userHasAnyRight( |
54 | $creator, |
55 | 'ipblock-exempt', |
56 | ) |
57 | ) { |
58 | return StatusValue::newGood(); |
59 | } |
60 | |
61 | $this->logger = LoggerFactory::getInstance( 'IpReputation' ); |
62 | |
63 | $ip = $this->manager->getRequest()->getIP(); |
64 | |
65 | $data = $this->getIpoidDataFor( $user, $ip ); |
66 | if ( !$data ) { |
67 | // ipoid doesn't know anything about this IP, so let the authentication request proceed. |
68 | return StatusValue::newGood(); |
69 | } |
70 | |
71 | $shouldLogOnly = $this->config->get( CAMainConfigNames::CentralAuthIpoidCheckAtAccountCreationLogOnly ); |
72 | |
73 | if ( !isset( $data['risks'] ) || !$data['risks'] ) { |
74 | // 'risks' should always be set and populated, but if not set to 'UNKNOWN'. |
75 | $data['risks'] = [ 'UNKNOWN' ]; |
76 | } |
77 | |
78 | if ( !isset( $data['tunnels'] ) ) { |
79 | // 'tunnels' should always be set, but if not set to empty list. |
80 | $data['tunnels'] = []; |
81 | } |
82 | |
83 | $risksToBlock = $this->config->get( CAMainConfigNames::CentralAuthIpoidDenyAccountCreationRiskTypes ); |
84 | $tunnelTypesToBlock = $this->config->get( CAMainConfigNames::CentralAuthIpoidDenyAccountCreationTunnelTypes ); |
85 | |
86 | $risks = $data['risks']; |
87 | sort( $risks ); |
88 | |
89 | $tunnels = $data['tunnels']; |
90 | sort( $tunnels ); |
91 | |
92 | // Allow for the possibility to exclude VPN users from having account |
93 | // creation denied, if the only risk type known for the IP is that it's a VPN, |
94 | // and if config is set up to allow VPN tunnel types. |
95 | // That would be done with: |
96 | // $wgCentralAuthIpoidDenyAccountCreationRiskTypes = [ 'TUNNEL', 'CALLBACK_PROXY', ... ]; |
97 | // $wgCentralAuthIpoidDenyAccountCreationTunnelTypes = [ 'PROXY', 'UNKNOWN' ]; |
98 | // If the only risk type is a TUNNEL... |
99 | if ( |
100 | $risks === [ 'TUNNEL' ] |
101 | // and there are tunnels listed for the IP |
102 | && count( $tunnels ) |
103 | // and we have configured TUNNEL as a risk type to block |
104 | && in_array( 'TUNNEL', $risksToBlock ) |
105 | // and the configured tunnel types to block are *not* present in the data |
106 | && !array_intersect( $tunnelTypesToBlock, $tunnels ) |
107 | ) { |
108 | $this->logger->debug( |
109 | 'Allowing account creation for user {user} as IP {ip} is known to iPoid ' |
110 | . 'with only non-blocked tunnels ({tunnelTypes})', |
111 | [ |
112 | 'user' => $user->getName(), |
113 | 'ip' => $ip, |
114 | 'tunnelTypes' => implode( ' ', $tunnels ), |
115 | 'ipoidData' => json_encode( $data ), |
116 | ] |
117 | ); |
118 | return StatusValue::newGood(); |
119 | } |
120 | |
121 | // Otherwise, check for other risks. |
122 | $blockedRisks = array_intersect( $risksToBlock, $risks ); |
123 | if ( $blockedRisks ) { |
124 | $this->logger->notice( |
125 | 'Blocking 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 ? 'DenyCreationLogOnly' : 'DenyCreation'; |
137 | $this->statsDataFactory->increment( "CentralAuth.IpReputation.$statsdKey." . implode( '_', $risks ) ); |
138 | |
139 | if ( $shouldLogOnly ) { |
140 | return StatusValue::newGood(); |
141 | } |
142 | |
143 | return StatusValue::newFatal( ApiMessage::create( 'centralauth-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 | $fname = __METHOD__; |
157 | $data = $this->cache->getWithSetCallback( |
158 | $this->cache->makeGlobalKey( 'centralauth-ipoid', $ip ), |
159 | // ipoid data is refreshed every 24 hours and roughly 10% of its IPs drop out |
160 | // of the database each 24-hour cycle. A one hour TTL seems reasonable to allow |
161 | // no longer problematic IPs to get evicted from the cache relatively quickly, |
162 | // and also means that IPs for e.g. residential proxies are updated in our cache |
163 | // relatively quickly. |
164 | $this->cache::TTL_HOUR, |
165 | function () use ( $ip, $user, $fname ) { |
166 | // If ipoid URL isn't configured, don't do any checks, let the user proceed. |
167 | $baseUrl = $this->config->get( CAMainConfigNames::CentralAuthIpoidUrl ); |
168 | if ( !$baseUrl ) { |
169 | $this->logger->warning( |
170 | 'Configured to check IP reputation on signup, but no iPoid URL configured' |
171 | ); |
172 | // Don't cache this. |
173 | return false; |
174 | } |
175 | |
176 | $timeout = $this->config->get( CAMainConfigNames::CentralAuthIpoidRequestTimeoutSeconds ); |
177 | // Convert IPv6 to lowercase, to match ipoid storage format. |
178 | $url = $baseUrl . '/feed/v1/ip/' . IPUtils::prettifyIP( $ip ); |
179 | $request = $this->httpRequestFactory->create( $url, [ |
180 | 'method' => 'GET', |
181 | 'timeout' => $timeout, |
182 | 'connectTimeout' => 1, |
183 | ], $fname ); |
184 | $response = $request->execute(); |
185 | if ( !$response->isOK() ) { |
186 | // Probably a 404, which means ipoid doesn't know about the IP. |
187 | // If not a 404, log it, so we can figure out what happened. |
188 | if ( $request->getStatus() !== 404 ) { |
189 | $statusFormatter = $this->formatterFactory->getStatusFormatter( RequestContext::getMain() ); |
190 | $this->logger->error( ...$statusFormatter->getPsr3MessageAndContext( $response ) ); |
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 | } |