diff --git a/src/auth/PhutilAuthAdapterOAuthMediaWiki.php b/src/auth/PhutilAuthAdapterOAuthMediaWiki.php new file mode 100644 --- /dev/null +++ b/src/auth/PhutilAuthAdapterOAuthMediaWiki.php @@ -0,0 +1,151 @@ +getHandshakeData(); + return idx($this->getUserInfo(), 'userid'); + } + + 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; + } +}