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
*/
*/
public $neededModules = ['TWITTER_PUBLIC_KEY', 'TWITTER_PRIVATE_KEY'];
+ /**
+ * @var ClientInterface
+ */
+ private $httpClient;
+
/**
* @inheritDoc
*/
{
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;
}
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;
+ }
}