Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.35% covered (success)
97.35%
110 / 113
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralAuthIpReputationPreAuthenticationProvider
97.35% covered (success)
97.35%
110 / 113
66.67% covered (warning)
66.67%
2 / 3
22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 testForAccountCreation
94.74% covered (success)
94.74%
54 / 57
0.00% covered (danger)
0.00%
0 / 1
14.03
 getIpoidDataFor
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3namespace MediaWiki\Extension\CentralAuth;
4
5use ApiMessage;
6use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
7use MediaWiki\Auth\AbstractPreAuthenticationProvider;
8use MediaWiki\Http\HttpRequestFactory;
9use MediaWiki\Language\FormatterFactory;
10use MediaWiki\Logger\LoggerFactory;
11use MediaWiki\Permissions\PermissionManager;
12use MediaWiki\User\UserIdentity;
13use RequestContext;
14use StatusValue;
15use WANObjectCache;
16use 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 */
22class 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}