* @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
{
--- /dev/null
+<?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;
+}
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;
* @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);
}, $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', [
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';
}
}
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;
* @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', [
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);
}
}
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;
* @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');
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'], [
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';
}
}
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;
* @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 = [
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');
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',
/**
* 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,
return $data;
}
- /**
- * Initiates the OAuth flow by redirecting to the '/authenticate' URL.
- */
- private function initiate(): ResponseInterface
+ #[\Override]
+ protected function initiate(): ResponseInterface
{
$data = $this->getRequestToken();
return new RedirectResponse(
\sprintf(
- 'https://api.twitter.com/oauth/authenticate?%s',
+ '%s?%s',
+ $this->getAuthorizeUrl(),
\http_build_query([
'oauth_token' => $data['oauth_token'],
], '', '&')
}
$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;
- }
}
--- /dev/null
+<?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)
+ {
+ }
+}
--- /dev/null
+<?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 = '')
+ {
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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);
+ }
+}