Add AbstractOauth2Action
authorTim Düsterhus <duesterhus@woltlab.com>
Thu, 7 Jan 2021 11:37:54 +0000 (12:37 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Thu, 7 Jan 2021 15:49:48 +0000 (16:49 +0100)
wcfsetup/install/files/lib/action/AbstractOauth2Action.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/authentication/oauth/User.class.php [new file with mode: 0644]

diff --git a/wcfsetup/install/files/lib/action/AbstractOauth2Action.class.php b/wcfsetup/install/files/lib/action/AbstractOauth2Action.class.php
new file mode 100644 (file)
index 0000000..39e0a8b
--- /dev/null
@@ -0,0 +1,204 @@
+<?php
+namespace wcf\action;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Psr7\Request;
+use ParagonIE\ConstantTime\Hex;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\NamedUserException;
+use wcf\system\io\HttpFactory;
+use wcf\system\user\authentication\oauth\User as OauthUser;
+use wcf\system\WCF;
+use wcf\util\HeaderUtil;
+use wcf\util\JSON;
+
+/**
+ * Generic implementation to handle the OAuth 2 flow.
+ * 
+ * @author     Tim Duesterhus
+ * @copyright  2001-2021 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\Action
+ */
+abstract class AbstractOauth2Action extends AbstractAction {
+       private const STATE = self::class . "\0state_parameter";
+       
+       /**
+        * @var ClientInterface
+        */
+       private $httpClient;
+       
+       /**
+        * @inheritDoc
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (WCF::getSession()->spiderID) {
+                       throw new IllegalLinkException();
+               }
+       }
+       
+       /**
+        * Returns a "static" instance of the HTTP client to use to allow
+        * for TCP connection reuse.
+        */
+       protected final function getHttpClient(): ClientInterface {
+               if (!$this->httpClient) {
+                       $this->httpClient = HttpFactory::makeClient();
+               }
+               
+               return $this->httpClient;
+       }
+       
+       /**
+        * Returns the URL of the '/token' endpoint that turns the code into an access token.
+        */
+       abstract protected function getTokenEndpoint(): string;
+       
+       /**
+        * Returns the 'client_id'.
+        */
+       abstract protected function getClientId(): string;
+       
+       /**
+        * Returns the 'client_secret'.
+        */
+       abstract protected function getClientSecret(): string;
+       
+       /**
+        * 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;
+       
+       /**
+        * Returns the callback URL. This should most likely be:
+        * 
+        * LinkHandler::getInstance()->getControllerLink(self::class)
+        */
+       abstract protected function getCallbackUrl(): string;
+       
+       /**
+        * Whether to validate the state or not. Should be 'true' to protect
+        * against CSRF attacks.
+        */
+       abstract protected function supportsState(): bool;
+       
+       /**
+        * 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).
+        */
+       abstract protected function processUser(OauthUser $oauthUser);
+
+       /**
+        * Validates the state parameter.
+        */
+       protected function validateState() {
+               if (!isset($_GET['state'])) {
+                       throw new \Exception('Missing state parameter');
+               }
+               if (!($sessionState = WCF::getSession()->getVar(self::STATE))) {
+                       throw new \Exception('Missing state in session');
+               }
+               if (!\hash_equals($sessionState, (string) $_GET['state'])) {
+                       throw new \Exception('Mismatching state');
+               }
+               
+               WCF::getSession()->unregister(self::STATE);
+       }
+       
+       /**
+        * Turns the 'code' into an access token.
+        */
+       protected function codeToAccessToken(string $code): array {
+               $request = new Request('POST', $this->getTokenEndpoint(), [
+                       'Accept' => 'application/json',
+                       'Content-Type' => 'application/x-www-form-urlencoded',
+               ], http_build_query([
+                       'grant_type' => 'authorization_code',
+                       'client_id' => $this->getClientId(),
+                       'client_secret' => $this->getClientSecret(),
+                       'redirect_uri' => $this->getCallbackUrl(),
+                       'code' => $_GET['code'],
+               ], '', '&', PHP_QUERY_RFC1738));
+               
+               $response = $this->getHttpClient()->send($request);
+               
+               // Validate state. Validation of state is executed after fetching the
+               // access_token to invalidate 'code'.
+               if ($this->supportsState()) {
+                       $this->validateState();
+               }
+               
+               $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;
+       }
+       
+       protected function handleError(string $error) {
+               throw new NamedUserException(WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.login.error.'.$error));
+       }
+       
+       /**
+        * Initiates the OAuth flow by redirecting to the '/authorize' URL.
+        */
+       protected function initiate() {
+               $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;
+               }
+               
+               $url = $this->getAuthorizeUrl().'?'.http_build_query($parameters, '', '&');
+               
+               HeaderUtil::redirect($url);
+               exit;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function execute() {
+               parent::execute();
+               
+               if (isset($_GET['code'])) {
+                       $accessToken = $this->codeToAccessToken($_GET['code']);
+                       $oauthUser = $this->getUser($accessToken);
+                       
+                       $this->processUser($oauthUser);
+               }
+               else if (isset($_GET['error'])) {
+                       $this->handleError($_GET['error']);
+               }
+               else {
+                       $this->initiate();
+               }
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/user/authentication/oauth/User.class.php b/wcfsetup/install/files/lib/system/user/authentication/oauth/User.class.php
new file mode 100644 (file)
index 0000000..5494e74
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+namespace wcf\system\user\authentication\oauth;
+
+/**
+ * Represents user information retrieved from an OAuth provider.
+ * 
+ * @author     Tim Duesterhus
+ * @copyright  2001-2021 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\System\User\Authentication\Oauth
+ * @since      5.4
+ */
+final class User implements \ArrayAccess {
+       private $data = [];
+       
+       public function __construct(array $data) {
+               if (empty($data['__id'])) {
+                       throw new \InvalidArgumentException("Missing '__id' key");
+               }
+               if (empty($data['__username'])) {
+                       throw new \InvalidArgumentException("Missing '__username' key");
+               }
+               
+               $this->data = $data;
+       }
+       
+       /**
+        * Returns the unique identifier for this user at the OAuth provider.
+        */
+       public function getId(): string {
+               return $this['__id'];
+       }
+       
+       /**
+        * Returns what the user considers their "username" or "handle" at
+        * the OAuth provider.
+        * 
+        * Depending on the provider this might be a real name, a handle or
+        * something entirely different.
+        */
+       public function getUsername(): string {
+               return $this['__username'];
+       }
+       
+       /**
+        * Returns the user's email at the OAuth provider.
+        * 
+        * Some providers might not return an email, so this method may
+        * return null.
+        */
+       public function getEmail(): ?string {
+               return $this['__email'] ?? null;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function offsetGet($offset) {
+               return $this->data[$offset] ?? null;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function offsetSet($offset, $value) {
+               if ($offset === '__id') {
+                       throw new \BadMethodCallException('You may not modify the id.');
+               }
+               if ($offset === '__username') {
+                       throw new \BadMethodCallException('You may not modify the username.');
+               }
+               
+               $this->data[$offset] = $value;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function offsetUnset($offset) {
+               if ($offset === '__id') {
+                       throw new \BadMethodCallException('You may not modify the id.');
+               }
+               if ($offset === '__username') {
+                       throw new \BadMethodCallException('You may not modify the username.');
+               }
+               
+               unset($this->data[$offset]);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function offsetExists($offset) {
+               return isset($this->data[$offset]);
+       }
+}