Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 123
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ForeignWikiRequest
0.00% covered (danger)
0.00%
0 / 123
0.00% covered (danger)
0.00%
0 / 9
930
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getCentralId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 canUseCentralAuth
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 getCentralAuthToken
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 getCsrfToken
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 getRequestParams
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 getQueryParams
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 doRequests
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace MediaWiki\Extension\Notifications;
4
5use ApiMain;
6use CentralAuthSessionProvider;
7use Exception;
8use MediaWiki\Logger\LoggerFactory;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\Request\FauxRequest;
11use MediaWiki\Request\WebRequest;
12use MediaWiki\Session\SessionManager;
13use MediaWiki\User\CentralId\CentralIdLookup;
14use MediaWiki\User\User;
15use MediaWiki\User\UserIdentity;
16use MediaWiki\WikiMap\WikiMap;
17use MWExceptionHandler;
18use RequestContext;
19
20class ForeignWikiRequest {
21
22    /** @var User */
23    protected $user;
24
25    /** @var array */
26    protected $params;
27
28    /** @var array */
29    protected $wikis;
30
31    /** @var string|null */
32    protected $wikiParam;
33
34    /** @var string */
35    protected $method;
36
37    /** @var string|null */
38    protected $tokenType;
39
40    /** @var string[]|null */
41    protected $csrfTokens;
42
43    /**
44     * @param User $user
45     * @param array $params Request parameters
46     * @param array $wikis Wikis to send the request to
47     * @param string|null $wikiParam Parameter name to set to the name of the wiki
48     * @param string|null $postToken If set, use POST requests and inject a token of this type;
49     *  if null, use GET requests.
50     */
51    public function __construct( User $user, array $params, array $wikis, $wikiParam = null, $postToken = null ) {
52        $this->user = $user;
53        $this->params = $params;
54        $this->wikis = $wikis;
55        $this->wikiParam = $wikiParam;
56        $this->method = $postToken === null ? 'GET' : 'POST';
57        $this->tokenType = $postToken;
58
59        $this->csrfTokens = null;
60    }
61
62    /**
63     * Execute the request
64     * @param WebRequest|null $originalRequest Original request data to be sent with these requests
65     * @return array[] [ wiki => result ]
66     */
67    public function execute( ?WebRequest $originalRequest = null ) {
68        if ( !$this->canUseCentralAuth() ) {
69            return [];
70        }
71
72        $reqs = $this->getRequestParams(
73            $this->method,
74            function ( string $wiki ) use ( $originalRequest ) {
75                return $this->getQueryParams( $wiki, $originalRequest );
76            },
77            $originalRequest
78        );
79        return $this->doRequests( $reqs );
80    }
81
82    /**
83     * @param UserIdentity $user
84     * @return int
85     */
86    protected function getCentralId( $user ) {
87        return MediaWikiServices::getInstance()
88            ->getCentralIdLookup()
89            ->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
90    }
91
92    protected function canUseCentralAuth() {
93        global $wgFullyInitialised;
94
95        return $wgFullyInitialised &&
96            RequestContext::getMain()->getUser()->isSafeToLoad() &&
97            $this->user->isSafeToLoad() &&
98            SessionManager::getGlobalSession()->getProvider() instanceof CentralAuthSessionProvider &&
99            $this->getCentralId( $this->user ) !== 0;
100    }
101
102    /**
103     * Returns CentralAuth token, or null on failure.
104     *
105     * @param User $user
106     * @return string|null
107     */
108    protected function getCentralAuthToken( User $user ) {
109        $context = new RequestContext;
110        $context->setRequest( new FauxRequest( [ 'action' => 'centralauthtoken' ] ) );
111        $context->setUser( $user );
112
113        $api = new ApiMain( $context );
114
115        try {
116            $api->execute();
117
118            return $api->getResult()->getResultData( [ 'centralauthtoken', 'centralauthtoken' ] );
119        } catch ( Exception $ex ) {
120            LoggerFactory::getInstance( 'Echo' )->debug(
121                'Exception when fetching CentralAuth token: wiki: {wiki}, userName: {userName}, ' .
122                    'userId: {userId}, centralId: {centralId}, exception: {exception}',
123                [
124                    'wiki' => WikiMap::getCurrentWikiId(),
125                    'userName' => $user->getName(),
126                    'userId' => $user->getId(),
127                    'centralId' => $this->getCentralId( $user ),
128                    'exception' => $ex,
129                ]
130            );
131
132            MWExceptionHandler::logException( $ex );
133
134            return null;
135        }
136    }
137
138    /**
139     * Get the CSRF token for a given wiki.
140     * This method fetches the tokens for all requested wikis at once and caches the result.
141     *
142     * @param string $wiki Name of the wiki to get a token for
143     * @param WebRequest|null $originalRequest Original request data to be sent with these requests
144     * @return string Token, or empty string if an unable to retrieve the token.
145     */
146    protected function getCsrfToken( $wiki, ?WebRequest $originalRequest ) {
147        if ( $this->csrfTokens === null ) {
148            $this->csrfTokens = [];
149            $reqs = $this->getRequestParams( 'GET', function ( string $wiki ) {
150                // This doesn't depend on the wiki, but 'centralauthtoken' must be different every time
151                return [
152                    'action' => 'query',
153                    'meta' => 'tokens',
154                    'type' => $this->tokenType,
155                    'format' => 'json',
156                    'formatversion' => '1',
157                    'errorformat' => 'bc',
158                    'centralauthtoken' => $this->getCentralAuthToken( $this->user ),
159                ];
160            }, $originalRequest );
161            $responses = $this->doRequests( $reqs );
162            foreach ( $responses as $w => $response ) {
163                if ( isset( $response['query']['tokens']['csrftoken'] ) ) {
164                    $this->csrfTokens[$w] = $response['query']['tokens']['csrftoken'];
165                } else {
166                    LoggerFactory::getInstance( 'Echo' )->warning(
167                        __METHOD__ . ': Unexpected CSRF token API response from {wiki}',
168                        [
169                            'wiki' => $wiki,
170                            'response' => $response,
171                        ]
172                    );
173                }
174            }
175        }
176        return $this->csrfTokens[$wiki] ?? '';
177    }
178
179    /**
180     * @param string $method 'GET' or 'POST'
181     * @param callable $makeParams Callback that takes a wiki name and returns an associative array of
182     *  query string / POST parameters
183     * @param WebRequest|null $originalRequest Original request data to be sent with these requests
184     * @return array[] Array of request parameters to pass to doRequests(), keyed by wiki name
185     */
186    protected function getRequestParams( $method, $makeParams, ?WebRequest $originalRequest ) {
187        $apis = ForeignNotifications::getApiEndpoints( $this->wikis );
188        if ( !$apis ) {
189            return [];
190        }
191
192        $reqs = [];
193        foreach ( $apis as $wiki => $api ) {
194            $queryKey = $method === 'POST' ? 'body' : 'query';
195            $reqs[$wiki] = [
196                'method' => $method,
197                'url' => $api['url'],
198                $queryKey => $makeParams( $wiki )
199            ];
200
201            if ( $originalRequest ) {
202                $reqs[$wiki]['headers'] = [
203                    'X-Forwarded-For' => $originalRequest->getIP(),
204                    'User-Agent' => (
205                        $originalRequest->getHeader( 'User-Agent' )
206                        . ' (via ForeignWikiRequest MediaWiki/' . MW_VERSION . ')'
207                    ),
208                ];
209            }
210        }
211
212        return $reqs;
213    }
214
215    /**
216     * @param string $wiki Wiki name
217     * @param WebRequest|null $originalRequest Original request data to be sent with these requests
218     * @return array
219     */
220    protected function getQueryParams( $wiki, ?WebRequest $originalRequest ) {
221        $extraParams = [];
222        if ( $this->wikiParam ) {
223            // Only request data from that specific wiki, or they'd all spawn
224            // cross-wiki api requests...
225            $extraParams[$this->wikiParam] = $wiki;
226        }
227        if ( $this->method === 'POST' ) {
228            $extraParams['token'] = $this->getCsrfToken( $wiki, $originalRequest );
229        }
230
231        return [
232            'centralauthtoken' => $this->getCentralAuthToken( $this->user ),
233            // once all the results are gathered & merged, they'll be output in the
234            // user requested format
235            // but this is going to be an internal request & we don't want those
236            // results in the format the user requested but in a fixed format that
237            // we can interpret here
238            'format' => 'json',
239            'formatversion' => '1',
240            'errorformat' => 'bc',
241        ] + $extraParams + $this->params;
242    }
243
244    /**
245     * @param array $reqs API request params
246     * @return array[]
247     * @throws Exception
248     */
249    protected function doRequests( array $reqs ) {
250        $http = MediaWikiServices::getInstance()->getHttpRequestFactory()->createMultiClient();
251        $responses = $http->runMulti( $reqs );
252
253        $results = [];
254        foreach ( $responses as $wiki => $response ) {
255            $statusCode = $response['response']['code'];
256
257            if ( $statusCode >= 200 && $statusCode <= 299 ) {
258                $parsed = json_decode( $response['response']['body'], true );
259                if ( $parsed ) {
260                    $results[$wiki] = $parsed;
261                }
262            }
263
264            if ( !isset( $results[$wiki] ) ) {
265                LoggerFactory::getInstance( 'Echo' )->warning(
266                    'Failed to fetch API response from {wiki}. Error: {error}',
267                    [
268                        'wiki' => $wiki,
269                        'error' => $response['response']['error'] ?? 'unknown',
270                        'statusCode' => $statusCode,
271                        'response' => $response['response'],
272                        'request' => $reqs[$wiki],
273                    ]
274                );
275            }
276        }
277
278        return $results;
279    }
280}