From e956503e6fd4411acce6e317d7458559c2b5070b Mon Sep 17 00:00:00 2001 From: Jonathan Lelievre Date: Wed, 3 Apr 2024 19:56:45 +0200 Subject: [PATCH] Refactor the authorization server to match the new interface, autocinfigure the service so that it is used by the TokenAuthenticator --- config.xml | 2 +- config/services.yml | 8 +- keycloak_connector_demo.php | 25 ++ src/Form/ConfigurationDataConfiguration.php | 3 +- src/Form/ConfigurationType.php | 2 +- src/OAuth2/KeyCloakResourceServer.php | 194 ---------------- src/OAuth2/KeycloakAuthorizationServer.php | 245 ++++++++++++++++++++ 7 files changed, 277 insertions(+), 202 deletions(-) delete mode 100644 src/OAuth2/KeyCloakResourceServer.php create mode 100644 src/OAuth2/KeycloakAuthorizationServer.php diff --git a/config.xml b/config.xml index d253edc..b35feea 100644 --- a/config.xml +++ b/config.xml @@ -8,4 +8,4 @@ 1 0 - + \ No newline at end of file diff --git a/config/services.yml b/config/services.yml index 82fba89..e3f763f 100644 --- a/config/services.yml +++ b/config/services.yml @@ -47,12 +47,12 @@ services: public: true autowire: true - PrestaShop\Module\KeycloakConnectorDemo\OAuth2\KeyCloakResourceServer: - class: PrestaShop\Module\KeycloakConnectorDemo\OAuth2\KeyCloakResourceServer + PrestaShop\Module\KeycloakConnectorDemo\OAuth2\KeycloakAuthorizationServer: + # Autoconfigure to get tag from interface and to be injected in TokenAuthenticator + autoconfigure: true + class: PrestaShop\Module\KeycloakConnectorDemo\OAuth2\KeycloakAuthorizationServer arguments: - '@prestashop.module.keycloak_connector_demo.client' - '@prestashop.adapter.legacy.configuration' - '@prestashop.module.keycloak_connector_demo.php_encrypt' - '@PrestaShop\Module\KeycloakConnectorDemo\RequestBuilder' - - PrestaShop\PrestaShop\Core\OAuth2\OAuth2Interface: '@PrestaShop\Module\KeycloakConnectorDemo\OAuth2\KeyCloakResourceServer' diff --git a/keycloak_connector_demo.php b/keycloak_connector_demo.php index b35f315..7b713b8 100644 --- a/keycloak_connector_demo.php +++ b/keycloak_connector_demo.php @@ -18,6 +18,7 @@ * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ +use PrestaShop\Module\KeycloakConnectorDemo\Form\ConfigurationDataConfiguration; use PrestaShop\PrestaShop\Adapter\SymfonyContainer; if (!defined('_PS_VERSION_')) { @@ -53,4 +54,28 @@ public function getContent(): void $router = $container->get('router'); Tools::redirectAdmin($router->generate('keycloak_connector_configuration')); } + + public function install() + { + if (!parent::install()) { + return false; + } + + // Inject default configuration on install (the value is encrypted in the DB); + $encryption = new PhpEncryption(_NEW_COOKIE_KEY_); + + return Configuration::updateValue(ConfigurationDataConfiguration::REALM_ENDPOINT, $encryption->encrypt('http://localhost:8003/realms/prestashop')); + } + + public function uninstall() + { + if (!parent::uninstall()) { + return false; + } + + // Delete configuration if present + Configuration::deleteByName(ConfigurationDataConfiguration::REALM_ENDPOINT); + + return true; + } } diff --git a/src/Form/ConfigurationDataConfiguration.php b/src/Form/ConfigurationDataConfiguration.php index 2aaa357..64a3779 100644 --- a/src/Form/ConfigurationDataConfiguration.php +++ b/src/Form/ConfigurationDataConfiguration.php @@ -28,7 +28,6 @@ use PrestaShop\PrestaShop\Core\ConfigurationInterface; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; -use RuntimeException; final class ConfigurationDataConfiguration implements DataConfigurationInterface { @@ -72,7 +71,7 @@ public function getConfiguration(): array if (!empty($endpoint)) { $endpoint = $this->encryption->decrypt($endpoint); if (!is_string($endpoint)) { - throw new RuntimeException('Unable to decrypt realm endpoint configuration'); + $endpoint = ''; } } diff --git a/src/Form/ConfigurationType.php b/src/Form/ConfigurationType.php index f201cc3..42fea78 100644 --- a/src/Form/ConfigurationType.php +++ b/src/Form/ConfigurationType.php @@ -32,7 +32,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add(ConfigurationDataConfiguration::REALM_ENDPOINT, TextType::class, [ 'label' => $this->trans('Keycloak realm endpoint', 'Modules.Keycloakconnectordemo.Admin'), - 'help' => $this->trans('i.e. https://my.keycloak.instance.org/realms/master', 'Modules.Keycloakconnectordemo.Admin'), + 'help' => $this->trans('i.e. http://localhost:8003/realms/prestashop', 'Modules.Keycloakconnectordemo.Admin'), ]); } } diff --git a/src/OAuth2/KeyCloakResourceServer.php b/src/OAuth2/KeyCloakResourceServer.php deleted file mode 100644 index 7f676b3..0000000 --- a/src/OAuth2/KeyCloakResourceServer.php +++ /dev/null @@ -1,194 +0,0 @@ - - * @copyright Since 2007 PrestaShop SA and Contributors - * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 - */ - -declare(strict_types=1); - -namespace PrestaShop\Module\KeycloakConnectorDemo\OAuth2; - -use Lcobucci\Clock\SystemClock; -use Lcobucci\JWT\Encoding\JoseEncoder; -use Lcobucci\JWT\Signer\Key; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Token; -use Lcobucci\JWT\Token\Parser; -use Lcobucci\JWT\UnencryptedToken; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use Lcobucci\JWT\Validation\Validator; -use PhpEncryption; -use PrestaShop\Module\KeycloakConnectorDemo\Form\ConfigurationDataConfiguration; -use PrestaShop\Module\KeycloakConnectorDemo\RequestBuilder; -use PrestaShop\PrestaShop\Core\ConfigurationInterface; -use PrestaShop\PrestaShop\Core\Security\OAuth2\AuthorisationServerInterface; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ServerRequestInterface; -use RuntimeException; -use Symfony\Component\Security\Core\User\UserInterface; - -class KeyCloakResourceServer implements AuthorisationServerInterface -{ - /** - * @var ClientInterface - */ - private $client; - - /** - * @var PhpEncryption - */ - private $phpEncryption; - - /** - * @var ConfigurationInterface - */ - private $configuration; - - /** - * @var RequestBuilder - */ - private $requestBuilder; - - public function __construct( - ClientInterface $client, - ConfigurationInterface $configuration, - PhpEncryption $phpEncryption, - RequestBuilder $requestBuilder - ) { - $this->client = $client; - $this->configuration = $configuration; - $this->phpEncryption = $phpEncryption; - $this->requestBuilder = $requestBuilder; - } - - public function isTokenValid(ServerRequestInterface $request): bool - { - $token = $this->getTokenFromRequest($request); - if ($token === null) { - return false; - } - - $certs = $this->getCerts(); - if ($certs === null) { - return false; - } - - $certificate = $this->getRightCertificate($token, $certs['keys']); - if ($certificate === null) { - return false; - } - - return (new Validator())->validate($token, ...$this->getValidationConstraints($certificate)); - } - - /** - * @return array>>|null - */ - private function getCerts(): ?array - { - try { - $response = $this->client->sendRequest($this->getCertsRequest()); - } catch (ClientExceptionInterface $e) { - return null; - } - - if ($response->getStatusCode() !== 200) { - return null; - } - - $json = json_decode($response->getBody()->getContents(), true); - if (!is_array($json) || !isset($json['keys'])) { - return null; - } - - return $json; - } - - /** - * @param Token $token - * @param array> $certs - * - * @return Key|null - */ - private function getRightCertificate(Token $token, array $certs): ?Key - { - foreach ($certs as $key) { - if ($key['kid'] === $token->headers()->get('kid')) { - return InMemory::plainText( - "-----BEGIN CERTIFICATE-----\n" . $key['x5c'][0] . "\n-----END CERTIFICATE-----" - ); - } - } - - return null; - } - - private function getCertsRequest(): RequestInterface - { - $endpoint = $this->phpEncryption->decrypt( - $this->configuration->get(ConfigurationDataConfiguration::REALM_ENDPOINT) - ); - - if (!is_string($endpoint)) { - throw new RuntimeException('Unable to decrypt realm endpoint configuration'); - } - - return $this->requestBuilder->getCertsRequest($endpoint); - } - - /** - * @param Key $key - * - * @return array{SignedWith, StrictValidAt} - */ - private function getValidationConstraints(Key $key): array - { - return [ - new SignedWith(new Sha256(), $key), - new StrictValidAt(SystemClock::fromUTC()), - ]; - } - - public function getUser(ServerRequestInterface $request): ?UserInterface - { - /** @var UnencryptedToken|null $token */ - $token = $this->getTokenFromRequest($request); - if ($token === null) { - return null; - } - $audience = $token->claims()->get('clientId') ?? $token->claims()->get('client_id'); - if (!is_string($audience)) { - return null; - } - - return new KeyCloakUser($audience); - } - - private function getTokenFromRequest(ServerRequestInterface $request): ?Token - { - $authorization = $request->getHeader('Authorization')[0] ?? null; - if ($authorization === null || strpos($authorization, 'Bearer ') !== 0 || empty(explode(' ', $authorization)[1])) { - return null; - } - - return (new Parser(new JoseEncoder()))->parse(explode(' ', $authorization)[1]); - } -} diff --git a/src/OAuth2/KeycloakAuthorizationServer.php b/src/OAuth2/KeycloakAuthorizationServer.php new file mode 100644 index 0000000..345609a --- /dev/null +++ b/src/OAuth2/KeycloakAuthorizationServer.php @@ -0,0 +1,245 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\KeycloakConnectorDemo\OAuth2; + +use Lcobucci\JWT\Encoding\JoseEncoder; +use Lcobucci\JWT\Signer\Key; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa\Sha256; +use Lcobucci\JWT\Token as TokenInterface; +use Lcobucci\JWT\Token\InvalidTokenStructure; +use Lcobucci\JWT\Token\Parser; +use Lcobucci\JWT\UnencryptedToken; +use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\Constraint\StrictValidAt; +use Lcobucci\JWT\Validation\Validator; +use PhpEncryption; +use PrestaShop\Module\KeycloakConnectorDemo\Form\ConfigurationDataConfiguration; +use PrestaShop\Module\KeycloakConnectorDemo\RequestBuilder; +use PrestaShop\PrestaShop\Core\ConfigurationInterface; +use PrestaShop\PrestaShop\Core\Security\OAuth2\AuthorisationServerInterface; +use PrestaShop\PrestaShop\Core\Security\OAuth2\JwtTokenUser; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; + +class KeycloakAuthorizationServer implements AuthorisationServerInterface +{ + private ?Parser $jwtParser = null; + + private ?string $certsUrl = null; + + private ?Validator $validator = null; + + private array $parsedTokens = []; + + public function __construct( + private readonly ClientInterface $client, + private readonly ConfigurationInterface $configuration, + private readonly PhpEncryption $phpEncryption, + private readonly RequestBuilder $requestBuilder, + private readonly LoggerInterface $logger, + ) { + } + + public function isTokenValid(Request $request): bool + { + $token = $this->getTokenFromRequest($request); + if ($token === null) { + return false; + } + + $certsUrl = $this->getCertsUrl(); + if (empty($certsUrl)) { + return false; + } + + if (!$token->hasBeenIssuedBy($certsUrl)) { + $this->logger->info('KeycloakAuthorizationServer: invalid issuer got ' . $token->claims()->get('iss') . ' instead of ' . $certsUrl); + + return false; + } + + $certs = $this->getCerts($certsUrl); + if ($certs === null) { + return false; + } + + $certificate = $this->getRightCertificate($token, $certs['keys']); + if ($certificate === null) { + return false; + } + + return $this->getValidator()->validate($token, ...$this->getValidationConstraints($certificate)); + } + + public function getJwtTokenUser(Request $request): ?JwtTokenUser + { + /** @var UnencryptedToken|null $token */ + $token = $this->getTokenFromRequest($request); + if ($token === null) { + return null; + } + + $clientId = $token->claims()->get('clientId') ?? $token->claims()->get('client_id'); + if (!is_string($clientId)) { + return null; + } + + $scope = $token->claims()->get('scope'); + if (empty($scope)) { + return null; + } + $scopes = explode(' ', $scope); + + return new JwtTokenUser($clientId, $scopes, $token->claims()->get('iss')); + } + + /** + * @return array>>|null + */ + private function getCerts(string $certsUrl): ?array + { + try { + $request = $this->requestBuilder->getCertsRequest($certsUrl); + $response = $this->client->sendRequest($request); + } catch (ClientExceptionInterface $e) { + return null; + } + + if ($response->getStatusCode() !== 200) { + return null; + } + + $json = json_decode($response->getBody()->getContents(), true); + if (!is_array($json) || !isset($json['keys'])) { + return null; + } + + return $json; + } + + /** + * @param TokenInterface $token + * @param array> $certs + * + * @return Key|null + */ + private function getRightCertificate(TokenInterface $token, array $certs): ?Key + { + foreach ($certs as $key) { + if ($key['kid'] === $token->headers()->get('kid')) { + return InMemory::plainText( + "-----BEGIN CERTIFICATE-----\n" . $key['x5c'][0] . "\n-----END CERTIFICATE-----" + ); + } + } + $this->logger->error('KeycloakAuthorizationServer: could not find right certificate kid in: ' . var_export($certs, true)); + + return null; + } + + private function getCertsUrl(): ?string + { + if (!empty($this->certsUrl)) { + return $this->certsUrl; + } + + $encryptedEndpoint = $this->configuration->get(ConfigurationDataConfiguration::REALM_ENDPOINT); + if (empty($encryptedEndpoint)) { + return null; + } + + $endpoint = $this->phpEncryption->decrypt($encryptedEndpoint); + if (!is_string($endpoint)) { + $this->logger->error('KeycloakAuthorizationServer: could not decrypt endpoint ' . $encryptedEndpoint); + + return null; + } + $this->certsUrl = $endpoint; + + return $this->certsUrl; + } + + /** + * @param Key $key + * + * @return array{SignedWith, StrictValidAt} + */ + private function getValidationConstraints(Key $key): array + { + return [ + new SignedWith(new Sha256(), $key), + ]; + } + + private function getTokenFromRequest(Request $request): ?TokenInterface + { + $authorization = $request->headers->get('Authorization') ?? null; + if ($authorization === null || !str_starts_with($authorization, 'Bearer ')) { + return null; + } + + $explodedToken = explode(' ', $authorization); + if (count($explodedToken) < 2) { + return null; + } + + $bearerToken = $explodedToken[1]; + if (empty($bearerToken)) { + return null; + } + + if (empty($this->parsedTokens[$bearerToken])) { + try { + $token = $this->getJwtParser()->parse($bearerToken); + } catch (InvalidTokenStructure $e) { + $this->logger->error('KeycloakAuthorizationServer: invalid token structure: ' . $e->getMessage()); + + return null; + } + $this->parsedTokens[$bearerToken] = $token; + } + + return $this->parsedTokens[$bearerToken]; + } + + private function getJwtParser(): Parser + { + if (!$this->jwtParser) { + $this->jwtParser = new Parser(new JoseEncoder()); + } + + return $this->jwtParser; + } + + private function getValidator(): Validator + { + if (!$this->validator) { + $this->validator = new Validator(); + } + + return $this->validator; + } +}