Skip to content

Commit b7cab72

Browse files
committed
Add OAuth library
Add an OAuth client library extracted from https://code.google.com/p/oauth/ and an OAuth client extracted from https://github.com/Stype/mwoauth-php. Change-Id: Id8222bbe9960942f94fd67d7fbc3290cb0105245
1 parent e54ab89 commit b7cab72

File tree

17 files changed

+2516
-0
lines changed

17 files changed

+2516
-0
lines changed

src/OAuth/Client.php

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
<?php
2+
/**
3+
* @section LICENSE
4+
* This file is part of Wikimedia Slim application library
5+
*
6+
* Wikimedia Slim application library is 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+
* Wikimedia Slim application 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 Wikimedia Grants Review application. 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 Wikimedia\Slimapp\OAuth;
25+
26+
use Psr\Log\LoggerAwareInterface;
27+
use Psr\Log\LoggerInterface;
28+
use Psr\Log\NullLogger;
29+
use Wikimedia\Slimapp\OAuth\SignatureMethod\HmacSha1;
30+
use Exception;
31+
32+
/**
33+
* MediaWiki OAuth client.
34+
*/
35+
class Client implements LoggerAwareInterface {
36+
37+
/**
38+
* @var LoggerInterface $logger
39+
*/
40+
protected $logger;
41+
42+
/**
43+
* @var ClientConfig $config
44+
*/
45+
private $config;
46+
47+
/**
48+
* Any extra params in the call that need to be signed
49+
* @var array $extraParams
50+
*/
51+
private $extraParams = array();
52+
53+
/**
54+
* url, defaults to oob
55+
* @var string $callbackUrl
56+
*/
57+
private $callbackUrl = 'oob';
58+
59+
/**
60+
* Track the last random nonce generated by the OAuth lib, used to verify
61+
* /identity response isn't a replay
62+
* @var string $lastNonce
63+
*/
64+
private $lastNonce;
65+
66+
/**
67+
* @param ClientConfig $config
68+
* @param LoggerInterface $logger
69+
*/
70+
function __construct(
71+
ClientConfig $config,
72+
LoggerInterface $logger = null
73+
) {
74+
$this->config = $config;
75+
$this->logger = $logger ?: new NullLogger();
76+
}
77+
78+
/**
79+
* @param LoggerInterface $logger
80+
*/
81+
public function setLogger( LoggerInterface $logger ) {
82+
$this->logger = $logger;
83+
}
84+
85+
/**
86+
* @param string $url
87+
* @param string $key
88+
* @param string $secret
89+
* @return MediaWiki
90+
*/
91+
public static function newFromKeyAndSecret( $url, $key, $secret ) {
92+
$config = new ClientConfig( $url, true, true );
93+
$config->setConsumer( new Consumer( $key, $secret ) );
94+
return new static( $config );
95+
}
96+
97+
/**
98+
* @param string $key
99+
* @param string $value
100+
*/
101+
public function setExtraParam( $key, $value ) {
102+
$this->extraParams[$key] = $value;
103+
}
104+
105+
/**
106+
* @param array $params
107+
*/
108+
public function setExtraParams( array $params ) {
109+
$this->extraParams = $params;
110+
}
111+
112+
/**
113+
* @param string $url
114+
*/
115+
public function setCallback( $url ) {
116+
$this->callbackUrl = $url;
117+
}
118+
119+
/**
120+
* First part of 3-legged OAuth, get the request Token.
121+
* Redirect your authorizing users to the redirect url, and keep
122+
* track of the request token since you need to pass it into complete()
123+
*
124+
* @return array (redirect, request/temp token)
125+
*/
126+
public function initiate() {
127+
$initUrl = $this->config->endpointURL .
128+
'/initiate&format=json&oauth_callback=' .
129+
urlencode( $this->callbackUrl );
130+
$data = $this->makeOAuthCall( null, $initUrl );
131+
$return = json_decode( $data );
132+
if ( $return->oauth_callback_confirmed !== 'true' ) {
133+
throw new Exception( "Callback wasn't confirmed" );
134+
}
135+
$requestToken = new Token( $return->key, $return->secret );
136+
$url = $this->config->redirURL ?:
137+
$this->config->endpointURL . "/authorize&";
138+
$url .= "oauth_token={$requestToken->key}&oauth_consumer_key={$this->config->consumer->key}";
139+
return array( $url, $requestToken );
140+
}
141+
142+
/**
143+
* The final leg of the OAuth handshake. Exchange the request Token from
144+
* initiate() and the verification code that the user submitted back to you
145+
* for an access token, which you'll use for all API calls.
146+
*
147+
* @param Token $requestToken Authorization code sent to the callback url
148+
* @param string Temp/request token obtained from initiate, or null if this
149+
* object was used and the token is already set.
150+
* @return Token The access token
151+
*/
152+
public function complete( Token $requestToken, $verifyCode ) {
153+
$tokenUrl = $this->config->endpointURL . '/token&format=json';
154+
$this->setExtraParam( 'oauth_verifier', $verifyCode );
155+
$data = $this->makeOAuthCall( $requestToken, $tokenUrl );
156+
$return = json_decode( $data );
157+
$accessToken = new Token( $return->key, $return->secret );
158+
// Cleanup after ourselves
159+
$this->setExtraParams = array();
160+
return $accessToken;
161+
}
162+
163+
/**
164+
* Optional step. This call the MediaWiki specific /identify method, which
165+
* returns a signed statement of the authorizing user's identity. Use this
166+
* if you are authenticating users in your application, and you need to
167+
* know their username, groups, rights, etc in MediaWiki.
168+
*
169+
* @param Token $accessToken Access token from complete()
170+
* @return object containing attributes of the user
171+
*/
172+
public function identify( Token $accessToken ) {
173+
$identifyUrl = $this->config->endpointURL . '/identify';
174+
$data = $this->makeOAuthCall( $accessToken, $identifyUrl );
175+
$identity = $this->decodeJWT( $data, $this->config->consumer->secret );
176+
if ( !$this->validateJWT(
177+
$identity,
178+
$this->config->consumer->key,
179+
$this->config->canonicalServerUrl,
180+
$this->lastNonce
181+
) ) {
182+
throw new Exception( "JWT didn't validate" );
183+
}
184+
return $identity;
185+
}
186+
187+
/**
188+
* Make a signed request to MediaWiki
189+
*
190+
* @param Token $token additional token to use in signature, besides
191+
* the consumer token. In most cases, this will be the access token you
192+
* got from complete(), but we set it to the request token when
193+
* finishing the handshake.
194+
* @param string $url URL to call
195+
* @param bool $isPost true if this should be a POST request
196+
* @param array $postFields POST parameters, only if $isPost is also true
197+
* @return string Body from the curl request
198+
*/
199+
public function makeOAuthCall(
200+
/*Token*/ $token, $url, $isPost = false, array $postFields = null
201+
) {
202+
$params = array();
203+
// Get any params from the url
204+
if ( strpos( $url, '?' ) ) {
205+
$parsed = parse_url( $url );
206+
parse_str( $parsed['query'], $params );
207+
}
208+
$params += $this->extraParams;
209+
if ( $isPost && $postFields ) {
210+
$params += $postFields;
211+
}
212+
$method = $isPost ? 'POST' : 'GET';
213+
$req = Request::fromConsumerAndToken(
214+
$this->config->consumer,
215+
$token,
216+
$method,
217+
$url,
218+
$params
219+
);
220+
$req->signRequest(
221+
new HmacSha1(),
222+
$this->config->consumer,
223+
$token
224+
);
225+
$this->lastNonce = $req->getParameter( 'oauth_nonce' );
226+
return $this->makeCurlCall(
227+
$url,
228+
$req->toHeader(),
229+
$isPost,
230+
$postFields,
231+
$this->config
232+
);
233+
}
234+
235+
/**
236+
* @param string $url
237+
* @param array $headers
238+
* @param bool $isPost
239+
* @param array $postFields
240+
* @return string
241+
*/
242+
private function makeCurlCall(
243+
$url, $headers, $isPost, array $postFields = null
244+
) {
245+
$ch = curl_init();
246+
curl_setopt( $ch, CURLOPT_URL, (string) $url );
247+
curl_setopt( $ch, CURLOPT_HEADER, 0 );
248+
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
249+
curl_setopt( $ch, CURLOPT_HTTPHEADER, array( $headers ) );
250+
if ( $isPost ) {
251+
curl_setopt( $ch, CURLOPT_POST, true );
252+
curl_setopt( $ch, CURLOPT_POSTFIELDS, http_build_query( $postFields ) );
253+
}
254+
if ( $this->config->useSSL ) {
255+
curl_setopt( $ch, CURLOPT_PORT, 443 );
256+
}
257+
if ( $this->config->verifySSL ) {
258+
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
259+
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 2 );
260+
} else {
261+
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
262+
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 );
263+
}
264+
$data = curl_exec( $ch );
265+
if ( !$data ) {
266+
throw new Exception( 'Curl error: ' . curl_error( $ch ) );
267+
}
268+
return $data;
269+
}
270+
271+
/**
272+
* @param string $JWT Json web token
273+
* @param string $secret
274+
* @return object
275+
*/
276+
private function decodeJWT( $JWT, $secret ) {
277+
list( $headb64, $bodyb64, $sigb64 ) = explode( '.', $JWT );
278+
$header = json_decode( $this->urlsafeB64Decode( $headb64 ) );
279+
$payload = json_decode( $this->urlsafeB64Decode( $bodyb64 ) );
280+
$sig = $this->urlsafeB64Decode( $sigb64 );
281+
// MediaWiki will only use sha256 hmac (HS256) for now. This check
282+
// makes sure an attacker doesn't return a JWT with 'none' signature
283+
// type.
284+
$expectSig = hash_hmac(
285+
'sha256', "{$headb64}.{$bodyb64}", $secret, true
286+
);
287+
if ( $header->alg !== 'HS256' || !$this->compareHash( $sig, $expectSig ) ) {
288+
throw new Exception( "Invalid JWT signature from /identify." );
289+
}
290+
return $payload;
291+
}
292+
293+
/**
294+
* @param object $identity
295+
* @param string $consumerKey
296+
* @param string $expectedConnonicalServer
297+
* @param string $nonce
298+
*/
299+
protected function validateJWT(
300+
$identity, $consumerKey, $expectedConnonicalServer, $nonce
301+
) {
302+
// Verify the issuer is who we expect (server sends $wgCanonicalServer)
303+
if ( $identity->iss !== $expectedConnonicalServer ) {
304+
$this->logger->info(
305+
"Invalid issuer '{$identity->iss}': expected '{$expectedConnonicalServer}'" );
306+
return false;
307+
}
308+
// Verify we are the intended audience
309+
if ( $identity->aud !== $consumerKey ) {
310+
$this->logger->info( "Invalid audience '{$identity->aud}': expected '{$consumerKey}'" );
311+
return false;
312+
}
313+
// Verify we are within the time limits of the token. Issued at (iat)
314+
// should be in the past, Expiration (exp) should be in the future.
315+
$now = time();
316+
if ( $identity->iat > $now || $identity->exp < $now ) {
317+
$this->logger->info(
318+
"Invalid times issued='{$identity->iat}', " .
319+
"expires='{$identity->exp}', now='{$now}'"
320+
);
321+
return false;
322+
}
323+
// Verify we haven't seen this nonce before, which would indicate a replay attack
324+
if ( $identity->nonce !== $nonce ) {
325+
$this->logger->info( "Invalid nonce '{$identity->nonce}': expected '{$nonce}'" );
326+
return false;
327+
}
328+
return true;
329+
}
330+
331+
/**
332+
* @param string $input
333+
* @return string
334+
*/
335+
private function urlsafeB64Decode( $input ) {
336+
$remainder = strlen( $input ) % 4;
337+
if ( $remainder ) {
338+
$padlen = 4 - $remainder;
339+
$input .= str_repeat( '=', $padlen );
340+
}
341+
return base64_decode( strtr( $input, '-_', '+/' ) );
342+
}
343+
344+
/**
345+
* Constant time comparison
346+
* @param string $hash1
347+
* @param string $hash2
348+
* @return bool
349+
*/
350+
private function compareHash( $hash1, $hash2 ) {
351+
$result = strlen( $hash1 ) ^ strlen( $hash2 );
352+
$len = min( strlen( $hash1 ), strlen( $hash2 ) ) - 1;
353+
for ( $i = 0; $i < $len; $i++ ) {
354+
$result |= ord( $hash1{$i} ) ^ ord( $hash2{$i} );
355+
}
356+
return $result == 0;
357+
}
358+
}

0 commit comments

Comments
 (0)