From 818c6063929c01630cbfd0a6e5b0c05d63ceea59 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Tim=20D=C3=BCsterhus?= Date: Tue, 29 Jun 2021 16:26:12 +0200 Subject: [PATCH] Migrate Twitter authentication to Guzzle --- .../templates/accountManagement.tpl | 2 +- .../lib/action/TwitterAuthAction.class.php | 424 +++++++++++------- .../lib/form/AccountManagementForm.class.php | 14 +- .../files/lib/form/RegisterForm.class.php | 17 +- 4 files changed, 282 insertions(+), 175 deletions(-) diff --git a/com.woltlab.wcf/templates/accountManagement.tpl b/com.woltlab.wcf/templates/accountManagement.tpl index 6bbfb785ff..3b72cce170 100644 --- a/com.woltlab.wcf/templates/accountManagement.tpl +++ b/com.woltlab.wcf/templates/accountManagement.tpl @@ -222,7 +222,7 @@
{lang}wcf.user.3rdparty.twitter{/lang}
- {if $__wcf->getSession()->getVar('__twitterData')} + {if $__wcf->session->getVar('__3rdPartyProvider') === 'twitter' && $__wcf->session->getVar('__oauthUser')} {else} {lang}wcf.user.3rdparty.twitter.connect{/lang} diff --git a/wcfsetup/install/files/lib/action/TwitterAuthAction.class.php b/wcfsetup/install/files/lib/action/TwitterAuthAction.class.php index 12fb4fb0f9..07235a9555 100644 --- a/wcfsetup/install/files/lib/action/TwitterAuthAction.class.php +++ b/wcfsetup/install/files/lib/action/TwitterAuthAction.class.php @@ -2,24 +2,28 @@ namespace wcf\action; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Psr7\Request; use ParagonIE\ConstantTime\Base64; use ParagonIE\ConstantTime\Hex; +use Psr\Http\Client\ClientExceptionInterface; use wcf\data\user\User; -use wcf\system\exception\IllegalLinkException; use wcf\system\exception\NamedUserException; use wcf\system\exception\PermissionDeniedException; -use wcf\system\exception\SystemException; +use wcf\system\io\HttpFactory; use wcf\system\request\LinkHandler; +use wcf\system\user\authentication\oauth\exception\StateValidationException; +use wcf\system\user\authentication\oauth\User as OauthUser; use wcf\system\WCF; use wcf\util\HeaderUtil; -use wcf\util\HTTPRequest; +use wcf\util\JSON; use wcf\util\StringUtil; /** * Handles twitter auth. * * @author Tim Duesterhus - * @copyright 2001-2019 WoltLab GmbH + * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License * @package WoltLabSuite\Core\Action */ @@ -30,6 +34,11 @@ class TwitterAuthAction extends AbstractAction */ public $neededModules = ['TWITTER_PUBLIC_KEY', 'TWITTER_PRIVATE_KEY']; + /** + * @var ClientInterface + */ + private $httpClient; + /** * @inheritDoc */ @@ -49,174 +58,270 @@ class TwitterAuthAction extends AbstractAction { parent::execute(); - // user accepted - if (isset($_GET['oauth_token']) && isset($_GET['oauth_verifier'])) { - // fetch data created in the first step - $initData = WCF::getSession()->getVar('__twitterInit'); - WCF::getSession()->unregister('__twitterInit'); - if (!$initData) { - throw new IllegalLinkException(); - } + try { + if (isset($_GET['oauth_token']) && isset($_GET['oauth_verifier'])) { + $token = $this->verifierToAccessToken( + $_GET['oauth_token'], + $_GET['oauth_verifier'] + ); + + $oauthUser = $this->getUser($token); - // validate oauth_token - if (!\hash_equals((string) $initData['oauth_token'], (string)$_GET['oauth_token'])) { - throw new IllegalLinkException(); + $this->processUser($oauthUser); + } elseif (isset($_GET['denied'])) { + throw new NamedUserException( + WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.login.error.denied') + ); + } else { + $this->initiate(); } + } catch (NamedUserException | PermissionDeniedException $e) { + throw $e; + } catch (StateValidationException $e) { + throw new NamedUserException(WCF::getLanguage()->getDynamicVariable( + 'wcf.user.3rdparty.login.error.stateValidation' + )); + } catch (\Exception $e) { + $exceptionID = \wcf\functions\exception\logThrowable($e); + + $type = 'genericException'; + if ($e instanceof ClientExceptionInterface) { + $type = 'httpError'; + } + + throw new NamedUserException(WCF::getLanguage()->getDynamicVariable( + 'wcf.user.3rdparty.login.error.' . $type, + [ + 'exceptionID' => $exceptionID, + ] + )); + } + + exit; + } - try { - // fetch access_token - $oauthHeader = [ - 'oauth_consumer_key' => StringUtil::trim(TWITTER_PUBLIC_KEY), - 'oauth_nonce' => Hex::encode(\random_bytes(20)), - 'oauth_signature_method' => 'HMAC-SHA1', - 'oauth_timestamp' => TIME_NOW, - 'oauth_version' => '1.0', - 'oauth_token' => $initData['oauth_token'], - ]; - $postData = [ - 'oauth_verifier' => $_GET['oauth_verifier'], - ]; - - $signature = $this->createSignature( - 'https://api.twitter.com/oauth/access_token', - \array_merge($oauthHeader, $postData) + /** + * Processes the user (e.g. by registering session variables and redirecting somewhere). + */ + protected function processUser(OauthUser $oauthUser) + { + $user = User::getUserByAuthData('twitter:' . $oauthUser->getId()); + + if ($user->userID) { + if (WCF::getUser()->userID) { + // This account belongs to an existing user, but we are already logged in. + // This can't be handled. + + throw new NamedUserException( + WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.twitter.connect.error.inuse') ); - $oauthHeader['oauth_signature'] = $signature; - - $request = new HTTPRequest('https://api.twitter.com/oauth/access_token', [], $postData); - $request->addHeader('Authorization', 'OAuth ' . $this->buildOAuthHeader($oauthHeader)); - $request->execute(); - $reply = $request->getReply(); - $content = $reply['body']; - } catch (SystemException $e) { - \wcf\functions\exception\logThrowable($e); - throw new IllegalLinkException(); + } else { + // This account belongs to an existing user, we are not logged in. + // Perform the login. + + WCF::getSession()->changeUser($user); + WCF::getSession()->update(); + HeaderUtil::redirect(LinkHandler::getInstance()->getLink()); + + exit; } + } else { + WCF::getSession()->register('__3rdPartyProvider', 'twitter'); + + if (WCF::getUser()->userID) { + // This account does not belong to anyone and we are already logged in. + // Thus we want to connect this account. + + WCF::getSession()->register('__oauthUser', $oauthUser); - \parse_str($content, $data); - - // check whether a user is connected to this twitter account - $user = User::getUserByAuthData('twitter:' . $data['user_id']); - - if ($user->userID) { - // a user is already connected, but we are logged in, break - if (WCF::getUser()->userID) { - throw new NamedUserException(WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.twitter.connect.error.inuse')); - } // perform login - else { - WCF::getSession()->changeUser($user); - WCF::getSession()->update(); - HeaderUtil::redirect(LinkHandler::getInstance()->getLink()); - } + HeaderUtil::redirect(LinkHandler::getInstance()->getLink('AccountManagement') . '#3rdParty'); + + exit; } else { - WCF::getSession()->register('__3rdPartyProvider', 'twitter'); - // save data for connection - if (WCF::getUser()->userID) { - WCF::getSession()->register('__twitterUsername', $data['screen_name']); - WCF::getSession()->register('__twitterData', $data); - - HeaderUtil::redirect(LinkHandler::getInstance()->getLink('AccountManagement') . '#3rdParty'); - } // save data and redirect to registration - else { - // fetch user data - $twitterData = null; - try { - $oauthHeader = [ - 'oauth_consumer_key' => StringUtil::trim(TWITTER_PUBLIC_KEY), - 'oauth_nonce' => Hex::encode(\random_bytes(20)), - 'oauth_signature_method' => 'HMAC-SHA1', - 'oauth_timestamp' => TIME_NOW, - 'oauth_version' => '1.0', - 'oauth_token' => $data['oauth_token'], - ]; - $getData = [ - 'include_email' => 'true', - 'skip_status' => 'true', - ]; - $signature = $this->createSignature( - 'https://api.twitter.com/1.1/account/verify_credentials.json', - \array_merge($oauthHeader, $getData), - $data['oauth_token_secret'], - 'GET' - ); - $oauthHeader['oauth_signature'] = $signature; - - $request = new HTTPRequest('https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_email=true'); - $request->addHeader('Authorization', 'OAuth ' . $this->buildOAuthHeader($oauthHeader)); - $request->execute(); - $reply = $request->getReply(); - $twitterData = \json_decode($reply['body'], true); - } catch (SystemException $e) { - /* ignore errors */ - } - - WCF::getSession()->register('__username', $data['screen_name']); - if (isset($twitterData['email'])) { - WCF::getSession()->register('__email', $twitterData['email']); - } - - if ($twitterData !== null) { - $data = $twitterData; - } - WCF::getSession()->register('__twitterData', $data); - - // we assume that bots won't register on twitter first - // thus no need for a captcha - if (REGISTER_USE_CAPTCHA) { - WCF::getSession()->register('noRegistrationCaptcha', true); - } - - WCF::getSession()->update(); - HeaderUtil::redirect(LinkHandler::getInstance()->getLink('Register')); - } + // This account does not belong to anyone and we are not logged in. + // Thus we want to connect this account to a newly registered user. + + WCF::getSession()->register('__oauthUser', $oauthUser); + WCF::getSession()->register('__username', $oauthUser->getUsername()); + WCF::getSession()->register('__email', $oauthUser->getEmail()); + + // We assume that bots won't register an external account first, so + // we skip the captcha. + WCF::getSession()->register('noRegistrationCaptcha', true); + + WCF::getSession()->update(); + HeaderUtil::redirect(LinkHandler::getInstance()->getControllerLink(RegisterForm::class)); + + exit; } + } + } - $this->executed(); + /** + * Turns the access token response into an oauth user. + */ + private function getUser(array $accessToken): OauthUser + { + $uri = 'https://api.twitter.com/1.1/account/verify_credentials.json'; + $oauthHeader = [ + 'oauth_consumer_key' => StringUtil::trim(TWITTER_PUBLIC_KEY), + 'oauth_nonce' => Hex::encode(\random_bytes(20)), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => TIME_NOW, + 'oauth_version' => '1.0', + 'oauth_token' => $accessToken['oauth_token'], + ]; + $queryParameters = [ + 'include_email' => 'true', + 'skip_status' => 'true', + ]; + $signature = $this->createSignature( + $uri, + \array_merge($oauthHeader, $queryParameters), + $accessToken['oauth_token_secret'], + 'GET' + ); + $oauthHeader['oauth_signature'] = $signature; + + $request = new Request( + 'GET', + \sprintf( + '%s?%s', + $uri, + \http_build_query($queryParameters, '', '&') + ), + [ + 'authorization' => \sprintf('OAuth %s', $this->buildOAuthHeader($oauthHeader)), + ] + ); + $response = $this->getHttpClient()->send($request); + + $parsed = JSON::decode((string)$response->getBody()); + $parsed['__id'] = $parsed['id']; + $parsed['__username'] = $parsed['name']; + if ($parsed['email']) { + $parsed['__email'] = $parsed['email']; + } + + return new OauthUser($parsed); + } - exit; + /** + * Turns the verifier provided by Twitter into an access token. + */ + private function verifierToAccessToken(string $oauthToken, string $oauthVerifier) + { + $initData = WCF::getSession()->getVar('__twitterInit'); + WCF::getSession()->unregister('__twitterInit'); + if (!$initData) { + throw new StateValidationException('Missing state in session'); } - // user declined - if (isset($_GET['denied'])) { - throw new NamedUserException(WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.login.error.denied')); + if (!\hash_equals((string)$initData['oauth_token'], $oauthToken)) { + throw new StateValidationException('oauth_token mismatch'); } - // start auth by fetching request_token - try { - $callbackURL = LinkHandler::getInstance()->getControllerLink(static::class); - $oauthHeader = [ - 'oauth_callback' => $callbackURL, - 'oauth_consumer_key' => StringUtil::trim(TWITTER_PUBLIC_KEY), - 'oauth_nonce' => Hex::encode(\random_bytes(20)), - 'oauth_signature_method' => 'HMAC-SHA1', - 'oauth_timestamp' => TIME_NOW, - 'oauth_version' => '1.0', - ]; - $signature = $this->createSignature('https://api.twitter.com/oauth/request_token', $oauthHeader); - $oauthHeader['oauth_signature'] = $signature; - - // call api - $request = new HTTPRequest('https://api.twitter.com/oauth/request_token', ['method' => 'POST']); - $request->addHeader('Authorization', 'OAuth ' . $this->buildOAuthHeader($oauthHeader)); - $request->execute(); - $reply = $request->getReply(); - - $content = $reply['body']; - } catch (SystemException $e) { - \wcf\functions\exception\logThrowable($e); - throw new IllegalLinkException(); + $uri = 'https://api.twitter.com/oauth/access_token'; + $oauthHeader = [ + 'oauth_consumer_key' => StringUtil::trim(TWITTER_PUBLIC_KEY), + 'oauth_nonce' => Hex::encode(\random_bytes(20)), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => TIME_NOW, + 'oauth_version' => '1.0', + 'oauth_token' => $oauthToken, + ]; + $postData = [ + 'oauth_verifier' => $oauthVerifier, + ]; + + $signature = $this->createSignature( + $uri, + \array_merge($oauthHeader, $postData) + ); + $oauthHeader['oauth_signature'] = $signature; + + $request = new Request( + 'POST', + $uri, + [ + 'authorization' => \sprintf('OAuth %s', $this->buildOAuthHeader($oauthHeader)), + 'content-type' => 'application/x-www-form-urlencoded', + ], + \http_build_query($postData, '', '&', \PHP_QUERY_RFC1738) + ); + $response = $this->getHttpClient()->send($request); + + \parse_str((string)$response->getBody(), $data); + + if (!isset($data['oauth_token'])) { + throw new \Exception("Access token response does not have the 'oauth_token' key."); } - \parse_str($content, $data); - if ($data['oauth_callback_confirmed'] != 'true') { - throw new IllegalLinkException(); + if (!isset($data['oauth_token_secret'])) { + throw new \Exception("Access token response does not have the 'oauth_token_secret' key."); } - WCF::getSession()->register('__twitterInit', $data); - // redirect to twitter - HeaderUtil::redirect('https://api.twitter.com/oauth/authenticate?oauth_token=' . \rawurlencode($data['oauth_token'])); + return $data; + } + + /** + * Requests an request_token to initiate the OAuth flow. + */ + private function getRequestToken() + { + $callbackURL = LinkHandler::getInstance()->getControllerLink(static::class); + $uri = 'https://api.twitter.com/oauth/request_token'; + $oauthHeader = [ + 'oauth_callback' => $callbackURL, + 'oauth_consumer_key' => StringUtil::trim(TWITTER_PUBLIC_KEY), + 'oauth_nonce' => Hex::encode(\random_bytes(20)), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => TIME_NOW, + 'oauth_version' => '1.0', + ]; + $signature = $this->createSignature($uri, $oauthHeader); + $oauthHeader['oauth_signature'] = $signature; + + // call api + $request = new Request( + 'POST', + $uri, + [ + 'authorization' => \sprintf('OAuth %s', $this->buildOAuthHeader($oauthHeader)), + ] + ); + $response = $this->getHttpClient()->send($request); + + \parse_str((string)$response->getBody(), $data); + + if ( + !isset($data['oauth_callback_confirmed']) + || $data['oauth_callback_confirmed'] !== 'true' + ) { + throw new \Exception("Request token response does not have the 'oauth_callback_confirmed' key set to 'true'."); + } - $this->executed(); + if (!isset($data['oauth_token'])) { + throw new \Exception("Request token response does not have the 'oauth_token' key."); + } + + return $data; + } + + /** + * Initiates the OAuth flow by redirecting to the '/authenticate' URL. + */ + private function initiate() + { + $data = $this->getRequestToken(); + + WCF::getSession()->register('__twitterInit', $data); + HeaderUtil::redirect(\sprintf( + 'https://api.twitter.com/oauth/authenticate?%s', + \http_build_query([ + 'oauth_token' => $data['oauth_token'], + ], '', '&') + )); exit; } @@ -275,4 +380,17 @@ class TwitterAuthAction extends AbstractAction return Base64::encode(\hash_hmac('sha1', $base, $key, true)); } + + /** + * Returns a "static" instance of the HTTP client to use to allow + * for TCP connection reuse. + */ + final protected function getHttpClient(): ClientInterface + { + if (!$this->httpClient) { + $this->httpClient = HttpFactory::makeClientWithTimeout(5); + } + + return $this->httpClient; + } } diff --git a/wcfsetup/install/files/lib/form/AccountManagementForm.class.php b/wcfsetup/install/files/lib/form/AccountManagementForm.class.php index a176f9b483..be9a68dc1b 100644 --- a/wcfsetup/install/files/lib/form/AccountManagementForm.class.php +++ b/wcfsetup/install/files/lib/form/AccountManagementForm.class.php @@ -443,13 +443,17 @@ class AccountManagementForm extends AbstractForm $success[] = 'wcf.user.3rdparty.github.disconnect.success'; } if (TWITTER_PUBLIC_KEY !== '' && TWITTER_PRIVATE_KEY !== '') { - if ($this->twitterConnect && WCF::getSession()->getVar('__twitterData')) { - $twitterData = WCF::getSession()->getVar('__twitterData'); - $updateParameters['authData'] = 'twitter:' . $twitterData['user_id']; + if ( + $this->twitterConnect + && WCF::getSession()->getVar('__3rdPartyProvider') == 'twitter' + && ($oauthUser = WCF::getSession()->getVar('__oauthUser')) + ) { + $updateParameters['authData'] = 'twitter:' . $oauthUser->getId(); + $updateParameters['password'] = null; $success[] = 'wcf.user.3rdparty.twitter.connect.success'; - WCF::getSession()->unregister('__twitterData'); - WCF::getSession()->unregister('__twitterUsername'); + WCF::getSession()->unregister('__3rdPartyProvider'); + WCF::getSession()->unregister('__oauthUser'); } } if ($this->twitterDisconnect && StringUtil::startsWith(WCF::getUser()->authData, 'twitter:')) { diff --git a/wcfsetup/install/files/lib/form/RegisterForm.class.php b/wcfsetup/install/files/lib/form/RegisterForm.class.php index 23275e0a5d..4ef12f19fc 100644 --- a/wcfsetup/install/files/lib/form/RegisterForm.class.php +++ b/wcfsetup/install/files/lib/form/RegisterForm.class.php @@ -370,26 +370,11 @@ class RegisterForm extends UserAddForm case 'github': case 'facebook': case 'google': + case 'twitter': if (($oauthUser = WCF::getSession()->getVar('__oauthUser'))) { $this->additionalFields['authData'] = $provider . ':' . $oauthUser->getId(); } break; - case 'twitter': - // Twitter - if (WCF::getSession()->getVar('__twitterData')) { - $twitterData = WCF::getSession()->getVar('__twitterData'); - $this->additionalFields['authData'] = 'twitter:' . ($twitterData['id'] ?? $twitterData['user_id']); - - WCF::getSession()->unregister('__twitterData'); - - if ( - WCF::getSession()->getVar('__email') - && WCF::getSession()->getVar('__email') == $this->email - ) { - $registerVia3rdParty = true; - } - } - break; } // Accounts connected to a 3rdParty login do not have passwords. -- 2.20.1