Skip to content

Commit 22d2993

Browse files
author
csteipp
committed
Initial version
1 parent bb1b6cb commit 22d2993

File tree

3 files changed

+1260
-0
lines changed

3 files changed

+1260
-0
lines changed

MWOAuthClient.php

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
<?php
2+
3+
include_once './OAuth.php'; // reference php library from oauth.net
4+
5+
function wfDebugLog( $method, $msg) {
6+
// Uncomment this if you want debuggging info from the OAuth library
7+
//echo "[$method] $msg\n";
8+
}
9+
10+
11+
class MWOAuthClientConfig {
12+
13+
// Url to the OAuth special page
14+
public $endpointURL;
15+
16+
// Canonical server url, used to check /identify's iss
17+
public $canonicalServerUrl;
18+
19+
public $useSSL = true;
20+
21+
// If you're testing against a server with self-signed certificates, you
22+
// can turn this off but don't do this in production.
23+
public $verifySSL = true;
24+
25+
function __construct( $url, $useSSL, $verifySSL ) {
26+
$this->endpointURL = $url;
27+
$this->useSSL = $useSSL;
28+
$this->verifySSL = $verifySSL;
29+
}
30+
31+
}
32+
33+
class MWOAuthClient {
34+
35+
// MWOAuthClientConfig
36+
private $config;
37+
38+
// TODO: move this to $config
39+
private $consumerToken;
40+
41+
// Any extra params in the call that need to be signed
42+
private $extraParams = array();
43+
44+
// Track the last random nonce generated by the OAuth lib, used to
45+
// verify /identity response isn't a replay
46+
private $lastNonce;
47+
48+
function __construct( MWOAuthClientConfig $config, OAuthToken $cmrToken ) {
49+
$this->consumerToken = $cmrToken;
50+
$this->config = $config;
51+
}
52+
53+
54+
public static function newFromKeyAndSecret( $url, $key, $secret ) {
55+
$cmrToken = new OAuthToken( $key, $secret );
56+
$config = new MWOAuthClientConfig( $url, true, true );
57+
return new self( $config, $cmrToken );
58+
}
59+
60+
public function setExtraParam( $key, $value ) {
61+
$this->extraParams[$key] = $value;
62+
}
63+
64+
public function setExtraParams( $params ) {
65+
$this->extraParams = $params;
66+
}
67+
68+
/**
69+
* First part of 3-legged OAuth, get the request Token.
70+
* Redirect your authorizing users to the redirect url, and keep
71+
* track of the request token since you need to pass it into complete()
72+
*
73+
* @return array (redirect, request/temp token)
74+
*/
75+
public function initiate() {
76+
$initUrl = $this->config->endpointURL . '/initiate&format=json&oauth_callback=oob';
77+
$data = $this->makeOAuthCall( null, $initUrl );
78+
$return = json_decode( $data );
79+
if ( $return->oauth_callback_confirmed !== 'true' ) {
80+
throw new Exception( "Callback wasn't confirmed" );
81+
}
82+
$requestToken = new OAuthToken( $return->key, $return->secret );
83+
$url = $this->config->endpointURL . "/authorize&oauth_token={$requestToken->key}&oauth_consumer_key={$this->consumerToken->key}";
84+
85+
return array( $url, $requestToken );
86+
}
87+
88+
/**
89+
* The final leg of the OAuth handshake. Exchange the request Token from
90+
* initiate() and the verification code that the user submitted back to you
91+
* for an access token, which you'll use for all API calls.
92+
*
93+
* @param the authorization code sent to the callback url
94+
* @param the temp/request token obtained from initiate, or null if this
95+
* object was used and the token is already set.
96+
* @return OAuthToken The access token
97+
*/
98+
public function complete( OAuthToken $requestToken, $verifyCode ) {
99+
$tokenUrl = $this->config->endpointURL . '/token&format=json';
100+
$this->setExtraParam( 'oauth_verifier', $verifyCode );
101+
$data = $this->makeOAuthCall( $requestToken , $tokenUrl );
102+
$return = json_decode( $data );
103+
$accessToken = new OAuthToken( $return->key, $return->secret );
104+
$this->setExtraParams = array(); // cleanup after ourselves
105+
return $accessToken;
106+
}
107+
108+
109+
/**
110+
* Optional step. This call the MediaWiki specific /identify method, which
111+
* returns a signed statement of the authorizing user's identity. Use this
112+
* if you are authenticating users in your application, and you need to
113+
* know their username, groups, rights, etc in MediaWiki.
114+
*
115+
* @param OAuthToken access token from complete()
116+
* @return object containing attributes of the user
117+
*/
118+
public function identify( OAuthToken $accessToken ) {
119+
$identifyUrl = $this->config->endpointURL . '/identify';
120+
$data = $this->makeOAuthCall( $accessToken, $identifyUrl );
121+
$identity = $this->decodeJWT( $data, $this->consumerToken->secret );
122+
123+
if ( !$this->validateJWT(
124+
$identity,
125+
$this->consumerToken->key,
126+
$this->config->canonicalServerUrl,
127+
$this->lastNonce
128+
) ) {
129+
throw new Exception( "JWT didn't validate" );
130+
}
131+
132+
return $identity;
133+
}
134+
135+
/**
136+
* Make a signed request to MediaWiki
137+
*
138+
* @param OAuthToken $token additional token to use in signature, besides the consumer token.
139+
* In most cases, this will be the access token you got from complete(), but we set it
140+
* to the request token when finishing the handshake.
141+
* @param $url string url to call
142+
* @param $isPost bool true if this should be a POST request
143+
* @param $postFields array of POST parameters, only if $isPost is also true
144+
* @return body from the curl request
145+
*/
146+
public function makeOAuthCall( $token, $url, $isPost = false, $postFields = false ) {
147+
148+
$params = array();
149+
150+
// Get any params from the url
151+
if ( strpos( $url, '?' ) ) {
152+
$parsed = parse_url( $url );
153+
parse_str($parsed['query'], $params);
154+
}
155+
$params += $this->extraParams;
156+
157+
$method = $isPost ? 'POST' : 'GET';
158+
$req = OAuthRequest::from_consumer_and_token(
159+
$this->consumerToken,
160+
$token,
161+
$method,
162+
$url,
163+
$params
164+
);
165+
$req->sign_request(
166+
new OAuthSignatureMethod_HMAC_SHA1(),
167+
$this->consumerToken,
168+
$token
169+
);
170+
171+
$this->lastNonce = $req->get_parameter( 'oauth_nonce' );
172+
173+
return $this->makeCurlCall(
174+
$url,
175+
$req->to_header(),
176+
$isPost,
177+
$postFields,
178+
$this->config
179+
);
180+
181+
}
182+
183+
184+
private function makeCurlCall( $url, $headers, $isPost, $postFields, MWOAuthClientConfig $config ) {
185+
186+
$ch = curl_init();
187+
curl_setopt( $ch, CURLOPT_URL, (string) $url );
188+
curl_setopt( $ch, CURLOPT_HEADER, 0 );
189+
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
190+
curl_setopt( $ch, CURLOPT_HTTPHEADER, array( $headers ) );
191+
192+
if ( $isPost ) {
193+
curl_setopt( $ch, CURLOPT_POST, true );
194+
curl_setopt( $ch, CURLOPT_POSTFIELDS, http_build_query( $postFields ) );
195+
}
196+
197+
if ( $config->useSSL ) {
198+
curl_setopt( $ch, CURLOPT_PORT , 443 );
199+
}
200+
201+
if ( $config->verifySSL ) {
202+
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
203+
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 2 );
204+
} else {
205+
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
206+
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 );
207+
}
208+
209+
$data = curl_exec( $ch );
210+
if( !$data ) {
211+
throw new Exception ( 'Curl error: ' . curl_error( $ch ) );
212+
}
213+
214+
return $data;
215+
}
216+
217+
218+
private function decodeJWT( $JWT, $secret ) {
219+
list( $headb64, $bodyb64, $sigb64 ) = explode( '.', $JWT );
220+
221+
$header = json_decode( $this->urlsafeB64Decode( $headb64 ) );
222+
$payload = json_decode( $this->urlsafeB64Decode( $bodyb64 ) );
223+
$sig = $this->urlsafeB64Decode( $sigb64 );
224+
225+
// MediaWiki will only use sha256 hmac (HS256) for now. This check makes sure
226+
// an attacker doesn't return a JWT with 'none' signature type.
227+
$expectSig = hash_hmac( 'sha256', "$headb64.$bodyb64", $secret, true);
228+
if ( $header->alg !== 'HS256' || !$this->compareHash( $sig, $expectSig ) ) {
229+
throw new Exception( "Invalid JWT signature from /identify." );
230+
}
231+
return $payload;
232+
}
233+
234+
protected function validateJWT( $identity, $consumerKey, $expectedConnonicalServer, $nonce ) {
235+
// Verify the issuer is who we expect (server sends $wgCanonicalServer)
236+
if ( $identity->iss !== $expectedConnonicalServer ) {
237+
print "Invalid Issuer";
238+
return false;
239+
}
240+
// Verify we are the intended audience
241+
if ( $identity->aud !== $consumerKey ) {
242+
print "Invalid Audience";
243+
return false;
244+
}
245+
// Verify we are within the time limits of the token. Issued at (iat) should be
246+
// in the past, Expiration (exp) should be in the future.
247+
$now = time();
248+
if ( $identity->iat > $now || $identity->exp < $now ) {
249+
print "Invalid Time";
250+
return false;
251+
}
252+
// Verify we haven't seen this nonce before, which would indicate a replay attack
253+
if ( $identity->nonce !== $nonce ) {
254+
print "Invalid Nonce";
255+
return false;
256+
}
257+
return true;
258+
}
259+
260+
private function urlsafeB64Decode( $input ) {
261+
$remainder = strlen( $input ) % 4;
262+
if ( $remainder ) {
263+
$padlen = 4 - $remainder;
264+
$input .= str_repeat( '=', $padlen );
265+
}
266+
return base64_decode( strtr( $input, '-_', '+/' ) );
267+
}
268+
269+
// Constant time comparison
270+
private function compareHash( $hash1, $hash2 ) {
271+
$result = strlen( $hash1 ) ^ strlen( $hash2 );
272+
$len = min( strlen( $hash1 ), strlen( $hash2 ) ) - 1;
273+
for ( $i = 0; $i < $len; $i++ ) {
274+
$result |= ord( $hash1{$i} ) ^ ord( $hash2{$i} );
275+
}
276+
return $result == 0;
277+
}
278+
279+
}

0 commit comments

Comments
 (0)