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/PhutilAuthAdapterOAuth1.php b/src/auth/PhutilAuthAdapterOAuth1.php --- a/src/auth/PhutilAuthAdapterOAuth1.php +++ b/src/auth/PhutilAuthAdapterOAuth1.php @@ -198,4 +198,13 @@ return; } + /** + * This tells the authentication provider whether CSRF token should be + * added to callback uri and verified after the callback completes. + * A subclass can return false to disable the extra CSRF check. + */ + public function shouldAddCSRFTokenToCallbackURI() { + return true; + } + } 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,179 @@ +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:'.urlencode($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'; + } + + public function shouldAddCSRFTokenToCallbackURI() { + return false; + } + + 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) { + $parsed = new PhutilURI($uri); + $this->setAdapterDomain($parsed->getDomain()); + $this->mediaWikiBaseURI = $uri; + return $this; + } + + public function getClientRedirectURI() { + $p = parent::getClientRedirectURI(); + 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 + * here we verify the identity, not just the jwt signature. + */ + 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; + } + + /** decode a JWT and verify the signature is valid */ + private function decodeJWT($jwt) { + list($headb64, $bodyb64, $sigb64) = explode('.', $jwt); + + $header = json_decode($this->urlsafeB64Decode($headb64)); + $body = json_decode($this->urlsafeB64Decode($bodyb64)); + $sig = $this->urlsafeB64Decode($sigb64); + + $expect_sig = hash_hmac( + 'sha256', + "$headb64.$bodyb64", + $this->getConsumerSecret()->openEnvelope(), + true); + + // MediaWiki will only use sha256 hmac (HS256) for now. + // This checks that an attacker doesn't return invalid JWT signature type. + if ($header->alg !== 'HS256' || + !$this->compareHash($sig, $expect_sig)) { + throw new Exception('Invalid JWT signature from /identify.'); + } + + return $body; + } + + private function urlsafeB64Decode($input) { + $remainder = strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= str_repeat('=', $padlen); + } + return base64_decode(strtr($input, '-_', '+/')); + } + + /** return true if hash1 has the same value as hash2 */ + 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}); + } + // this is just a constant time compare of the two hash strings + return $result == 0; + } +}