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 @@ -212,6 +212,7 @@ 'PhutilLunarPhaseTestCase' => 'utils/__tests__/PhutilLunarPhaseTestCase.php', 'PhutilMarkupEngine' => 'markup/PhutilMarkupEngine.php', 'PhutilMarkupTestCase' => 'markup/__tests__/PhutilMarkupTestCase.php', + 'PhutilMediaWikiAuthAdapter' => 'auth/PhutilMediaWikiAuthAdapter.php', 'PhutilMemcacheKeyValueCache' => 'cache/PhutilMemcacheKeyValueCache.php', 'PhutilMethodNotImplementedException' => 'error/PhutilMethodNotImplementedException.php', 'PhutilMetricsChannel' => 'channel/PhutilMetricsChannel.php', @@ -620,6 +621,7 @@ 'PhutilLogfileChannel' => 'PhutilChannelChannel', 'PhutilLunarPhaseTestCase' => 'PhutilTestCase', 'PhutilMarkupTestCase' => 'PhutilTestCase', + 'PhutilMediaWikiAuthAdapter' => 'PhutilOAuth1AuthAdapter', 'PhutilMemcacheKeyValueCache' => 'PhutilKeyValueCache', 'PhutilMethodNotImplementedException' => 'Exception', 'PhutilMetricsChannel' => 'PhutilChannelChannel', diff --git a/src/auth/PhutilMediaWikiAuthAdapter.php b/src/auth/PhutilMediaWikiAuthAdapter.php new file mode 100644 --- /dev/null +++ b/src/auth/PhutilMediaWikiAuthAdapter.php @@ -0,0 +1,206 @@ +mediaWikiBaseURI; + if (substr($uri, -1) != '/') { + $uri .= '/'; + } + if (!is_array($query_params)) { + $query_params = array(); + } + $query_params['title'] = $title; + return $uri.'index.php?'. + http_build_query( + $query_params, + '', + '&'); + } + + public function getAccountID() { + $this->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->getWikiPageURI('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->getWikiPageURI('Special:OAuth/initiate', + array('oauth_callback' => 'oob')); + } + + protected function getAuthorizeTokenURI() { + return $this->getWikiPageURI('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(); + return $p."&oauth_consumer_key={$this->getConsumerKey()}"; + } + + protected function getValidateTokenURI() { + return $this->getWikiPageURI('Special:OAuth/token'); + } + + private function getUserInfo() { + if ($this->userinfo === null) { + $uri = new PhutilURI( + $this->getWikiPageURI('Special:OAuth/identify', + array('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; + } + + protected function willProcessTokenRequestResponse($body) { + if (substr_count($body, 'Error:') > 0) { + phlog('OAuth provider returned error in response body: '.$body); + throw new Exception( + pht('OAuth provider returned an error response.')); + } + } + + /** + * 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); + $iss_uri = new PhutilURI($identity->iss); + $expected_uri = new PhutilURI($this->mediaWikiBaseURI); + + $now = time(); + + if ($iss_uri->getDomain() !== $expected_uri->getDomain()) { + throw new Exception( + pht('OAuth JWT iss didn\'t match expected server name')); + } + if ($identity->aud !== $this->getConsumerKey()) { + throw new Exception( + pht('OAuth JWT aud didn\'t match expected consumer key')); + } + if ($identity->iat > $now || $identity->exp < $now) { + throw new Exception( + pht('OAuth JWT wasn\'t valid at this time')); + } + if ($identity->nonce !== $nonce) { + throw new Exception( + pht('OAuth JWT nonce didn\'t match what we sent.')); + } + $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; + } +} diff --git a/src/auth/PhutilOAuth1AuthAdapter.php b/src/auth/PhutilOAuth1AuthAdapter.php --- a/src/auth/PhutilOAuth1AuthAdapter.php +++ b/src/auth/PhutilOAuth1AuthAdapter.php @@ -88,6 +88,8 @@ abstract protected function getAuthorizeTokenURI(); abstract protected function getValidateTokenURI(); + + protected function getSignatureMethod() { return 'HMAC-SHA1'; } @@ -133,6 +135,9 @@ } list($body) = $future->resolvex(); + + $this->willProcessTokenRequestResponse($body); + $data = id(new PhutilQueryStringParser())->parseQueryString($body); // NOTE: Per the spec, this value MUST be the string 'true'. @@ -198,4 +203,21 @@ return; } + /** + * Hook that allows subclasses to parse errors from the response body + * returned by the oauth provider during the token request call + */ + protected function willProcessTokenRequestResponse($body) { + 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; + } + }