Implementation of a new request handler for OAuth 2 requests
authorCyperghost <olaf_schmitz_1@t-online.de>
Tue, 23 Apr 2024 12:04:42 +0000 (14:04 +0200)
committerCyperghost <olaf_schmitz_1@t-online.de>
Tue, 23 Apr 2024 12:04:42 +0000 (14:04 +0200)
wcfsetup/install/files/lib/action/AbstractOauth2Action.class.php
wcfsetup/install/files/lib/action/AbstractOauth2AuthAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/action/FacebookAuthAction.class.php
wcfsetup/install/files/lib/action/GithubAuthAction.class.php
wcfsetup/install/files/lib/action/GoogleAuthAction.class.php
wcfsetup/install/files/lib/action/TwitterAuthAction.class.php
wcfsetup/install/files/lib/system/user/authentication/oauth/Failure.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/authentication/oauth/Success.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/authentication/oauth/twitter/Failure.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/authentication/oauth/twitter/Success.class.php [new file with mode: 0644]

index 5c54d33385fc2418513638f72fcf413d0368b4cf..57e04b01e12a130b52ef5c38d1b05d1722ef75db 100644 (file)
@@ -25,6 +25,7 @@ use wcf\util\JSON;
  * @copyright   2001-2022 WoltLab GmbH
  * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  * @since 5.4
