From e32626500c2719e19b25400734fb2a656b2dba23 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann <8483328+buchmarv@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:50:58 +0200 Subject: [PATCH] [TASK] Symfony 6 Upgrade (#13) * [TASK] Symfony 6 Upgrade * Move to KNPU bundle * Update README.md * Update composer dependencies * Fix group evaluation * Drop Symfony 4 & 5 and PHP 7.4 support, fix cgl * Add Symfony 5 again, update CI * Add dependency * Provide configuration options for routes * Provide default login/logout actions and move logic into a service class * Update dependencies and fix CGL * Fix DI in Symfony 5 * Fix Symfony 5 issues * Fix CGL * Code cleanup and comparison fix --- .ddev/config.yaml | 8 +- .github/workflows/ci.yml | 11 +- .php-cs-fixer.dist.php | 4 +- README.md | 59 ++---- composer.json | 22 +-- src/Controller/LoginController.php | 44 +++++ src/DependencyInjection/Configuration.php | 18 +- .../T3GKeycloakExtension.php | 48 ++--- src/EventSubscriber/RequestSubscriber.php | 78 ++++++++ src/Resources/config/routes.xml | 9 + src/Resources/config/services.yaml | 22 ++- src/Security/KeyCloakAuthenticator.php | 170 ++++++------------ src/Security/KeyCloakUserProvider.php | 19 +- src/Service/JWTService.php | 75 -------- src/Service/RedirectService.php | 60 +++++++ src/Service/TokenService.php | 65 +++++++ src/T3GKeycloakBundle.php | 3 +- 17 files changed, 415 insertions(+), 300 deletions(-) create mode 100644 src/Controller/LoginController.php create mode 100644 src/EventSubscriber/RequestSubscriber.php create mode 100644 src/Resources/config/routes.xml delete mode 100644 src/Service/JWTService.php create mode 100644 src/Service/RedirectService.php create mode 100644 src/Service/TokenService.php diff --git a/.ddev/config.yaml b/.ddev/config.yaml index db19b8f..90b3f97 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -1,7 +1,7 @@ name: symfony-keycloak-bundle type: php docroot: "" -php_version: "7.4" +php_version: "8.1" webserver_type: nginx-fpm router_http_port: "80" router_https_port: "443" @@ -12,7 +12,7 @@ database: type: mariadb version: "10.3" omit_containers: [db] -webimage_extra_packages: [php7.4-gmp, ssh] +webimage_extra_packages: [php8.1-gmp, ssh] use_dns_when_possible: true composer_version: "2" web_environment: [] @@ -54,7 +54,7 @@ nodejs_version: "18" # "ddev xhprof" to enable xhprof and "ddev xhprof off" to disable it work better, # as leaving xhprof enabled all the time is a big performance hit. -# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn +# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn # timezone: Europe/Berlin # This is the timezone used in the containers and by PHP; @@ -96,7 +96,7 @@ nodejs_version: "18" # Please take care with this because it can cause great confusion. # upload_dirs: "custom/upload/dir" -# +# # upload_dirs: # - custom/upload/dir # - ../private diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8df6d9..33d2a86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,15 +15,12 @@ jobs: strategy: fail-fast: false matrix: - symfony: ['^4.4', '^5.4'] - php: ['7.4', '8.0', '8.1'] + symfony: ['^5.4', '^6.4'] + php: ['8.1', '8.2', '8.3'] experimental: [false] include: - - symfony: '^6.0' - php: '8.0' - experimental: true - - symfony: '^6.0' - php: '8.1' + - symfony: '^7.0' + php: '8.2' experimental: true steps: diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 0fa18c0..1a6d00d 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -41,7 +41,7 @@ ], 'declare_strict_types' => true, 'no_leading_import_slash' => true, - 'no_trailing_comma_in_singleline_array' => true, + 'no_trailing_comma_in_singleline' => true, 'no_singleline_whitespace_before_semicolons' => true, 'no_unused_imports' => true, 'concat_space' => ['spacing' => 'one'], @@ -55,7 +55,7 @@ 'no_blank_lines_after_phpdoc' => true, 'array_syntax' => ['syntax' => 'short'], 'whitespace_after_comma_in_array' => true, - 'function_typehint_space' => true, + 'type_declaration_spaces' => true, 'single_line_comment_style' => true, 'no_alias_functions' => true, 'lowercase_cast' => true, diff --git a/README.md b/README.md index 93c0cb8..d75b690 100644 --- a/README.md +++ b/README.md @@ -16,24 +16,27 @@ Update your security.yaml like this ```yaml # config/packages/security.yaml security: + enable_authenticator_manager: true providers: keycloak: id: keycloak.typo3.com.user.provider firewalls: main: - anonymous: true + provider: keycloak logout: path: /logout target: home - guard: - authenticators: - - T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator + custom_authenticators: + - T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator ``` ```yaml # config/routes.yaml logout: path: /logout + +login: + alias: t3g_keycloak_login ``` ## Step 3: Enable the Bundle @@ -48,47 +51,13 @@ in the `config/bundles.php` file of your project: return [ // ... - Jose\Bundle\JoseFramework\JoseFrameworkBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], Http\HttplugBundle\HttplugBundle::class => ['all' => true], T3G\Bundle\Keycloak\T3GKeycloakBundle::class => ['all' => true], ]; ``` -## Step 5: Create a login controller - -In order to log in, a simple login controller will suffice: - -```php -getClient('keycloak') - ->redirect([ - 'profile roles email', // the scopes you want to access - ], []); - } -} -``` - # Configuration ```bash @@ -105,12 +74,12 @@ php bin/console debug:config t3g_keycloak # Default configuration for extension with alias: "t3g_keycloak" t3g_keycloak: keycloak: - jku_url: 'https://login.typo3.com/realms/TYPO3/protocol/openid-connect/certs' user_provider_class: T3G\Bundle\Keycloak\Security\KeyCloakUserProvider default_roles: # Defaults: - ROLE_USER - ROLE_OAUTH_USER + clientId: '%env(KEYCLOAK_CLIENT_ID)%' ``` ### Role Mapping @@ -121,3 +90,13 @@ t3g_keycloak: role_mapping: my-role: ROLE_ADMIN ``` + +### Routes +```yaml +t3g_keycloak: + routes: + # route to redirect to after successful authentication + success: home + # redirect_route passed to keycloak + authentication: t3g_keycloak_oauthCallback +``` diff --git a/composer.json b/composer.json index 26146bf..6188393 100644 --- a/composer.json +++ b/composer.json @@ -16,26 +16,20 @@ ], "minimum-stability": "stable", "require": { - "php": "^7.4 || ^8.0", + "php": "^8.1", "ext-json": "*", "ext-gmp": "*", + "knpuniversity/oauth2-client-bundle": "^2.18", "nyholm/psr7": "^1.2", "php-http/cache-plugin": "^1.7", "php-http/curl-client": "^2.1", "php-http/httplug-bundle": "^1.17", - "rbdwllr/reallysimplejwt": "^2.1", - "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.0", - "symfony/framework-bundle": "^4.4 || ^5.4 || ^6.0", - "symfony/security-bundle": "^4.4 || ^5.4 || ^6.0", - "symfony/security-core": "^4.4 || ^5.4 || ^6.0", - "symfony/security-http": "^4.4 || ^5.4 || ^6.0", - "web-token/jwt-bundle": "^2.1", - "web-token/jwt-checker": "^2.1", - "web-token/jwt-core": "^2.1", - "web-token/jwt-key-mgmt": "^2.1", - "web-token/jwt-signature": "^2.1", - "web-token/jwt-signature-algorithm-hmac": "^2.1", - "web-token/jwt-signature-algorithm-rsa": "^2.1" + "stevenmaguire/oauth2-keycloak": "^5.1", + "symfony/dependency-injection": "^5.4 || ^6.4", + "symfony/framework-bundle": "^5.4 || ^6.4", + "symfony/security-bundle": "^5.4 || ^6.4", + "symfony/security-core": "^5.4 || ^6.4", + "symfony/security-http": "^5.4 || ^6.4" }, "autoload": { "psr-4": { diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php new file mode 100644 index 0000000..1f25a8c --- /dev/null +++ b/src/Controller/LoginController.php @@ -0,0 +1,44 @@ +redirectService = $redirectService; + } + + public function login(): RedirectResponse + { + if (null !== $this->getUser()) { + return $this->redirectToRoute($this->getParameter('t3g_keycloak.routes.success')); + } + + return $this->redirectService->generateLoginRedirectResponse(['openid', 'profile', 'roles', 'email']); + } + + public function oauthCallback(): RedirectResponse + { + // fallback in case the authenticator does not redirect + return $this->redirectToRoute($this->getParameter('t3g_keycloak.routes.success')); + } + + public function oauthLogout(): RedirectResponse + { + return $this->redirectService->generateLogoutRedirectResponse(); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e6282c3..d0bd7a9 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -23,10 +23,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->children() ->arrayNode('keycloak')->addDefaultsIfNotSet() ->children() - ->scalarNode('jku_url') - ->defaultValue('https://login.typo3.com/realms/TYPO3/protocol/openid-connect/certs') - ->cannotBeEmpty() - ->end() ->scalarNode('user_provider_class') ->defaultValue(KeyCloakUserProvider::class) ->cannotBeEmpty() @@ -40,8 +36,22 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue([]) ->scalarPrototype()->end() ->end() + ->scalarNode('clientId') + ->defaultValue('%env(KEYCLOAK_CLIENT_ID)%') + ->cannotBeEmpty() + ->end() ->end() ->end() + ->arrayNode('routes')->addDefaultsIfNotSet() + ->children() + ->scalarNode('authentication') + ->defaultValue('t3g_keycloak_oauthCallback') + ->end() + ->scalarNode('success') + ->defaultValue('home') + ->end() + ->end() + ->end() ->end() ; diff --git a/src/DependencyInjection/T3GKeycloakExtension.php b/src/DependencyInjection/T3GKeycloakExtension.php index c9ff9d1..fdb79f4 100644 --- a/src/DependencyInjection/T3GKeycloakExtension.php +++ b/src/DependencyInjection/T3GKeycloakExtension.php @@ -34,13 +34,28 @@ public function prepend(ContainerBuilder $container): void $configs = $container->getExtensionConfig($this->getAlias()); $config = $this->processConfiguration(new Configuration(), $configs); - $container->setParameter('t3g_keycloak.keycloak.jku_url', $config['keycloak']['jku_url']); $container->setParameter('t3g_keycloak.keycloak.user_provider_class', $config['keycloak']['user_provider_class']); $container->setParameter('t3g_keycloak.keycloak.default_roles', $config['keycloak']['default_roles']); $container->setParameter('t3g_keycloak.keycloak.role_mapping', $config['keycloak']['role_mapping']); + $container->setParameter('t3g_keycloak.keycloak.clientId', $config['keycloak']['clientId']); + $container->setParameter('t3g_keycloak.routes.authentication', $config['routes']['authentication']); + $container->setParameter('t3g_keycloak.routes.success', $config['routes']['success']); if ($container->hasExtension($this->getAlias())) { - $container->prependExtensionConfig($this->getAlias(), ['keycloak' => []]); + $container->prependExtensionConfig($this->getAlias(), ['keycloak' => [], 'routes' => []]); + } + + if ($container->hasExtension('knpu_oauth2_client')) { + $container->prependExtensionConfig( + 'knpu_oauth2_client', + [ + 'clients' => [ + 'keycloak' => [ + 'redirect_route' => '%t3g_keycloak.routes.authentication%', + ], + ], + ] + ); } if ($container->hasExtension('httplug')) { @@ -78,34 +93,5 @@ public function prepend(ContainerBuilder $container): void ] ); } - - if ($container->hasExtension('jose')) { - $container->prependExtensionConfig( - 'jose', - [ - 'key_sets' => [ - 'login_typo3_com' => [ - 'jku' => [ - 'url' => '%t3g_keycloak.keycloak.jku_url%', - 'is_public' => true - ] - ] - ], - 'jws' => [ - 'verifiers' => [ - 'login_typo3_com' => [ - 'signature_algorithms' => ['HS256', 'RS256'], - 'is_public' => true - ] - ] - ], - 'jku_factory' => [ - 'enabled' => 'test' !== $_SERVER['APP_ENV'], // we don't want to have requests to the login server in test context - 'client' => 'httplug.client.login_typo3_com', - 'request_factory' => 'httplug.message_factory' - ] - ] - ); - } } } diff --git a/src/EventSubscriber/RequestSubscriber.php b/src/EventSubscriber/RequestSubscriber.php new file mode 100644 index 0000000..4f1b6d6 --- /dev/null +++ b/src/EventSubscriber/RequestSubscriber.php @@ -0,0 +1,78 @@ +client = $clientRegistry->getClient('keycloak'); + $this->router = $router; + } + + public static function getSubscribedEvents(): array + { + return [ + RequestEvent::class => ['refreshAccessToken', 10], + ]; + } + + public function refreshAccessToken(RequestEvent $event): void + { + $request = $event->getRequest(); + if ('logout' === $request->attributes->get('_route')) { + // Don't try to refresh access token on logout page + return; + } + + $session = $request->getSession(); + /** @var ?AccessToken $accessToken */ + $accessToken = $session->get(KeyCloakAuthenticator::SESSION_KEYCLOAK_ACCESS_TOKEN); + if ($accessToken?->hasExpired()) { + try { + $accessToken = $this->client->refreshAccessToken((string)$accessToken->getRefreshToken()); + $session->set(KeyCloakAuthenticator::SESSION_KEYCLOAK_ACCESS_TOKEN, $accessToken); + } catch (IdentityProviderException $e) { + if (is_string($e->getResponseBody())) { + /** @var array $body */ + $body = json_decode($e->getResponseBody(), true, 512, JSON_THROW_ON_ERROR); + } else { + $body = $e->getResponseBody(); + } + + if ('invalid_grant' === $body['error']) { + // User had a keycloak session, but refreshing the access token failed. Enforce logout. + $response = new RedirectResponse( + $this->router->generate('logout'), + Response::HTTP_TEMPORARY_REDIRECT + ); + $event->setResponse($response); + return; + } + + throw $e; + } + } + } +} diff --git a/src/Resources/config/routes.xml b/src/Resources/config/routes.xml new file mode 100644 index 0000000..468a3ee --- /dev/null +++ b/src/Resources/config/routes.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 76bd3f9..87d9f14 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -9,18 +9,30 @@ services: keycloak.typo3.com.user.provider: class: '%t3g_keycloak.keycloak.user_provider_class%' arguments: + $tokenService: '@keycloak.typo3.com.token_service' $roleMapping: '%t3g_keycloak.keycloak.role_mapping%' $defaultRoles: '%t3g_keycloak.keycloak.default_roles%' - keycloak.typo3.com.jwt.service: - class: T3G\Bundle\Keycloak\Service\JWTService + keycloak.typo3.com.login_controller: + class: T3G\Bundle\Keycloak\Controller\LoginController + + keycloak.typo3.com.token_service: + class: T3G\Bundle\Keycloak\Service\TokenService + + T3G\Bundle\Keycloak\Service\RedirectService: + class: T3G\Bundle\Keycloak\Service\RedirectService public: true arguments: - $JWSVerifier: '@jose.jws_verifier.login_typo3_com' - $JWKSet: '@jose.key_set.login_typo3_com' + $clientId: '%t3g_keycloak.keycloak.clientId%' T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator: class: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator public: true arguments: - $JWTService: '@keycloak.typo3.com.jwt.service' + $tokenService: '@keycloak.typo3.com.token_service' + $userProvider: '@keycloak.typo3.com.user.provider' + $routeAuthentication: '%t3g_keycloak.routes.authentication%' + $routeSuccess: '%t3g_keycloak.routes.success%' + + T3G\Bundle\Keycloak\EventSubscriber\RequestSubscriber: + tags: [ kernel.event_subscriber ] diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index 3d0aee9..4fac813 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -10,82 +10,85 @@ namespace T3G\Bundle\Keycloak\Security; +use KnpU\OAuth2ClientBundle\Client\ClientRegistry; +use KnpU\OAuth2ClientBundle\Client\OAuth2ClientInterface; +use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; -use Symfony\Component\Security\Guard\AbstractGuardAuthenticator; -use T3G\Bundle\Keycloak\Service\JWTService; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; +use T3G\Bundle\Keycloak\Service\TokenService; -class KeyCloakAuthenticator extends AbstractGuardAuthenticator +class KeyCloakAuthenticator extends OAuth2Authenticator implements AuthenticationEntrypointInterface { - protected SessionInterface $session; - - protected JWTService $JWTService; - - public function __construct(SessionInterface $session, JWTService $JWTService) - { - $this->session = $session; - $this->JWTService = $JWTService; - } + public const SESSION_KEYCLOAK_ACCESS_TOKEN = 'keycloak_access_token'; + private OAuth2ClientInterface $client; + private SessionInterface $session; + private RouterInterface $router; + private UserProviderInterface $userProvider; + private TokenService $tokenService; + private ?string $routeAuthentication; + private ?string $routeSuccess; /** - * @param Request $request The request that resulted in an AuthenticationException - * @param AuthenticationException $authException The exception that started the authentication process - * @return RedirectResponse + * @param KeyCloakUserProvider $userProvider */ - public function start(Request $request, AuthenticationException $authException = null): RedirectResponse + public function __construct(ClientRegistry $clientRegistry, RequestStack $requestStack, RouterInterface $router, UserProviderInterface $userProvider, TokenService $tokenService, ?string $routeAuthentication = null, ?string $routeSuccess = null) { - return new RedirectResponse('/', Response::HTTP_TEMPORARY_REDIRECT); + $this->client = $clientRegistry->getClient('keycloak'); + $this->session = $requestStack->getSession(); + $this->router = $router; + $this->userProvider = $userProvider; + $this->tokenService = $tokenService; + $this->routeAuthentication = $routeAuthentication; + $this->routeSuccess = $routeSuccess; } - public function supports(Request $request): bool + public function supports(Request $request): ?bool { - return $request->headers->has('X-Auth-Token') - && $request->headers->has('X-Auth-Username') - && $request->headers->has('X-Auth-Userid'); + return $this->routeAuthentication === $request->attributes->get('_route'); } - /** - * @param Request $request - * @return Request - */ - public function getCredentials(Request $request): Request + public function authenticate(Request $request): Passport { - return $request; + $accessToken = $this->fetchAccessToken($this->client); + $this->session->set(self::SESSION_KEYCLOAK_ACCESS_TOKEN, $accessToken); + $userData = $this->tokenService->fetchUserData(); + + return new SelfValidatingPassport( + new UserBadge($userData['preferred_username'], fn () => $this->userProvider->loadUserByIdentifier( + $userData['preferred_username'], + $userData['realm_access']['roles'] ?? [], + $this->tokenService->getScopes(), + $userData['email'] ?? null, + $userData['name'] ?? null, + true + )) + ); } - /** - * @param Request $credentials - * @param UserProviderInterface|KeyCloakUserProvider $userProvider - * @return KeyCloakUser|null - */ - public function getUser($credentials, UserProviderInterface $userProvider): ?KeyCloakUser + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { - $this->session->set('JWT_TOKEN', $credentials->headers->get('X-Auth-Token')); - $roles = $this->getRolesFromToken($credentials->headers->get('X-Auth-Token')); - $scopes = $this->getScopesFromToken($credentials->headers->get('X-Auth-Token')); + if (null === $this->routeSuccess) { + return null; + } - return $userProvider->loadUserByIdentifier( - $credentials->headers->get('X-Auth-Username'), - $roles, - $scopes, - $this->getEmailFromToken($credentials->headers->get('X-Auth-Token')), - $this->getFullNameFromToken($credentials->headers->get('X-Auth-Token')), - true + return new RedirectResponse( + $this->router->generate($this->routeSuccess), + Response::HTTP_TEMPORARY_REDIRECT ); } - /** - * @param Request $request - * @param AuthenticationException $exception - * @return Response|null - */ - public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { $message = strtr($exception->getMessageKey(), $exception->getMessageData()); @@ -93,70 +96,11 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio } /** - * @param Request $request - * @param TokenInterface $token - * @param string $providerKey The provider (i.e. firewall) key - * @return Response|null - */ - public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response - { - return null; - } - - /** - * @param Request $credentials - * @param UserInterface $user - * @return bool - */ - public function checkCredentials($credentials, UserInterface $user): bool - { - // Gatekeeper takes care of credential validation - return true; - } - - /** - * @return bool + * Called when authentication is needed, but it's not sent. + * This redirects to the 'login'. */ - public function supportsRememberMe(): bool + public function start(Request $request, AuthenticationException $authException = null): Response { - return false; - } - - protected function decodeJwtToken(string $token): array - { - $this->JWTService->verify($token); - - return json_decode($this->JWTService->getPayload(), true, 512, JSON_THROW_ON_ERROR); - } - - protected function getScopesFromToken(string $token): array - { - $roles= []; - $scopes = explode(' ', $this->decodeJwtToken($token)['scope']); - - foreach ($scopes as $scope) { - $roles[] = 'ROLE_SCOPE_' . strtoupper(str_replace('.', '_', $scope)); - } - - return $roles; - } - - protected function getRolesFromToken(string $token): array - { - return $this->decodeJwtToken($token)['realm_access']['roles'] ?? []; - } - - public function getFullNameFromToken(string $token): ?string - { - $data = $this->decodeJwtToken($token); - - return $data['name'] ?? null; - } - - public function getEmailFromToken(string $token): ?string - { - $data = $this->decodeJwtToken($token); - - return $data['email'] ?? null; + return new RedirectResponse('/', Response::HTTP_TEMPORARY_REDIRECT); } } diff --git a/src/Security/KeyCloakUserProvider.php b/src/Security/KeyCloakUserProvider.php index a523ec5..ed1533f 100644 --- a/src/Security/KeyCloakUserProvider.php +++ b/src/Security/KeyCloakUserProvider.php @@ -1,5 +1,5 @@ tokenService = $tokenService; $this->roleMapping = $roleMapping; $this->defaultRoles = $defaultRoles; } @@ -81,7 +83,16 @@ public function refreshUser(UserInterface $user): KeyCloakUser throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user))); } - return new KeyCloakUser($user->getUsername(), $user->getRoles(), $user->getEmail(), $user->getFullName(), false); + $userData = $this->tokenService->fetchUserData(); + + return $this->loadUserByIdentifier( + $userData['preferred_username'], + $userData['realm_access']['roles'] ?? [], + $this->tokenService->getScopes(), + $userData['email'] ?? null, + $userData['name'] ?? null, + true + ); } /** diff --git a/src/Service/JWTService.php b/src/Service/JWTService.php deleted file mode 100644 index d7eed8f..0000000 --- a/src/Service/JWTService.php +++ /dev/null @@ -1,75 +0,0 @@ -verifier = $JWSVerifier; - $this->set = $JWKSet; - $this->serializerManager = $JWSSerializerManagerFactory->create(['jws_compact']); - } - - public function verify(string $token): bool - { - $this->token = $token; - $jws = $this->serializerManager->unserialize($this->token); - $result = $this->verifier->verifyWithKeySet($jws, $this->set, 0); - if (!$result) { - $this->token = null; - } - return $result; - } - - public function getPayload(): string - { - $this->checkToken(); - return $this->serializerManager->unserialize($this->token)->getPayload(); - } - - public function getSignature(int $index = 0): Signature - { - $this->checkToken(); - return $this->serializerManager->unserialize($this->token)->getSignature($index); - } - - /** - * @return Signature[] - */ - public function getSignatures(): array - { - $this->checkToken(); - return $this->serializerManager->unserialize($this->token)->getSignatures(); - } - - protected function checkToken(): void - { - if (null === $this->token) { - throw new NoTokenException('no token set, please run JSTService->verify() first'); - } - } -} diff --git a/src/Service/RedirectService.php b/src/Service/RedirectService.php new file mode 100644 index 0000000..d7476e9 --- /dev/null +++ b/src/Service/RedirectService.php @@ -0,0 +1,60 @@ +clientRegistry = $clientRegistry; + $this->router = $router; + $this->clientId = $clientId; + } + + /** + * @param string[] $scopes + */ + public function generateLoginRedirectResponse(array $scopes): RedirectResponse + { + /** @var OAuth2Client $client */ + $client = $this->clientRegistry->getClient('keycloak'); + + return $client->redirect($scopes); + } + + public function generateLogoutRedirectResponse(): RedirectResponse + { + $redirectAfterOAuthLogout = rtrim($this->router->generate('home', [], UrlGeneratorInterface::ABSOLUTE_URL), '/'); + /** @var Keycloak $provider */ + $provider = $this->clientRegistry->getClient('keycloak')->getOAuth2Provider(); + $redirectTarget = sprintf( + '%s/realms/%s/protocol/openid-connect/logout?client_id=%s&post_logout_redirect_uri=%s', + $provider->authServerUrl, + $provider->realm, + $this->clientId, + urlencode($redirectAfterOAuthLogout) + ); + + return new RedirectResponse($redirectTarget, Response::HTTP_TEMPORARY_REDIRECT); + } +} diff --git a/src/Service/TokenService.php b/src/Service/TokenService.php new file mode 100644 index 0000000..cc1a829 --- /dev/null +++ b/src/Service/TokenService.php @@ -0,0 +1,65 @@ +client = $clientRegistry->getClient('keycloak'); + $this->session = $requestStack->getSession(); + } + + /** + * @return array{realm_access: ?array{roles: ?string[]}, name?: ?string, preferred_username: string, email?: ?string} + */ + public function fetchUserData(): array + { + $accessToken = $this->getAccessTokenFromSession(); + + if (null !== $accessToken) { + return $this->client->fetchUserFromToken($accessToken)?->toArray(); + } + + return []; + } + + public function getScopes(): array + { + $accessToken = $this->getAccessTokenFromSession(); + if (null === $accessToken) { + return []; + } + + $scopes = explode(' ', $accessToken->getValues()['scope'] ?? ''); + + return array_map(static fn (string $scope) => 'ROLE_SCOPE_' . strtoupper(str_replace('.', '_', $scope)), $scopes); + } + + public function getAccessTokenFromSession(): ?AccessToken + { + if ($this->session->has(KeyCloakAuthenticator::SESSION_KEYCLOAK_ACCESS_TOKEN)) { + return $this->session->get(KeyCloakAuthenticator::SESSION_KEYCLOAK_ACCESS_TOKEN); + } + + return null; + } +} diff --git a/src/T3GKeycloakBundle.php b/src/T3GKeycloakBundle.php index 8b2dd0b..dbeaa56 100644 --- a/src/T3GKeycloakBundle.php +++ b/src/T3GKeycloakBundle.php @@ -10,6 +10,7 @@ namespace T3G\Bundle\Keycloak; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; use T3G\Bundle\Keycloak\DependencyInjection\T3GKeycloakExtension; @@ -18,7 +19,7 @@ final class T3GKeycloakBundle extends Bundle /** * Overridden to allow for the custom extension alias. */ - public function getContainerExtension() + public function getContainerExtension(): ?ExtensionInterface { if (null === $this->extension) { $this->extension = new T3GKeycloakExtension();