Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
5.24% |
10 / 191 |
|
6.25% |
1 / 16 |
CRAP | |
0.00% |
0 / 1 |
Client | |
5.24% |
10 / 191 |
|
6.25% |
1 / 16 |
2920.80 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
newFromKeyAndSecret | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
setExtraParam | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setExtraParams | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setCallback | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
initiate | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
complete | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
20 | |||
identify | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
makeOAuthCall | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
90 | |||
makeCurlCall | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
90 | |||
decodeJWT | |
23.08% |
3 / 13 |
|
0.00% |
0 / 1 |
11.28 | |||
validateJWT | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
urlsafeB64Decode | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
3.47 | |||
compareHash | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
decodeJson | |
0.00% |
0 / 20 |
|
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 | |
24 | namespace MediaWiki\OAuthClient; |
25 | |
26 | use MediaWiki\OAuthClient\SignatureMethod\HmacSha1; |
27 | use Psr\Log\LoggerAwareInterface; |
28 | use Psr\Log\LoggerInterface; |
29 | use Psr\Log\NullLogger; |
30 | use stdClass; |
31 | |
32 | /** |
33 | * MediaWiki OAuth client. |
34 | */ |
35 | class 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 | } |