Migrate Twitter authentication to Guzzle
authorTim Düsterhus <duesterhus@woltlab.com>
Tue, 29 Jun 2021 14:26:12 +0000 (16:26 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Tue, 29 Jun 2021 14:29:17 +0000 (16:29 +0200)
com.woltlab.wcf/templates/accountManagement.tpl
wcfsetup/install/files/lib/action/TwitterAuthAction.class.php
wcfsetup/install/files/lib/form/AccountManagementForm.class.php
wcfsetup/install/files/lib/form/RegisterForm.class.php

index 6bbfb785ffffb5736bbf58ed6aad2f8574a949fd..3b72cce170b67e1971c6f8b5e8c8e90870c08500 100644 (file)
                                                <dl>
                                                        <dt>{lang}wcf.user.3rdparty.twitter{/lang}</dt>
                                                        <dd>
-                                                               {if $__wcf->getSession()->getVar('__twitterData')}
+                                                               {if $__wcf->session->getVar('__3rdPartyProvider') === 'twitter' && $__wcf->session->getVar('__oauthUser')}
                                                                        <label><input type="checkbox" name="twitterConnect" value="1"{if $twitterConnect} checked{/if}> {lang}wcf.user.3rdparty.twitter.connect{/lang}</label>
                                                                {else}
                                                                        <a href="{link controller='TwitterAuth'}{/link}" class="thirdPartyLoginButton twitterLoginButton button"><span class="icon icon24 fa-twitter"></span> <span>{lang}wcf.user.3rdparty.twitter.connect{/lang}</span></a>
index 12fb4fb0f95bb70b8a363d2388d76a4dc6d19ec4..07235a95556aee329e334a5e67674cb6eccef00c 100644 (file)
@@ -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 <http://opensource.org/licenses/lgpl-license.php>
  * @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;
+    }
 }
index a176f9b4836a5ab3d01ad6f7988721cfbd13db61..be9a68dc1b12aafb99c0c393d4fe0e8810b9daae 100644 (file)
@@ -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:')) {
index 23275e0a5d7a0cf72a89c448ac73624ea6e5842a..4ef12f19fce2a601e5a646cc92864edd30b9ccda 100644 (file)
@@ -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.