Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.18% covered (danger)
5.18%
10 / 193
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
5.18% covered (danger)
5.18%
10 / 193
6.25% covered (danger)
6.25%
1 / 16
2925.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newFromKeyAndSecret
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setExtraParam
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setExtraParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initiate
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 complete
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 identify
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 makeOAuthCall
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
90
 makeCurlCall
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
90
 decodeJWT
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
11.28
 validateJWT
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 urlsafeB64Decode
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 compareHash
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 decodeJson
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * @section LICENSE
4 * This file is part of the MediaWiki OAuth Client library
5 *
6 * The MediaWiki OAuth Client libraryis free software: you can
7 * redistribute it and/or modify it under the terms of the GNU General Public
8 * License as published by the Free Software Foundation, either version 3 of
9 * the License, or (at your option) any later version.
10 *
11 * The MediaWiki OAuth Client library is distributed in the hope that it
12 * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
13 * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 * General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with the MediaWiki OAuth Client library. If not, see
18 * <http://www.gnu.org/licenses/>.
19 *
20 * @file
21 * @copyright © 2015 Chris Steipp, Wikimedia Foundation and contributors.
22 */
23
24namespace MediaWiki\OAuthClient;
25
26use MediaWiki\OAuthClient\SignatureMethod\HmacSha1;
27use Psr\Log\LoggerAwareInterface;
28use Psr\Log\LoggerInterface;
29use Psr\Log\NullLogger;
30use stdClass;
31
32/**
33 * MediaWiki OAuth client.
34 */
35class Client implements LoggerAwareInterface {
36
37    /**
38     * Number of seconds by which IAT (token issue time) can be larger than current time, to account
39     * for clock drift.
40     * @var int
41     */
42    public const IAT_TOLERANCE = 2;
43
44    /**
45     * @var LoggerInterface
46     */
47    protected $logger;
48
49    /**
50     * @var ClientConfig
51     */
52    private $config;
53
54    /**
55     * Any extra params in the call that need to be signed
56     * @var array
57     */
58    private $extraParams = [];
59
60    /**
61     * url, defaults to oob
62     * @var string
63     */
64    private $callbackUrl = 'oob';
65
66    /**
67     * Track the last random nonce generated by the OAuth lib, used to verify
68     * /identity response isn't a replay
69     * @var string
70     */
71    private $lastNonce;
72
73    /**
74     * @param ClientConfig $config
75     * @param LoggerInterface|null $logger
76     */
77    public function __construct(
78        ClientConfig $config,
79        ?LoggerInterface $logger = null
80    ) {
81        $this->config = $config;
82        $this->logger = $logger ?: new NullLogger();
83    }
84
85    /**
86     * @inheritDoc
87     */
88    public function setLogger( LoggerInterface $logger ): void {
89        $this->logger = $logger;
90    }
91
92    /**
93     * @param string $url
94     * @param string $key
95     * @param string $secret
96     * @return Client
97     */
98    public static function newFromKeyAndSecret( $url, $key, $secret ) {
99        $config = new ClientConfig( $url, true, true );
100        $config->setConsumer( new Consumer( $key, $secret ) );
101        return new static( $config );
102    }
103
104    /**
105     * Set an extra param in the call that need to be signed.
106     * This should only be needed for OAuth internals.
107     * @param string $key
108     * @param string $value
109     */
110    public function setExtraParam( $key, $value ) {
111        $this->extraParams[$key] = $value;
112    }
113
114    /**
115     * @param array $params
116     * @see setExtraParam
117     */
118    public function setExtraParams( array $params ) {
119        $this->extraParams = $params;
120    }
121
122    /**
123     * Set callback URL for OAuth handshake
124     * @param string $url
125     */
126    public function setCallback( $url ) {
127        $this->callbackUrl = $url;
128    }
129
130    /**
131     * First part of 3-legged OAuth, get the request Token.
132     * Redirect your authorizing users to the redirect url, and keep
133     * track of the request token since you need to pass it into complete()
134     *
135     * @return array [redirect, request/temp token]
136     * @throws Exception When the server returns an error or a malformed response
137     */
138    public function initiate() {
139        $initUrl = $this->config->endpointURL .
140            '/initiate&format=json&oauth_callback=' .
141            urlencode( $this->callbackUrl );
142        $data = $this->makeOAuthCall( null, $initUrl );
143        $return = $this->decodeJson( $data );
144        if ( property_exists( $return, 'error' ) ) {
145            $this->logger->error(
146                'OAuth server error {error}: {msg}',
147                [ 'error' => $return->error, 'msg' => $return->message ]
148            );
149            throw new Exception( "Server returned error: $return->message" );
150        }
151        if ( !property_exists( $return, 'oauth_callback_confirmed' ) ||
152            $return->oauth_callback_confirmed !== 'true'
153        ) {
154            throw new Exception( "Callback wasn't confirmed" );
155        }
156        $requestToken = new Token( $return->key, $return->secret );
157        $subPage = $this->config->authenticateOnly ? 'authenticate' : 'authorize';
158        $url = $this->config->redirURL ?:
159            ( $this->config->endpointURL . "/" . $subPage . "&" );
160        $url .= "oauth_token={$requestToken->key}&oauth_consumer_key={$this->config->consumer->key}";
161        return [ $url, $requestToken ];
162    }
163
164    /**
165     * The final leg of the OAuth handshake. Exchange the request Token from
166     * initiate() and the verification code that the user submitted back to you
167     * for an access token, which you'll use for all API calls.
168     *
169     * @param Token $requestToken Authorization code sent to the callback url
170     * @param string $verifyCode Temp/request token obtained from initiate, or null if this
171     *     object was used and the token is already set.
172     * @return Token The access token
173     * @throws Exception On failed handshakes
174     */
175    public function complete( Token $requestToken, $verifyCode ) {
176        $tokenUrl = $this->config->endpointURL . '/token&format=json';
177        $this->setExtraParam( 'oauth_verifier', $verifyCode );
178
179        $data = $this->makeOAuthCall( $requestToken, $tokenUrl );
180        $return = $this->decodeJson( $data );
181
182        if ( property_exists( $return, 'error' ) ) {
183            $this->logger->error(
184                'OAuth server error {error}: {msg}',
185                [ 'error' => $return->error, 'msg' => $return->message ]
186            );
187            throw new Exception(
188                "Handshake error: $return->message ($return->error)"
189            );
190        } elseif (
191            !property_exists( $return, 'key' ) ||
192            !property_exists( $return, 'secret' )
193        ) {
194            $this->logger->error(
195                'Could not parse OAuth server response: {data}',
196                [ 'data' => $data ]
197            );
198            throw new Exception(
199                "Server response missing expected values (Raw response: $data)"
200            );
201        }
202        $accessToken = new Token( $return->key, $return->secret );
203        // Cleanup after ourselves
204        $this->setExtraParams( [] );
205        return $accessToken;
206    }
207
208    /**
209     * Optional step. This call the MediaWiki specific /identify method, which
210     * returns a signed statement of the authorizing user's identity. Use this
211     * if you are authenticating users in your application, and you need to
212     * know their username, groups, rights, etc in MediaWiki.
213     *
214     * @param Token $accessToken Access token from complete()
215     * @return stdClass An object containing attributes of the user
216     * @throws Exception On malformed server response or invalid JWT
217     */
218    public function identify( Token $accessToken ) {
219        $identifyUrl = $this->config->endpointURL . '/identify';
220        $data = $this->makeOAuthCall( $accessToken, $identifyUrl );
221        $identity = $this->decodeJWT( $data, $this->config->consumer->secret );
222        if ( !$this->validateJWT(
223            $identity,
224            $this->config->consumer->key,
225            $this->config->canonicalServerUrl,
226            $this->lastNonce
227        ) ) {
228            throw new Exception( "JWT didn't validate" );
229        }
230        return $identity;
231    }
232
233    /**
234     * Make a signed request to MediaWiki
235     *
236     * @param Token $token additional token to use in signature, besides
237     *     the consumer token. In most cases, this will be the access token you
238     *     got from complete(), but we set it to the request token when
239     *     finishing the handshake.
240     * @param string $url URL to call
241     * @param bool $isPost true if this should be a POST request
242     * @param array|null $postFields POST parameters, only if $isPost is also true
243     * @return string Body from the curl request
244     * @throws Exception On curl failure
245     */
246    public function makeOAuthCall(
247        /*Token*/ $token, $url, $isPost = false, ?array $postFields = null
248    ) {
249        // Figure out if there is a file in postFields
250        $hasFile = false;
251        if ( is_array( $postFields ) ) {
252            foreach ( $postFields as $field ) {
253                if ( is_a( $field, 'CurlFile' ) ) {
254                    $hasFile = true;
255                    break;
256                }
257            }
258        }
259
260        $params = [];
261        // Get any params from the url
262        if ( strpos( $url, '?' ) ) {
263            $parsed = parse_url( $url );
264            parse_str( $parsed['query'], $params );
265        }
266        $params += $this->extraParams;
267        if ( $isPost && $postFields && !$hasFile ) {
268            $params += $postFields;
269        }
270        $method = $isPost ? 'POST' : 'GET';
271        $req = Request::fromConsumerAndToken(
272            $this->config->consumer,
273            $token,
274            $method,
275            $url,
276            $params
277        );
278        $req->signRequest(
279            new HmacSha1(),
280            $this->config->consumer,
281            $token
282        );
283        $this->lastNonce = $req->getParameter( 'oauth_nonce' );
284        return $this->makeCurlCall(
285            $url,
286            $req->toHeader(),
287            $isPost,
288            $postFields,
289            $hasFile
290        );
291    }
292
293    /**
294     * @param string $url
295     * @param array $authorizationHeader
296     * @param bool $isPost
297     * @param array|null $postFields
298     * @param bool $hasFile
299     * @return string
300     * @throws Exception On curl failure
301     */
302    private function makeCurlCall(
303        $url, $authorizationHeader, $isPost, ?array $postFields = null, $hasFile = false
304    ) {
305        if ( !$hasFile && $postFields ) {
306            $postFields = http_build_query( $postFields );
307        }
308
309        $ch = curl_init();
310        curl_setopt( $ch, CURLOPT_URL, (string)$url );
311        curl_setopt( $ch, CURLOPT_HEADER, 0 );
312        curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
313
314        $headers = [
315            $authorizationHeader
316        ];
317        $userAgent = " MediaWikiOAuthClient (https://www.mediawiki.org/wiki/Oauthclient-php)";
318        if ( $this->config->userAgent !== null ) {
319            $userAgent = $this->config->userAgent . $userAgent;
320        }
321        $headers[] = 'User-Agent: ' . $userAgent;
322        curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
323
324        if ( $isPost ) {
325            curl_setopt( $ch, CURLOPT_POST, true );
326            curl_setopt( $ch, CURLOPT_POSTFIELDS, $postFields );
327        }
328        if ( $this->config->useSSL ) {
329            curl_setopt( $ch, CURLOPT_PORT, 443 );
330        }
331        if ( $this->config->verifySSL ) {
332            curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
333            curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 2 );
334        } else {
335            curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
336            curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 );
337        }
338        $data = curl_exec( $ch );
339        if ( !$data ) {
340            if ( curl_errno( $ch ) ) {
341                throw new Exception( 'Curl error: ' . curl_error( $ch ) );
342            } else {
343                throw new Exception( 'Empty HTTP response! Status: '
344                    . curl_getinfo( $ch, CURLINFO_HTTP_CODE ) );
345            }
346        }
347        return $data;
348    }
349
350    /**
351     * @param string $JWT Json web token
352     * @param string $secret
353     * @return stdClass
354     * @throws Exception On invalid JWT signature
355     */
356    private function decodeJWT( $JWT, $secret ) {
357        $jwtParts = explode( '.', $JWT );
358        if ( count( $jwtParts ) !== 3 ) {
359            throw new Exception( "JWT has incorrect format. Received: $JWT" );
360        }
361        [ $headb64, $bodyb64, $sigb64 ] = $jwtParts;
362        $header = $this->decodeJson( $this->urlsafeB64Decode( $headb64 ) );
363        $payload = $this->decodeJson( $this->urlsafeB64Decode( $bodyb64 ) );
364        $sig = $this->urlsafeB64Decode( $sigb64 );
365        // MediaWiki will only use sha256 hmac (HS256) for now. This check
366        // makes sure an attacker doesn't return a JWT with 'none' signature
367        // type.
368        $expectSig = hash_hmac(
369            'sha256', "{$headb64}.{$bodyb64}", $secret, true
370        );
371        if ( $header->alg !== 'HS256' || !$this->compareHash( $sig, $expectSig ) ) {
372            throw new Exception( "Invalid JWT signature from /identify." );
373        }
374        return $payload;
375    }
376
377    /**
378     * @param stdClass $identity
379     * @param string $consumerKey
380     * @param string $expectedConnonicalServer
381     * @param string $nonce
382     * @return bool
383     */
384    protected function validateJWT(
385        $identity, $consumerKey, $expectedConnonicalServer, $nonce
386    ) {
387        // Verify the issuer is who we expect (server sends $wgCanonicalServer)
388        if ( $identity->iss !== $expectedConnonicalServer ) {
389            $this->logger->info(
390                "Invalid issuer '{$identity->iss}': expected '{$expectedConnonicalServer}'" );
391            return false;
392        }
393        // Verify we are the intended audience
394        if ( $identity->aud !== $consumerKey ) {
395            $this->logger->info( "Invalid audience '{$identity->aud}': expected '{$consumerKey}'" );
396            return false;
397        }
398        // Verify we are within the time limits of the token. Issued at (iat)
399        // should be in the past, Expiration (exp) should be in the future.
400        $now = time();
401        if ( $identity->iat > $now + static::IAT_TOLERANCE || $identity->exp < $now ) {
402            $this->logger->info(
403                "Invalid times issued='{$identity->iat}', " .
404                "expires='{$identity->exp}', now='{$now}'"
405            );
406            return false;
407        }
408        // Verify we haven't seen this nonce before, which would indicate a replay attack
409        if ( $identity->nonce !== $nonce ) {
410            $this->logger->info( "Invalid nonce '{$identity->nonce}': expected '{$nonce}'" );
411            return false;
412        }
413        return true;
414    }
415
416    /**
417     * @param string $input
418     * @return string
419     * @throws Exception If the input could not be decoded.
420     */
421    private function urlsafeB64Decode( $input ) {
422        // Pad the input with equals characters to the right to make it the correct length.
423        $remainder = strlen( $input ) % 4;
424        if ( $remainder ) {
425            $padlen = 4 - $remainder;
426            $input .= str_repeat( '=', $padlen );
427        }
428        // Decode the string.
429        $decoded = base64_decode( strtr( $input, '-_', '+/' ), true );
430        if ( $decoded === false ) {
431            throw new Exception( "Unable to decode base64 value: $input" );
432        }
433        return $decoded;
434    }
435
436    /**
437     * Constant time comparison
438     * @param string $hash1
439     * @param string $hash2
440     * @return bool
441     */
442    private function compareHash( $hash1, $hash2 ) {
443        $result = strlen( $hash1 ) ^ strlen( $hash2 );
444        $len = min( strlen( $hash1 ), strlen( $hash2 ) ) - 1;
445        for ( $i = 0; $i < $len; $i++ ) {
446            $result |= ord( $hash1[$i] ) ^ ord( $hash2[$i] );
447        }
448        return $result == 0;
449    }
450
451    /**
452     * Like json_decode but with sane error handling.
453     * Assumes that null is not a valid value for the JSON string.
454     * @param string $json
455     * @return mixed
456     * @throws Exception On invalid JSON
457     */
458    private function decodeJson( $json ) {
459        $error = $errorMsg = null;
460        $return = json_decode( $json );
461        if ( $return === null && trim( $json ) !== 'null' ) {
462            $error = json_last_error();
463            $errorMsg = json_last_error_msg();
464        } elseif ( !$return || !is_object( $return ) ) {
465            $error = 128;
466            $errorMsg = 'Response must be an object';
467        }
468
469        if ( $error ) {
470            $this->logger->error(
471                'Failed to decode server response as JSON: {message}',
472                [
473                    'response' => $json,
474                    'code' => json_last_error(),
475                    'message' => json_last_error_msg(),
476                ]
477            );
478            throw new Exception( 'Decoding server response failed: ' . json_last_error_msg()
479                . " (Raw response: $json)" );
480        }
481        return $return;
482    }
483
484}