Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.24% covered (danger)
5.24%
10 / 191
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
5.24% covered (danger)
5.24%
10 / 191
6.25% covered (danger)
6.25%
1 / 16
2920.80
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 / 29
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        if ( $this->config->userAgent !== null ) {
318            $headers[] = 'User-Agent: ' . $this->config->userAgent;
319        }
320        curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
321
322        if ( $isPost ) {
323            curl_setopt( $ch, CURLOPT_POST, true );
324            curl_setopt( $ch, CURLOPT_POSTFIELDS, $postFields );
325        }
326        if ( $this->config->useSSL ) {
327            curl_setopt( $ch, CURLOPT_PORT, 443 );
328        }
329        if ( $this->config->verifySSL ) {
330            curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
331            curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 2 );
332        } else {
333            curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
334            curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 );
335        }
336        $data = curl_exec( $ch );
337        if ( !$data ) {
338            if ( curl_errno( $ch ) ) {
339                throw new Exception( 'Curl error: ' . curl_error( $ch ) );
340            } else {
341                throw new Exception( 'Empty HTTP response! Status: '
342                    . curl_getinfo( $ch, CURLINFO_HTTP_CODE ) );
343            }
344        }
345        return $data;
346    }
347
348    /**
349     * @param string $JWT Json web token
350     * @param string $secret
351     * @return stdClass
352     * @throws Exception On invalid JWT signature
353     */
354    private function decodeJWT( $JWT, $secret ) {
355        $jwtParts = explode( '.', $JWT );
356        if ( count( $jwtParts ) !== 3 ) {
357            throw new Exception( "JWT has incorrect format. Received: $JWT" );
358        }
359        [ $headb64, $bodyb64, $sigb64 ] = $jwtParts;
360        $header = $this->decodeJson( $this->urlsafeB64Decode( $headb64 ) );
361        $payload = $this->decodeJson( $this->urlsafeB64Decode( $bodyb64 ) );
362        $sig = $this->urlsafeB64Decode( $sigb64 );
363        // MediaWiki will only use sha256 hmac (HS256) for now. This check
364        // makes sure an attacker doesn't return a JWT with 'none' signature
365        // type.
366        $expectSig = hash_hmac(
367            'sha256', "{$headb64}.{$bodyb64}", $secret, true
368        );
369        if ( $header->alg !== 'HS256' || !$this->compareHash( $sig, $expectSig ) ) {
370            throw new Exception( "Invalid JWT signature from /identify." );
371        }
372        return $payload;
373    }
374
375    /**
376     * @param stdClass $identity
377     * @param string $consumerKey
378     * @param string $expectedConnonicalServer
379     * @param string $nonce
380     * @return bool
381     */
382    protected function validateJWT(
383        $identity, $consumerKey, $expectedConnonicalServer, $nonce
384    ) {
385        // Verify the issuer is who we expect (server sends $wgCanonicalServer)
386        if ( $identity->iss !== $expectedConnonicalServer ) {
387            $this->logger->info(
388                "Invalid issuer '{$identity->iss}': expected '{$expectedConnonicalServer}'" );
389            return false;
390        }
391        // Verify we are the intended audience
392        if ( $identity->aud !== $consumerKey ) {
393            $this->logger->info( "Invalid audience '{$identity->aud}': expected '{$consumerKey}'" );
394            return false;
395        }
396        // Verify we are within the time limits of the token. Issued at (iat)
397        // should be in the past, Expiration (exp) should be in the future.
398        $now = time();
399        if ( $identity->iat > $now + static::IAT_TOLERANCE || $identity->exp < $now ) {
400            $this->logger->info(
401                "Invalid times issued='{$identity->iat}', " .
402                "expires='{$identity->exp}', now='{$now}'"
403            );
404            return false;
405        }
406        // Verify we haven't seen this nonce before, which would indicate a replay attack
407        if ( $identity->nonce !== $nonce ) {
408            $this->logger->info( "Invalid nonce '{$identity->nonce}': expected '{$nonce}'" );
409            return false;
410        }
411        return true;
412    }
413
414    /**
415     * @param string $input
416     * @return string
417     * @throws Exception If the input could not be decoded.
418     */
419    private function urlsafeB64Decode( $input ) {
420        // Pad the input with equals characters to the right to make it the correct length.
421        $remainder = strlen( $input ) % 4;
422        if ( $remainder ) {
423            $padlen = 4 - $remainder;
424            $input .= str_repeat( '=', $padlen );
425        }
426        // Decode the string.
427        $decoded = base64_decode( strtr( $input, '-_', '+/' ), true );
428        if ( $decoded === false ) {
429            throw new Exception( "Unable to decode base64 value: $input" );
430        }
431        return $decoded;
432    }
433
434    /**
435     * Constant time comparison
436     * @param string $hash1
437     * @param string $hash2
438     * @return bool
439     */
440    private function compareHash( $hash1, $hash2 ) {
441        $result = strlen( $hash1 ) ^ strlen( $hash2 );
442        $len = min( strlen( $hash1 ), strlen( $hash2 ) ) - 1;
443        for ( $i = 0; $i < $len; $i++ ) {
444            $result |= ord( $hash1[$i] ) ^ ord( $hash2[$i] );
445        }
446        return $result == 0;
447    }
448
449    /**
450     * Like json_decode but with sane error handling.
451     * Assumes that null is not a valid value for the JSON string.
452     * @param string $json
453     * @return mixed
454     * @throws Exception On invalid JSON
455     */
456    private function decodeJson( $json ) {
457        $error = $errorMsg = null;
458        $return = json_decode( $json );
459        if ( $return === null && trim( $json ) !== 'null' ) {
460            $error = json_last_error();
461            $errorMsg = json_last_error_msg();
462        } elseif ( !$return || !is_object( $return ) ) {
463            $error = 128;
464            $errorMsg = 'Response must be an object';
465        }
466
467        if ( $error ) {
468            $this->logger->error(
469                'Failed to decode server response as JSON: {message}',
470                [
471                    'response' => $json,
472                    'code' => json_last_error(),
473                    'message' => json_last_error_msg(),
474                ]
475            );
476            throw new Exception( 'Decoding server response failed: ' . json_last_error_msg()
477                . " (Raw response: $json)" );
478        }
479        return $return;
480    }
481
482}