Page MenuHomePhabricator

D9202.diff
No OneTemporary

D9202.diff

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 @@
+<?php
+
+/**
+ * Authentication adapter for MediaWiki OAuth1.
+ */
+final class PhutilMediaWikiAuthAdapter
+ extends PhutilOAuth1AuthAdapter {
+
+ private $userinfo;
+ private $domain = '';
+ private $mediaWikiBaseURI = '';
+
+ public function getWikiPageURI($title, $query_params = null) {
+ $uri = $this->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;
+ }
+
}

File Metadata

Mime Type
text/plain
Expires
Sat, Oct 19, 4:45 AM (20 h, 15 m)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
6722520
Default Alt Text
D9202.diff (8 KB)

Event Timeline