+ * @deprecated 6.1 use `AbstractOauth2AuthAction` instead
  */
 abstract class AbstractOauth2Action extends AbstractAction
 {
diff --git a/wcfsetup/install/files/lib/action/AbstractOauth2AuthAction.class.php b/wcfsetup/install/files/lib/action/AbstractOauth2AuthAction.class.php
new file mode 100644 (file)
index 0000000..ca675d6
--- /dev/null
@@ -0,0 +1,397 @@
+<?php
+
+namespace wcf\action;
+
+use CuyZ\Valinor\MapperBuilder;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Psr7\Request;
+use Laminas\Diactoros\Response\RedirectResponse;
+use Laminas\Diactoros\Uri;
+use ParagonIE\ConstantTime\Base64UrlSafe;
+use ParagonIE\ConstantTime\Hex;
+use Psr\Http\Client\ClientExceptionInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use wcf\data\user\User;
+use wcf\form\AccountManagementForm;
+use wcf\form\RegisterForm;
+use wcf\system\event\EventHandler;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\NamedUserException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\io\HttpFactory;
+use wcf\system\request\LinkHandler;
+use wcf\system\user\authentication\event\UserLoggedIn;
+use wcf\system\user\authentication\LoginRedirect;
+use wcf\system\user\authentication\oauth\exception\StateValidationException;
+use wcf\system\user\authentication\oauth\Failure as OAuth2Failure;
+use wcf\system\user\authentication\oauth\Success as OAuth2Success;
+use wcf\system\user\authentication\oauth\User as OauthUser;
+use wcf\system\WCF;
+use wcf\util\JSON;
+
+/**
+ * Generic implementation to handle the OAuth 2 flow.
+ *
+ * @author      Olaf Braun
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.1
+ */
+abstract class AbstractOauth2AuthAction implements RequestHandlerInterface
+{
+    private const STATE = self::class . "\0state_parameter";
+
+    private const PKCE = self::class . "\0pkce";
+
+    private ClientInterface $httpClient;
+
+    #[\Override]
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        if (!$this->isEnabled()) {
+            throw new IllegalLinkException();
+        }
+        if (WCF::getSession()->spiderIdentifier) {
+            throw new PermissionDeniedException();
+        }
+
+        $parameters = $this->mapParameters($request);
+
+        try {
+            if ($parameters instanceof OAuth2Success) {
+                $accessToken = $this->getAccessToken($parameters);
+                $user = $this->getUser($accessToken);
+
+                return $this->processUser($user);
+            } elseif ($parameters instanceof OAuth2Failure) {
+                return $this->handleError($parameters);
+            } else {
+                return $this->initiate();
+            }
+        } catch (NamedUserException $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,
+                    ]
+                )
+            );
+        }
+    }
+
+    /**
+     * Returns whether this OAuth provider is enabled.
+     */
+    abstract protected function isEnabled(): bool;
+
+    protected function mapParameters(ServerRequestInterface $request): OAuth2Success | OAuth2Failure | null
+    {
+        try {
+            $mapper = (new MapperBuilder())
+                ->allowSuperfluousKeys()
+                ->enableFlexibleCasting()
+                ->mapper();
+
+            return $mapper->map(
+                \sprintf("%s|%s", OAuth2Success::class, OAuth2Failure::class),
+                $request->getQueryParams()
+            );
+        } catch (\Throwable) {
+            return null;
+        }
+    }
+
+    /**
+     * Turns the 'code' into an access token.
+     */
+    protected function getAccessToken(OAuth2Success $auth2Success): array
+    {
+        $payload = [
+            'grant_type' => 'authorization_code',
+            'client_id' => $this->getClientId(),
+            'client_secret' => $this->getClientSecret(),
+            'redirect_uri' => $this->getCallbackUrl(),
+            'code' => $auth2Success->code,
+        ];
+
+        if ($this->usePkce()) {
+            if (!($verifier = WCF::getSession()->getVar(self::PKCE))) {
+                throw new StateValidationException('Missing PKCE verifier in session');
+            }
+
+            $payload['code_verifier'] = $verifier;
+        }
+
+        $request = new Request('POST', $this->getTokenEndpoint(), [
+            'Accept' => 'application/json',
+            'Content-Type' => 'application/x-www-form-urlencoded',
+        ], \http_build_query($payload, '', '&', \PHP_QUERY_RFC1738));
+
+        try {
+            $response = $this->getHttpClient()->send($request);
+        } finally {
+            // Validate state. Validation of state is executed after fetching the
+            // access_token to invalidate 'code'.
+            //
+            // Validation is happening within the `finally` so that the StateValidationException
+            // overwrites any HTTP exception (improving the error message).
+            if ($this->supportsState()) {
+                $this->validateState($auth2Success);
+            }
+        }
+
+        $parsed = JSON::decode((string)$response->getBody());
+
+        if (!empty($parsed['error'])) {
+            throw new \Exception(
+                \sprintf(
+                    "Access token response indicates an error: '%s'",
+                    $parsed['error']
+                )
+            );
+        }
+
+        if (empty($parsed['access_token'])) {
+            throw new \Exception("Access token response does not have the 'access_token' key.");
+        }
+
+        return $parsed;
+    }
+
+    /**
+     * Returns the 'client_id'.
+     */
+    abstract protected function getClientId(): string;
+
+    /**
+     * Returns the 'client_secret'.
+     */
+    abstract protected function getClientSecret(): string;
+
+    /**
+     * Returns the callback URL. This should most likely be:
+     *
+     * LinkHandler::getInstance()->getControllerLink(self::class)
+     */
+    abstract protected function getCallbackUrl(): string;
+
+    /**
+     * Whether to use PKCE (RFC 7636). Defaults to 'false'.
+     */
+    protected function usePkce(): bool
+    {
+        return false;
+    }
+
+    /**
+     * Returns the URL of the '/token' endpoint that turns the code into an access token.
+     */
+    abstract protected function getTokenEndpoint(): string;
+
+    /**
+     * Returns a "static" instance of the HTTP client to use to allow
+     * for TCP connection reuse.
+     */
+    protected function getHttpClient(): ClientInterface
+    {
+        if (!isset($this->httpClient)) {
+            $this->httpClient = HttpFactory::makeClientWithTimeout(5);
+        }
+
+        return $this->httpClient;
+    }
+
+    /**
+     * Whether to validate the state or not. Should be 'true' to protect
+     * against CSRF attacks.
+     */
+    abstract protected function supportsState(): bool;
+
+    /**
+     * Validates the state parameter.
+     */
+    protected function validateState(OAuth2Success $auth2Success): void
+    {
+        try {
+            if (!($sessionState = WCF::getSession()->getVar(self::STATE))) {
+                throw new StateValidationException('Missing state in session');
+            }
+            if (!\hash_equals($sessionState, $auth2Success->state)) {
+                throw new StateValidationException('Mismatching state');
+            }
+        } finally {
+            WCF::getSession()->unregister(self::STATE);
+        }
+    }
+
+    /**
+     * Turns the access token response into an oauth user.
+     */
+    abstract protected function getUser(array $accessToken): OauthUser;
+
+    /**
+     * Processes the user (e.g. by registering session variables and redirecting somewhere).
+     */
+    protected function processUser(OauthUser $oauthUser): ResponseInterface
+    {
+        $user = $this->getInternalUser($oauthUser);
+
+        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($this->getInUseErrorMessage());
+            } else {
+                // This account belongs to an existing user, we are not logged in.
+                // Perform the login.
+
+                WCF::getSession()->changeUser($user);
+                WCF::getSession()->update();
+                EventHandler::getInstance()->fire(
+                    new UserLoggedIn($user)
+                );
+
+                return new RedirectResponse(
+                    LoginRedirect::getUrl()
+                );
+            }
+        } else {
+            WCF::getSession()->register('__3rdPartyProvider', $this->getProviderName());
+
+            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);
+
+                return new RedirectResponse(
+                    LinkHandler::getInstance()->getControllerLink(
+                        AccountManagementForm::class,
+                        [],
+                        '#3rdParty'
+                    )
+                );
+            } else {
+                // 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.
+                return $this->performeRegister($oauthUser);
+            }
+        }
+    }
+
+    /**
+     * Returns the user who is assigned to the OAuth user.
+     */
+    protected function getInternalUser(OauthUser $oauthUser): User
+    {
+        return User::getUserByAuthData(\sprintf("%s:%s", $this->getProviderName(), $oauthUser->getId()));
+    }
+
+    /**
+     * Returns the name of the provider.
+     */
+    abstract protected function getProviderName(): string;
+
+    /**
+     * Returns the error message if the user is logged in and the external account is linked to another user.
+     */
+    protected function getInUseErrorMessage(): string
+    {
+        return WCF::getLanguage()->getDynamicVariable(
+            "wcf.user.3rdparty.{$this->getProviderName()}.connect.error.inuse"
+        );
+    }
+
+    protected function performeRegister(OauthUser $oauthUser): ResponseInterface
+    {
+        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();
+
+        return new RedirectResponse(
+            LinkHandler::getInstance()->getControllerLink(RegisterForm::class)
+        );
+    }
+
+    protected function handleError(OAuth2Failure $oauth2Failure): ResponseInterface
+    {
+        throw new NamedUserException(
+            WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.login.error.' . $oauth2Failure->error)
+        );
+    }
+
+    /**
+     * Initiates the OAuth flow by redirecting to the '/authorize' URL.
+     */
+    protected function initiate(): ResponseInterface
+    {
+        $parameters = [
+            'response_type' => 'code',
+            'client_id' => $this->getClientId(),
+            'scope' => $this->getScope(),
+            'redirect_uri' => $this->getCallbackUrl(),
+        ];
+
+        if ($this->supportsState()) {
+            $token = Hex::encode(\random_bytes(16));
+            WCF::getSession()->register(self::STATE, $token);
+
+            $parameters['state'] = $token;
+        }
+
+        if ($this->usePkce()) {
+            $verifier = Hex::encode(\random_bytes(32));
+            WCF::getSession()->register(self::PKCE, $verifier);
+
+            $parameters['code_challenge'] = Base64UrlSafe::encodeUnpadded(\hash('sha256', $verifier, true));
+            $parameters['code_challenge_method'] = 'S256';
+        }
+
+        $encodedParameters = \http_build_query($parameters, '', '&');
+
+        $url = new Uri($this->getAuthorizeUrl());
+        $query = $url->getQuery();
+        if ($query !== '') {
+            $url = $url->withQuery("{$query}&{$encodedParameters}");
+        } else {
+            $url = $url->withQuery($encodedParameters);
+        }
+
+        return new RedirectResponse($url);
+    }
+
+    /**
+     * Returns the 'scope' to request.
+     */
+    abstract protected function getScope(): string;
+
+    /**
+     * Returns the URL of the '/authorize' endpoint where the user is redirected to.
+     */
+    abstract protected function getAuthorizeUrl(): string;
+}
index 0646be6b9e102f1aaaadcfc782cb628a241586b6..627418adeb85fd2c7040b632ef6d1b2445681a36 100644 (file)
@@ -3,18 +3,8 @@
 namespace wcf\action;
 
 use GuzzleHttp\Psr7\Request;
