diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -104,6 +104,7 @@ 'PhutilAuthAdapterOAuthGitHub' => 'auth/PhutilAuthAdapterOAuthGitHub.php', 'PhutilAuthAdapterOAuthGoogle' => 'auth/PhutilAuthAdapterOAuthGoogle.php', 'PhutilAuthAdapterOAuthJIRA' => 'auth/PhutilAuthAdapterOAuthJIRA.php', + 'PhutilAuthAdapterOAuthMediaWiki' => 'auth/PhutilAuthAdapterOAuthMediaWiki.php', 'PhutilAuthAdapterOAuthTwitch' => 'auth/PhutilAuthAdapterOAuthTwitch.php', 'PhutilAuthAdapterOAuthTwitter' => 'auth/PhutilAuthAdapterOAuthTwitter.php', 'PhutilAuthAdapterOAuthWordPress' => 'auth/PhutilAuthAdapterOAuthWordPress.php', @@ -533,6 +534,7 @@ 'PhutilAuthAdapterOAuthGitHub' => 'PhutilAuthAdapterOAuth', 'PhutilAuthAdapterOAuthGoogle' => 'PhutilAuthAdapterOAuth', 'PhutilAuthAdapterOAuthJIRA' => 'PhutilAuthAdapterOAuth1', + 'PhutilAuthAdapterOAuthMediaWiki' => 'PhutilAuthAdapterOAuth1', 'PhutilAuthAdapterOAuthTwitch' => 'PhutilAuthAdapterOAuth', 'PhutilAuthAdapterOAuthTwitter' => 'PhutilAuthAdapterOAuth1', 'PhutilAuthAdapterOAuthWordPress' => 'PhutilAuthAdapterOAuth', 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,172 @@ +getHandshakeData(); + return idx($this->getUserInfo(), 'userid'); + } + + public function getAccountName() { + return idx($this->getUserInfo(), 'username'); + } + + public function getAccountURI() { + $name = $this->getAccountName(); + if (strlen($name)) { + return $this->mediaWikiBaseURI.'/wiki/User:'.$name; + } + return null; + } + + 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 $this->domain; + } + + /* mediawiki oauth needs the callback uri to be "oob" + (out of band callback) */ + public function getCallbackURI() { + return 'oob'; + } + + protected function getRequestTokenURI() { + return $this->mediaWikiBaseURI.'/w/index.php?title=Special:OAuth/initiate'; + } + + protected function getAuthorizeTokenURI() { + return $this->mediaWikiBaseURI.'/wiki/Special:OAuth/authorize'; + } + + public function setAdapterDomain($domain) { + $this->domain = $domain; + return $this; + } + + public function setMediaWikiBaseURI($uri) { + $this->mediaWikiBaseURI = $uri; + return $this; + } + + 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 $this->mediaWikiBaseURI.'/w/index.php?title=Special:OAuth/token'; + } + + private function getUserInfo() { + if ($this->userinfo === null) { + $uri = new PhutilURI($this->mediaWikiBaseURI. + '/w/index.php?title=Special:OAuth/identify&format=json'); + // We gen this so we can check for replay below: + $nonce = Filesystem::readRandomCharacters(32); + 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 = $this->mediaWikiBaseURI; + $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)); + for ($i = 0; $i < $len; $i++) { + $result |= ord($hash1{$i}) ^ ord($hash2{$i}); + } + return $result == 0; + } +}