Changeset View
Changeset View
Standalone View
Standalone View
src/auth/PhutilAuthAdapterOAuthMediaWiki.php
- This file was added.
<?php | |||||
/** | |||||
* Authentication adapter for MediaWiki OAuth1. | |||||
*/ | |||||
final class PhutilAuthAdapterOAuthMediaWiki extends PhutilAuthAdapterOAuth1 { | |||||
private $userinfo; | |||||
public function getAccountID() { | |||||
$this->getHandshakeData(); | |||||
return idx($this->getUserInfo(), 'userid'); | |||||
epriestley: Should these be empty? It seems unlikely that any install other than WMF will want to… | |||||
Not Done Inline ActionsFair enough. 20after4: Fair enough. | |||||
} | |||||
public function getAccountName() { | |||||
return idx($this->getUserInfo(), 'username'); | |||||
} | |||||
public function getAccountURI() { | |||||
$name = $this->getAccountName(); | |||||
if (strlen($name)) { | |||||
return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=User:'.$name; | |||||
} | |||||
return null; | |||||
} | |||||
public function getCallbackURI() { | |||||
return 'oob'; | |||||
} | |||||
public function getAccountImageURI() { | |||||
$info = $this->getUserInfo(); | |||||
return idx($info, 'profile_image_url'); | |||||
} | |||||
public function getAccountRealName() { | |||||
$info = $this->getUserInfo(); | |||||
return idx($info, 'name'); | |||||
} | |||||
public function getAdapterType() { | |||||
return 'mediawiki'; | |||||
} | |||||
public function getAdapterDomain() { | |||||
return 'mediawiki.org'; | |||||
} | |||||
protected function getRequestTokenURI() { | |||||
return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/initiate'; | |||||
} | |||||
protected function getAuthorizeTokenURI() { | |||||
return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/authorize'; | |||||
} | |||||
public function getClientRedirectURI() { | |||||
$p = parent::getClientRedirectURI(); | |||||
$token = $this->getToken(); | |||||
$token_secret = $this->getTokenSecret(); | |||||
// MediaWiki requires the call to /authorize be signed with the temp | |||||
// secret. This also forces the connected app to prevent CSRF. | |||||
setcookie('mwoauth', "$token:$token_secret"); | |||||
return $p . "&oauth_consumer_key={$this->getConsumerKey()}"; | |||||
} | |||||
protected function getValidateTokenURI() { | |||||
return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/token'; | |||||
} | |||||
private function getUserInfo() { | |||||
if ($this->userinfo === null) { | |||||
$uri = new PhutilURI('http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/identify&format=json'); | |||||
$nonce = Filesystem::readRandomCharacters(32); // We gen this so we can check for replay below | |||||
list($body) = $this->newOAuth1Future($uri) | |||||
->setMethod('GET') | |||||
->setNonce($nonce) | |||||
->resolvex(); | |||||
$this->userinfo = $this->decodeAndVerifyJWT($body, $nonce); | |||||
} | |||||
return $this->userinfo; | |||||
} | |||||
/** | |||||
* MediaWiki uses a signed JWT to assert the user's identity | |||||
*/ | |||||
private function decodeAndVerifyJWT($jwt, $nonce) { | |||||
$userinfo = array(); | |||||
$identity = $this->decodeJWT($jwt); | |||||
$expected_connonical_server = 'http://en.wikipedia.beta.wmflabs.org'; | |||||
$now = time(); | |||||
if ($identity->iss !== $expected_connonical_server) { | |||||
throw new Exception("OAuth JWT iss didn't match expected server name"); | |||||
} | |||||
if ($identity->aud !== $this->getConsumerKey()) { | |||||
throw new Exception("OAuth JWT aud didn't match expected consumer key"); | |||||
} | |||||
if ($identity->iat > $now || $identity->exp < $now) { | |||||
throw new Exception("OAuth JWT wasn't valid at this time"); | |||||
} | |||||
if ($identity->nonce !== $nonce) { | |||||
throw new Exception("OAuth JWT nonce didn't match what we sent. MITM?"); | |||||
} | |||||
$userinfo['userid'] = $identity->sub; | |||||
$userinfo['username'] = $identity->username; | |||||
$userinfo['groups'] = $identity->groups; | |||||
$userinfo['blocked'] = $identity->blocked; | |||||
$userinfo['editcount'] = $identity->editcount; | |||||
return $userinfo; | |||||
} | |||||
private function decodeJWT($jwt) { | |||||
list($headb64, $bodyb64, $sigb64) = explode('.', $jwt); | |||||
$header = json_decode($this->urlsafeB64Decode($headb64)); | |||||
$payload = json_decode($this->urlsafeB64Decode($bodyb64)); | |||||
$sig = $this->urlsafeB64Decode($sigb64); | |||||
$expect_sig = hash_hmac( | |||||
'sha256', | |||||
"$headb64.$bodyb64", | |||||
$this->getConsumerSecret()->openEnvelope(), | |||||
true); | |||||
if ($header->alg !== 'HS256' || !$this->compareHash($sig, $expect_sig)) { | |||||
throw new Exception('Invalid JWT signature from /identify.'); | |||||
} | |||||
return $payload; | |||||
} | |||||
private function urlsafeB64Decode($input) { | |||||
$remainder = strlen($input) % 4; | |||||
if ($remainder) { | |||||
$padlen = 4 - $remainder; | |||||
$input .= str_repeat('=', $padlen); | |||||
} | |||||
return base64_decode(strtr($input, '-_', '+/')); | |||||
} | |||||
private function compareHash($hash1, $hash2) { | |||||
$result = strlen($hash1) ^ strlen($hash2); | |||||
$len = min(strlen($hash1), strlen($hash2)) - 1; | |||||
for ($i = 0; $i < $len; $i++) { | |||||
$result |= ord($hash1{$i}) ^ ord($hash2{$i}); | |||||
} | |||||
return $result == 0; | |||||
} | |||||
epriestleyUnsubmitted Not Done Inline ActionsIt's very hard for me to believe this attack is plausible, but let's move this to something like phutil_constant_time_compare() for clarity, we "should" "probably" "use" it "elsewhere" "too". epriestley: It's very hard for me to believe this attack is plausible, but let's move this to something… | |||||
} | |||||
Not Done Inline ActionsThrow a comment here like "// This is a constant-time compare."? It took me a couple of minutes to figure out what was going on here the first time, since it looked like signature verification at first glance (e.g., "compare hash to signature"). epriestley: Throw a comment here like "// This is a constant-time compare."? It took me a couple of minutes… |
Should these be empty? It seems unlikely that any install other than WMF will want to authenticate against this domain?