-use Laminas\Diactoros\Response\RedirectResponse;
-use Psr\Http\Message\ResponseInterface;
-use wcf\data\user\User;
-use wcf\form\AccountManagementForm;
-use wcf\form\RegisterForm;
-use wcf\system\event\EventHandler;
-use wcf\system\exception\NamedUserException;
 use wcf\system\request\LinkHandler;
-use wcf\system\user\authentication\event\UserLoggedIn;
-use wcf\system\user\authentication\LoginRedirect;
 use wcf\system\user\authentication\oauth\User as OauthUser;
-use wcf\system\WCF;
 use wcf\util\JSON;
 use wcf\util\StringUtil;
 
@@ -25,56 +15,45 @@ use wcf\util\StringUtil;
  * @copyright   2001-2021 WoltLab GmbH
  * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  */
-final class FacebookAuthAction extends AbstractOauth2Action
+final class FacebookAuthAction extends AbstractOauth2AuthAction
 {
-    /**
-     * @inheritDoc
-     */
-    public $neededModules = ['FACEBOOK_PUBLIC_KEY', 'FACEBOOK_PRIVATE_KEY'];
+    #[\Override]
+    protected function isEnabled(): bool
+    {
+        return !empty(FACEBOOK_PUBLIC_KEY) && !empty(FACEBOOK_PRIVATE_KEY);
+    }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getTokenEndpoint(): string
     {
         return 'https://graph.facebook.com/oauth/access_token';
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getClientId(): string
     {
         return StringUtil::trim(FACEBOOK_PUBLIC_KEY);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getClientSecret(): string
     {
         return StringUtil::trim(FACEBOOK_PRIVATE_KEY);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getScope(): string
     {
         return 'email';
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getAuthorizeUrl(): string
     {
         return 'https://www.facebook.com/dialog/oauth';
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getCallbackUrl(): string
     {
         $callbackURL = LinkHandler::getInstance()->getControllerLink(self::class);
@@ -86,17 +65,13 @@ final class FacebookAuthAction extends AbstractOauth2Action
         }, $callbackURL);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function supportsState(): bool
     {
         return true;
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getUser(array $accessToken): OauthUser
     {
         $request = new Request('GET', 'https://graph.facebook.com/me?fields=email,id,name', [
@@ -116,69 +91,9 @@ final class FacebookAuthAction extends AbstractOauth2Action
         return new OauthUser($parsed);
     }
 
-    /**
-     * @inheritDoc
-     */
-    protected function processUser(OauthUser $oauthUser): ResponseInterface
+    #[\Override]
+    protected function getProviderName(): string
     {
-        $user = User::getUserByAuthData('facebook:' . $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.facebook.connect.error.inuse')
-                );
-            } else {
-                // This account belongs to an existing user, we are not logged in.
-                // Perform the login.
-
-                WCF::getSession()->changeUser($user);
-                WCF::getSession()->update();
-                EventHandler::getInstance()->fire(
-                    new UserLoggedIn($user)
-                );
-
-                return new RedirectResponse(
-                    LoginRedirect::getUrl()
-                );
-            }
-        } else {
-            WCF::getSession()->register('__3rdPartyProvider', 'facebook');
-
-            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);
-
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(
-                        AccountManagementForm::class,
-                        [],
-                        '#3rdParty'
-                    )
-                );
-            } else {
-                // 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();
-
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(RegisterForm::class)
-                );
-            }
-        }
+        return 'facebook';
     }
 }
index b2b01e8ada7f3244e9cc9a1fad7aeb60e52d4d67..51160548af51e96ff8d5ae0ee80cb016f216e6a9 100644 (file)
@@ -3,19 +3,11 @@
 namespace wcf\action;
 
 use GuzzleHttp\Psr7\Request;
-use Laminas\Diactoros\Response\RedirectResponse;
 use Psr\Http\Client\ClientExceptionInterface;
 use Psr\Http\Message\ResponseInterface;
 use wcf\data\user\User;
-use wcf\form\AccountManagementForm;
-use wcf\form\RegisterForm;
-use wcf\system\event\EventHandler;
-use wcf\system\exception\NamedUserException;
 use wcf\system\request\LinkHandler;
-use wcf\system\user\authentication\event\UserLoggedIn;
-use wcf\system\user\authentication\LoginRedirect;
 use wcf\system\user\authentication\oauth\User as OauthUser;
-use wcf\system\WCF;
 use wcf\util\JSON;
 use wcf\util\StringUtil;
 
@@ -26,72 +18,57 @@ use wcf\util\StringUtil;
  * @copyright   2001-2021 WoltLab GmbH
  * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  */
-final class GithubAuthAction extends AbstractOauth2Action
+final class GithubAuthAction extends AbstractOauth2AuthAction
 {
-    /**
-     * @inheritDoc
-     */
-    public $neededModules = ['GITHUB_PUBLIC_KEY', 'GITHUB_PRIVATE_KEY'];
-
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getTokenEndpoint(): string
     {
         return 'https://github.com/login/oauth/access_token';
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
+    public function isEnabled(): bool
+    {
+        return !empty(GITHUB_PUBLIC_KEY) && !empty(GITHUB_PRIVATE_KEY);
+    }
+
+    #[\Override]
     protected function getClientId(): string
     {
         return StringUtil::trim(GITHUB_PUBLIC_KEY);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getClientSecret(): string
     {
         return StringUtil::trim(GITHUB_PRIVATE_KEY);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getScope(): string
     {
         return 'user:email';
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getAuthorizeUrl(): string
     {
         return 'https://github.com/login/oauth/authorize';
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getCallbackUrl(): string
     {
         return LinkHandler::getInstance()->getControllerLink(self::class);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function supportsState(): bool
     {
         return true;
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getUser(array $accessToken): OauthUser
     {
         $request = new Request('GET', 'https://api.github.com/user', [
@@ -108,89 +85,41 @@ final class GithubAuthAction extends AbstractOauth2Action
         return new OauthUser($parsed);
     }
 
-    /**
-     * @inheritDoc
-     */
-    protected function processUser(OauthUser $oauthUser): ResponseInterface
+    #[\Override]
+    protected function getInternalUser(OauthUser $oauthUser): User
     {
-        $user = User::getUserByAuthData('github:' . $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.github.connect.error.inuse')
-                );
-            } else {
-                // This account belongs to an existing user, we are not logged in.
-                // Perform the login.
-
-                WCF::getSession()->changeUser($user);
-                WCF::getSession()->update();
-                EventHandler::getInstance()->fire(
-                    new UserLoggedIn($user)
-                );
-
-                return new RedirectResponse(
-                    LoginRedirect::getUrl()
-                );
-            }
-        } else {
-            WCF::getSession()->register('__3rdPartyProvider', 'github');
-
-            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);
-
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(
-                        AccountManagementForm::class,
-                        [],
-                        '#3rdParty'
-                    )
-                );
-            } else {
-                // 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.
-
-                try {
-                    $request = new Request('GET', 'https://api.github.com/user/emails', [
-                        'accept' => 'application/json',
-                        'authorization' => \sprintf('Bearer %s', $oauthUser["accessToken"]["access_token"]),
-                    ]);
-                    $response = $this->getHttpClient()->send($request);
-                    $emails = JSON::decode((string)$response->getBody());
-
-                    // search primary email
-                    $email = $emails[0]['email'];
-                    foreach ($emails as $tmp) {
-                        if ($tmp['primary']) {
-                            $email = $tmp['email'];
-                            break;
-                        }
-                    }
-                    $oauthUser["__email"] = $email;
-                } catch (ClientExceptionInterface $e) {
-                }
-
-                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);
+        return User::getUserByAuthData('github:' . $oauthUser->getId());
+    }
 
-                WCF::getSession()->update();
+    #[\Override]
+    protected function getProviderName(): string
+    {
+        return 'github';
+    }
 
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(RegisterForm::class)
-                );
+    #[\Override]
+    protected function performeRegister(OauthUser $oauthUser): ResponseInterface
+    {
+        try {
+            $request = new Request('GET', 'https://api.github.com/user/emails', [
+                'accept' => 'application/json',
+                'authorization' => \sprintf('Bearer %s', $oauthUser["accessToken"]["access_token"]),
+            ]);
+            $response = $this->getHttpClient()->send($request);
+            $emails = JSON::decode((string)$response->getBody());
+
+            // search primary email
+            $email = $emails[0]['email'];
+            foreach ($emails as $tmp) {
+                if ($tmp['primary']) {
+                    $email = $tmp['email'];
+                    break;
+                }
             }
+            $oauthUser["__email"] = $email;
+        } catch (ClientExceptionInterface $e) {
         }
+
+        return parent::performeRegister($oauthUser);
     }
 }
index 2052252e195b4fc1fba5137090fc5be458ebe33b..0b4e9bb03da41e4f44aeb9410391c4dba6703bf1 100644 (file)
@@ -3,18 +3,8 @@
 namespace wcf\action;
 
 use GuzzleHttp\Psr7\Request;
-use Laminas\Diactoros\Response\RedirectResponse;
-use Psr\Http\Message\ResponseInterface;
-use wcf\data\user\User;
-use wcf\form\AccountManagementForm;
-use wcf\form\RegisterForm;
-use wcf\system\event\EventHandler;
-use wcf\system\exception\NamedUserException;
 use wcf\system\request\LinkHandler;
-use wcf\system\user\authentication\event\UserLoggedIn;
-use wcf\system\user\authentication\LoginRedirect;
 use wcf\system\user\authentication\oauth\User as OauthUser;
-use wcf\system\WCF;
 use wcf\util\JSON;
 use wcf\util\StringUtil;
 
@@ -25,22 +15,14 @@ use wcf\util\StringUtil;
  * @copyright   2001-2021 WoltLab GmbH
  * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  */
-final class GoogleAuthAction extends AbstractOauth2Action
+final class GoogleAuthAction extends AbstractOauth2AuthAction
 {
-    /**
-     * @inheritDoc
-     */
-    public $neededModules = ['GOOGLE_PUBLIC_KEY', 'GOOGLE_PRIVATE_KEY'];
-
-    /**
-     * @var array
-     */
-    private $configuration;
+    private array $configuration;
 
     /**
      * Returns Google's OpenID Connect configuration.
      */
-    private function getConfiguration()
+    private function getConfiguration(): array
     {
         if (!isset($this->configuration)) {
             $request = new Request('GET', 'https://accounts.google.com/.well-known/openid-configuration');
@@ -52,65 +34,55 @@ final class GoogleAuthAction extends AbstractOauth2Action
         return $this->configuration;
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
+    protected function isEnabled(): bool
+    {
+        return !empty(GOOGLE_PUBLIC_KEY) && !empty(GOOGLE_PRIVATE_KEY);
+    }
+
+    #[\Override]
     protected function getTokenEndpoint(): string
     {
         return $this->getConfiguration()['token_endpoint'];
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getClientId(): string
     {
         return StringUtil::trim(GOOGLE_PUBLIC_KEY);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getClientSecret(): string
     {
         return StringUtil::trim(GOOGLE_PRIVATE_KEY);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getScope(): string
     {
         return 'profile openid email';
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getAuthorizeUrl(): string
     {
         return $this->getConfiguration()['authorization_endpoint'];
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getCallbackUrl(): string
     {
         return LinkHandler::getInstance()->getControllerLink(self::class);
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function supportsState(): bool
     {
         return true;
     }
 
-    /**
-     * @inheritDoc
-     */
+    #[\Override]
     protected function getUser(array $accessToken): OauthUser
     {
         $request = new Request('GET', $this->getConfiguration()['userinfo_endpoint'], [
@@ -130,69 +102,9 @@ final class GoogleAuthAction extends AbstractOauth2Action
         return new OauthUser($parsed);
     }
 
-    /**
-     * @inheritDoc
-     */
-    protected function processUser(OauthUser $oauthUser): ResponseInterface
+    #[\Override]
+    protected function getProviderName(): string
     {
-        $user = User::getUserByAuthData('google:' . $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.google.connect.error.inuse')
-                );
-            } else {
-                // This account belongs to an existing user, we are not logged in.
-                // Perform the login.
-
-                WCF::getSession()->changeUser($user);
-                WCF::getSession()->update();
-                EventHandler::getInstance()->fire(
-                    new UserLoggedIn($user)
-                );
-
-                return new RedirectResponse(
-                    LoginRedirect::getUrl()
-                );
-            }
-        } else {
-            WCF::getSession()->register('__3rdPartyProvider', 'google');
-
-            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);
-
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(
-                        AccountManagementForm::class,
-                        [],
-                        '#3rdParty'
-                    )
-                );
-            } else {
-                // 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();
-
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(RegisterForm::class)
-                );
-            }
-        }
+        return 'google';
     }
 }
index 4c7d37d4d26651136ed384c5dc43365b56b724f7..ebcc2fbf280c78bc82bab9315bbe5e35ef86d8bb 100644 (file)
@@ -2,23 +2,19 @@
 
 namespace wcf\action;
 
-use GuzzleHttp\ClientInterface;
+use CuyZ\Valinor\MapperBuilder;
 use GuzzleHttp\Psr7\Request;
 use Laminas\Diactoros\Response\RedirectResponse;
 use ParagonIE\ConstantTime\Base64;
 use ParagonIE\ConstantTime\Hex;
-use Psr\Http\Client\ClientExceptionInterface;
 use Psr\Http\Message\ResponseInterface;
-use wcf\data\user\User;
-use wcf\form\RegisterForm;
-use wcf\system\event\EventHandler;
-use wcf\system\exception\NamedUserException;
-use wcf\system\exception\PermissionDeniedException;
-use wcf\system\io\HttpFactory;
+use Psr\Http\Message\ServerRequestInterface;
 use wcf\system\request\LinkHandler;
-use wcf\system\user\authentication\event\UserLoggedIn;
-use wcf\system\user\authentication\LoginRedirect;
 use wcf\system\user\authentication\oauth\exception\StateValidationException;
+use wcf\system\user\authentication\oauth\Failure as OAuth2Failure;
+use wcf\system\user\authentication\oauth\Success as OAuth2Success;
+use wcf\system\user\authentication\oauth\twitter\Failure as OAuth2TwitterFailure;
+use wcf\system\user\authentication\oauth\twitter\Success as OAuth2TwitterSuccess;
 use wcf\system\user\authentication\oauth\User as OauthUser;
 use wcf\system\WCF;
 use wcf\util\JSON;
@@ -31,146 +27,83 @@ use wcf\util\StringUtil;
  * @copyright   2001-2021 WoltLab GmbH
  * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  */
-final class TwitterAuthAction extends AbstractAction
+final class TwitterAuthAction extends AbstractOauth2AuthAction
 {
-    /**
-     * @inheritDoc
-     */
-    public $neededModules = ['TWITTER_PUBLIC_KEY', 'TWITTER_PRIVATE_KEY'];
+    #[\Override]
+    protected function isEnabled(): bool
+    {
+        return !empty(TWITTER_PUBLIC_KEY) && !empty(TWITTER_PRIVATE_KEY);
+    }
 
-    private ClientInterface $httpClient;
+    #[\Override]
+    protected function getClientId(): string
+    {
+        return StringUtil::trim(TWITTER_PUBLIC_KEY);
+    }
 
-    /**
-     * @inheritDoc
-     */
-    public function readParameters()
+    #[\Override]
+    protected function getClientSecret(): string
     {
-        parent::readParameters();
+        return StringUtil::trim(TWITTER_PRIVATE_KEY);
+    }
 
-        if (WCF::getSession()->spiderIdentifier) {
-            throw new PermissionDeniedException();
-        }
+    #[\Override]
+    protected function getCallbackUrl(): string
+    {
+        return LinkHandler::getInstance()->getControllerLink(self::class);
     }
 
-    /**
-     * @inheritDoc
-     */
-    public function execute(): ResponseInterface
+    #[\Override]
+    protected function getTokenEndpoint(): string
     {
-        parent::execute();
+        return 'https://api.twitter.com/oauth/access_token';
+    }
 
-        try {
-            if (isset($_GET['oauth_token']) && isset($_GET['oauth_verifier'])) {
-                $token = $this->verifierToAccessToken(
-                    $_GET['oauth_token'],
-                    $_GET['oauth_verifier']
-                );
-
-                $oauthUser = $this->getUser($token);
-
-                return $this->processUser($oauthUser);
-            } elseif (isset($_GET['denied'])) {
-                throw new NamedUserException(
-                    WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.login.error.denied')
-                );
-            } else {
-                return $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';
-            }
+    #[\Override]
+    protected function supportsState(): bool
+    {
+        return false;
+    }
 
-            throw new NamedUserException(WCF::getLanguage()->getDynamicVariable(
-                'wcf.user.3rdparty.login.error.' . $type,
-                [
-                    'exceptionID' => $exceptionID,
-                ]
-            ));
-        }
+    #[\Override]
+    protected function getProviderName(): string
+    {
+        return 'twitter';
+    }
 
-        throw new \LogicException("Unreachable");
+    #[\Override]
+    protected function getScope(): string
+    {
+        // Twitter OAuth 1.0a does not support scopes
+        return '';
     }
 
-    /**
-     * Processes the user (e.g. by registering session variables and redirecting somewhere).
-     */
-    protected function processUser(OauthUser $oauthUser): ResponseInterface
+    #[\Override]
+    protected function getAuthorizeUrl(): string
     {
-        $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')
-                );
-            } else {
-                // This account belongs to an existing user, we are not logged in.
-                // Perform the login.
-
-                WCF::getSession()->changeUser($user);
-                WCF::getSession()->update();
-                EventHandler::getInstance()->fire(
-                    new UserLoggedIn($user)
-                );
-
-                return new RedirectResponse(
-                    LoginRedirect::getUrl()
-                );
-            }
-        } 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);
-
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(
-                        AccountManagementForm::class,
-                        [],
-                        '#3rdParty'
-                    )
-                );
-            } else {
-                // 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();
-
-                return new RedirectResponse(
-                    LinkHandler::getInstance()->getControllerLink(RegisterForm::class)
-                );
-            }
+        return 'https://api.twitter.com/oauth/authenticate';
+    }
+
+    #[\Override]
+    protected function mapParameters(ServerRequestInterface $request): OAuth2Success | OAuth2Failure | null
+    {
+        try {
+            $mapper = (new MapperBuilder())
+                ->allowSuperfluousKeys()
+                ->enableFlexibleCasting()
+                ->mapper();
+
+            return $mapper->map(
+                \sprintf("%s|%s", OAuth2TwitterSuccess::class, OAuth2TwitterFailure::class),
+                $request->getQueryParams()
+            );
+        } catch (\Throwable) {
+            return null;
         }
     }
 
-    /**
-     * Turns the access token response into an oauth user.
-     */
-    private function getUser(array $accessToken): OauthUser
+    #[\Override]
+    protected function getUser(array $accessToken): OauthUser
     {
         $uri = 'https://api.twitter.com/1.1/account/verify_credentials.json';
         $oauthHeader = [
@@ -216,10 +149,8 @@ final class TwitterAuthAction extends AbstractAction
         return new OauthUser($parsed);
     }
 
-    /**
-     * Turns the verifier provided by Twitter into an access token.
-     */
-    private function verifierToAccessToken(string $oauthToken, string $oauthVerifier)
+    #[\Override]
+    protected function getAccessToken(OAuth2Success $auth2Success): array
     {
         $initData = WCF::getSession()->getVar('__twitterInit');
         WCF::getSession()->unregister('__twitterInit');
@@ -227,32 +158,31 @@ final class TwitterAuthAction extends AbstractAction
             throw new StateValidationException('Missing state in session');
         }
 
-        if (!\hash_equals((string)$initData['oauth_token'], $oauthToken)) {
+        if (!\hash_equals((string)$initData['oauth_token'], $auth2Success->code)) {
             throw new StateValidationException('oauth_token mismatch');
         }
 
-        $uri = 'https://api.twitter.com/oauth/access_token';
         $oauthHeader = [
-            'oauth_consumer_key' => StringUtil::trim(TWITTER_PUBLIC_KEY),
+            'oauth_consumer_key' => $this->getClientId(),
             'oauth_nonce' => Hex::encode(\random_bytes(20)),
             'oauth_signature_method' => 'HMAC-SHA1',
             'oauth_timestamp' => TIME_NOW,
             'oauth_version' => '1.0',
-            'oauth_token' => $oauthToken,
+            'oauth_token' => $auth2Success->code,
         ];
         $postData = [
-            'oauth_verifier' => $oauthVerifier,
+            'oauth_verifier' => $auth2Success->state,
         ];
 
         $signature = $this->createSignature(
-            $uri,
+            $this->getTokenEndpoint(),
             \array_merge($oauthHeader, $postData)
         );
         $oauthHeader['oauth_signature'] = $signature;
 
         $request = new Request(
             'POST',
-            $uri,
+            $this->getTokenEndpoint(),
             [
                 'authorization' => \sprintf('OAuth %s', $this->buildOAuthHeader($oauthHeader)),
                 'content-type' => 'application/x-www-form-urlencoded',
@@ -277,13 +207,12 @@ final class TwitterAuthAction extends AbstractAction
     /**
      * Requests an request_token to initiate the OAuth flow.
      */
-    private function getRequestToken()
+    private function getRequestToken(): array
     {
-        $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_callback' => $this->getCallbackUrl(),
+            'oauth_consumer_key' => $this->getClientId(),
             'oauth_nonce' => Hex::encode(\random_bytes(20)),
             'oauth_signature_method' => 'HMAC-SHA1',
             'oauth_timestamp' => TIME_NOW,
@@ -318,10 +247,8 @@ final class TwitterAuthAction extends AbstractAction
         return $data;
     }
 
-    /**
-     * Initiates the OAuth flow by redirecting to the '/authenticate' URL.
-     */
-    private function initiate(): ResponseInterface
+    #[\Override]
+    protected function initiate(): ResponseInterface
     {
         $data = $this->getRequestToken();
 
@@ -329,7 +256,8 @@ final class TwitterAuthAction extends AbstractAction
 
         return new RedirectResponse(
             \sprintf(
-                'https://api.twitter.com/oauth/authenticate?%s',
+                '%s?%s',
+                $this->getAuthorizeUrl(),
                 \http_build_query([
                     'oauth_token' => $data['oauth_token'],
                 ], '', '&')
@@ -385,21 +313,8 @@ final class TwitterAuthAction extends AbstractAction
         }
 
         $base = $method . "&" . \rawurlencode($url) . "&" . \rawurlencode($parameterString);
-        $key = \rawurlencode(StringUtil::trim(TWITTER_PRIVATE_KEY)) . '&' . \rawurlencode($tokenSecret);
+        $key = \rawurlencode($this->getClientSecret()) . '&' . \rawurlencode($tokenSecret);
 
         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.
-     */
-    protected function getHttpClient(): ClientInterface
-    {
-        if (!isset($this->httpClient)) {
-            $this->httpClient = HttpFactory::makeClientWithTimeout(5);
-        }
-
-        return $this->httpClient;
-    }
 }
diff --git a/wcfsetup/install/files/lib/system/user/authentication/oauth/Failure.class.php b/wcfsetup/install/files/lib/system/user/authentication/oauth/Failure.class.php
new file mode 100644 (file)
index 0000000..0aa7686
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace wcf\system\user\authentication\oauth;
+
+/**
+ * Represents request parameters for a failed/denied OAuth 2 login.
+ *
+ * @author      Olaf Braun
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.1
+ */
+class Failure
+{
+    public function __construct(public readonly string $error)
+    {
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/user/authentication/oauth/Success.class.php b/wcfsetup/install/files/lib/system/user/authentication/oauth/Success.class.php
new file mode 100644 (file)
index 0000000..bd226c6
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace wcf\system\user\authentication\oauth;
+
+/**
+ * Represents request parameters for a successful OAuth 2 login.
+ *
+ * @author      Olaf Braun
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.1
+ */
+class Success
+{
+    public function __construct(public readonly string $code, public readonly string $state = '')
+    {
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/user/authentication/oauth/twitter/Failure.class.php b/wcfsetup/install/files/lib/system/user/authentication/oauth/twitter/Failure.class.php
new file mode 100644 (file)
index 0000000..c12e638
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\user\authentication\oauth\twitter;
+
+use wcf\system\user\authentication\oauth\Failure as BaseFailure;
+
+/**
+ * Represents request parameters for a failed/denied OAuth 2 login to Twitter.
+ *
+ * @author      Olaf Braun
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.1
+ */
+final class Failure extends BaseFailure
+{
+    public function __construct(string $denied)
+    {
+        parent::__construct($denied);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/user/authentication/oauth/twitter/Success.class.php b/wcfsetup/install/files/lib/system/user/authentication/oauth/twitter/Success.class.php
new file mode 100644 (file)
index 0000000..bde4dc2
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+namespace wcf\system\user\authentication\oauth\twitter;
+
+use wcf\system\user\authentication\oauth\Success as BaseSuccess;
+
+/**
+ * Represents the request parameters for a successful OAuth 2 login to Twitter.
+ *
+ * @author      Olaf Braun
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.1
+ */
+final class Success extends BaseSuccess
+{
+    public function __construct(
+        string $oauth_token,
+        string $oauth_verifier,
+    ) {
+        parent::__construct($oauth_token, $oauth_verifier);
+    }
+}