From 222f0043b3ec5e6ced6eb6df55b2a176f78eb53f Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Thu, 30 May 2024 12:20:05 +0200 Subject: [PATCH 01/15] [TASK] Symfony 6 Upgrade --- README.md | 1 + composer.json | 10 +- src/DependencyInjection/Configuration.php | 4 - .../T3GKeycloakExtension.php | 30 ---- src/Resources/config/services.yaml | 9 - src/Security/KeyCloakAuthenticator.php | 161 +++++------------- src/Security/KeyCloakUserProvider.php | 20 ++- src/Service/JWTService.php | 75 -------- src/T3GKeycloakBundle.php | 3 +- 9 files changed, 63 insertions(+), 250 deletions(-) delete mode 100644 src/Service/JWTService.php diff --git a/README.md b/README.md index 93c0cb8..0fb7cd1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ $ composer require t3g/symfony-keycloak-bundle Update your security.yaml like this +# @TODO: UPDATE ```yaml # config/packages/security.yaml security: diff --git a/composer.json b/composer.json index 26146bf..8b35628 100644 --- a/composer.json +++ b/composer.json @@ -23,19 +23,11 @@ "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" + "symfony/security-http": "^4.4 || ^5.4 || ^6.0" }, "autoload": { "psr-4": { diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e6282c3..ca8870f 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() diff --git a/src/DependencyInjection/T3GKeycloakExtension.php b/src/DependencyInjection/T3GKeycloakExtension.php index c9ff9d1..838b0d1 100644 --- a/src/DependencyInjection/T3GKeycloakExtension.php +++ b/src/DependencyInjection/T3GKeycloakExtension.php @@ -34,7 +34,6 @@ 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']); @@ -78,34 +77,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/Resources/config/services.yaml b/src/Resources/config/services.yaml index 76bd3f9..bf577e3 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -12,15 +12,6 @@ services: $roleMapping: '%t3g_keycloak.keycloak.role_mapping%' $defaultRoles: '%t3g_keycloak.keycloak.default_roles%' - keycloak.typo3.com.jwt.service: - class: T3G\Bundle\Keycloak\Service\JWTService - public: true - arguments: - $JWSVerifier: '@jose.jws_verifier.login_typo3_com' - $JWKSet: '@jose.key_set.login_typo3_com' - T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator: class: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator public: true - arguments: - $JWTService: '@keycloak.typo3.com.jwt.service' diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index 3d0aee9..5167efc 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -1,5 +1,5 @@ session = $session; - $this->JWTService = $JWTService; + $this->clientRegistry = $clientRegistry; + $this->session = $requestStack->getSession(); + $this->router = $router; } - /** - * @param Request $request The request that resulted in an AuthenticationException - * @param AuthenticationException $authException The exception that started the authentication process - * @return RedirectResponse - */ - public function start(Request $request, AuthenticationException $authException = null): RedirectResponse + public function supports(Request $request): ?bool { - return new RedirectResponse('/', Response::HTTP_TEMPORARY_REDIRECT); + // continue ONLY if the current ROUTE matches the check ROUTE + return $request->attributes->get('_route') === 'oauth_callback'; } - public function supports(Request $request): bool + public function authenticate(Request $request): Passport { - return $request->headers->has('X-Auth-Token') - && $request->headers->has('X-Auth-Username') - && $request->headers->has('X-Auth-Userid'); - } + $client = $this->clientRegistry->getClient('keycloak'); + $accessToken = $this->fetchAccessToken($client); - /** - * @param Request $request - * @return Request - */ - public function getCredentials(Request $request): Request - { - return $request; + return new SelfValidatingPassport( + new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) { + return $client->fetchUserFromToken($accessToken); + }) + ); } - /** - * @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')); + // @TODO: make configurable + $targetUrl = $this->router->generate('dashboard'); - 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($targetUrl); } - /** - * @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 +73,15 @@ 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 - { - 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 + public function start(Request $request, AuthenticationException $authException = null): Response { - $data = $this->decodeJwtToken($token); - - return $data['name'] ?? null; - } - - public function getEmailFromToken(string $token): ?string - { - $data = $this->decodeJwtToken($token); - - return $data['email'] ?? null; + // @TODO: make configurable + return new RedirectResponse( + $this->router->generate('login'), + Response::HTTP_TEMPORARY_REDIRECT + ); } } diff --git a/src/Security/KeyCloakUserProvider.php b/src/Security/KeyCloakUserProvider.php index a523ec5..f050087 100644 --- a/src/Security/KeyCloakUserProvider.php +++ b/src/Security/KeyCloakUserProvider.php @@ -10,15 +10,16 @@ namespace T3G\Bundle\Keycloak\Security; +use Drenso\OidcBundle\Model\OidcUserData; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Security\Core\User\UserProviderInterface; +use Drenso\OidcBundle\Security\UserProvider\OidcUserProviderInterface; -class KeyCloakUserProvider implements UserProviderInterface +class KeyCloakUserProvider implements OidcUserProviderInterface { private array $roleMapping; - private array $defaultRoles; + private array $userRoles = []; public function __construct(array $roleMapping, array $defaultRoles = ['ROLE_USER', 'ROLE_OAUTH_USER']) { @@ -45,7 +46,7 @@ public function loadUserByIdentifier( ): KeyCloakUser { $roles = array_intersect_key($this->roleMapping, array_flip(array_map(static function ($v) { return str_replace('-', '_', $v); - }, $keycloakGroups))); + }, $this->userRoles))); $roles = array_merge($roles, $scopes, $this->defaultRoles); return new KeyCloakUser($identifier, array_values($roles), $email, $fullName, $fresh); @@ -92,4 +93,15 @@ public function supportsClass($class): bool { return KeyCloakUser::class === $class; } + + public function ensureUserExists(string $userIdentifier, OidcUserData $userData) + { + // @TODO: store it in the session? + $this->userRoles = $userData->getUserDataArray('realm_access')['roles']; + } + + public function loadOidcUser(string $userIdentifier): UserInterface + { + return $this->loadUserByIdentifier($userIdentifier); + } } 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/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(); From 0436365600ee57bc7a1f66863c491ced97a7c228 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Thu, 22 Aug 2024 15:00:32 +0200 Subject: [PATCH 02/15] Move to KNPU bundle --- src/EventSubscriber/RequestSubscriber.php | 77 +++++++++++++++++++++++ src/Resources/config/services.yaml | 3 + src/Security/KeyCloakAuthenticator.php | 70 +++++++++++++++------ src/Security/KeyCloakUserProvider.php | 16 +---- 4 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 src/EventSubscriber/RequestSubscriber.php diff --git a/src/EventSubscriber/RequestSubscriber.php b/src/EventSubscriber/RequestSubscriber.php new file mode 100644 index 0000000..fb97edd --- /dev/null +++ b/src/EventSubscriber/RequestSubscriber.php @@ -0,0 +1,77 @@ +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/services.yaml b/src/Resources/config/services.yaml index bf577e3..ab2e3c0 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -15,3 +15,6 @@ services: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator: class: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator public: true + + T3G\Bundle\Keycloak\EventSubscriber\RequestSubscriber: + tags: [ kernel.event_subscriber ] diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index 5167efc..8794d8b 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -10,17 +10,19 @@ namespace T3G\Bundle\Keycloak\Security; -use Doctrine\ORM\EntityManagerInterface; use KnpU\OAuth2ClientBundle\Client\ClientRegistry; +use KnpU\OAuth2ClientBundle\Client\OAuth2ClientInterface; use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator; -use Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner; +use League\OAuth2\Client\Token\AccessToken; 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\UserProviderInterface; 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; @@ -28,31 +30,46 @@ class KeyCloakAuthenticator extends OAuth2Authenticator implements AuthenticationEntrypointInterface { - private $clientRegistry; - private $session; - private $router; + public const SESSION_KEYCLOAK_ACCESS_TOKEN = 'keycloak_access_token'; + private OAuth2ClientInterface $client; + private SessionInterface $session; + private RouterInterface $router; + private UserProviderInterface $userProvider; - public function __construct(ClientRegistry $clientRegistry, RequestStack $requestStack, RouterInterface $router) + /** + * @param KeyCloakUserProvider $userProvider + */ + public function __construct(ClientRegistry $clientRegistry, RequestStack $requestStack, RouterInterface $router, UserProviderInterface $userProvider) { - $this->clientRegistry = $clientRegistry; + $this->client = $clientRegistry->getClient('keycloak'); $this->session = $requestStack->getSession(); $this->router = $router; + $this->userProvider = $userProvider; } public function supports(Request $request): ?bool { - // continue ONLY if the current ROUTE matches the check ROUTE - return $request->attributes->get('_route') === 'oauth_callback'; + // @TODO: make configurable + return 'oauth_callback' === $request->attributes->get('_route'); } public function authenticate(Request $request): Passport { - $client = $this->clientRegistry->getClient('keycloak'); - $accessToken = $this->fetchAccessToken($client); + $accessToken = $this->fetchAccessToken($this->client); + /** @var array{realm_access: ?array{roles: ?string[]}, name?: ?string, preferred_username: string, email?: ?string} $userData */ + $userData = $this->client->fetchUserFromToken($accessToken)?->toArray(); + $this->session->set(self::SESSION_KEYCLOAK_ACCESS_TOKEN, $accessToken); return new SelfValidatingPassport( - new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) { - return $client->fetchUserFromToken($accessToken); + new UserBadge($userData['preferred_username'], function() use ($accessToken, $userData) { + return $this->userProvider->loadUserByIdentifier( + $userData['preferred_username'], + $userData['realm_access']['roles'] ?? [], + $this->getScopesFromToken($accessToken), + $userData['email'] ?? null, + $userData['name'] ?? null, + true + ); }) ); } @@ -60,16 +77,19 @@ public function authenticate(Request $request): Passport public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { // @TODO: make configurable - $targetUrl = $this->router->generate('dashboard'); - - return new RedirectResponse($targetUrl); + return new RedirectResponse( + $this->router->generate('dashboard'), + Response::HTTP_TEMPORARY_REDIRECT + ); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { - $message = strtr($exception->getMessageKey(), $exception->getMessageData()); - - return new Response($message, Response::HTTP_FORBIDDEN); + // @TODO: make configurable + return new RedirectResponse( + $this->router->generate('login'), + Response::HTTP_TEMPORARY_REDIRECT + ); } /** @@ -84,4 +104,16 @@ public function start(Request $request, AuthenticationException $authException = Response::HTTP_TEMPORARY_REDIRECT ); } + + private function getScopesFromToken(AccessToken $token): array + { + $roles = []; + $scopes = explode(' ', $token->getValues()['scope'] ?? ''); + + foreach ($scopes as $scope) { + $roles[] = 'ROLE_SCOPE_' . strtoupper(str_replace('.', '_', $scope)); + } + + return $roles; + } } diff --git a/src/Security/KeyCloakUserProvider.php b/src/Security/KeyCloakUserProvider.php index f050087..464eb45 100644 --- a/src/Security/KeyCloakUserProvider.php +++ b/src/Security/KeyCloakUserProvider.php @@ -10,12 +10,11 @@ namespace T3G\Bundle\Keycloak\Security; -use Drenso\OidcBundle\Model\OidcUserData; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\UserInterface; -use Drenso\OidcBundle\Security\UserProvider\OidcUserProviderInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; -class KeyCloakUserProvider implements OidcUserProviderInterface +class KeyCloakUserProvider implements UserProviderInterface { private array $roleMapping; private array $defaultRoles; @@ -93,15 +92,4 @@ public function supportsClass($class): bool { return KeyCloakUser::class === $class; } - - public function ensureUserExists(string $userIdentifier, OidcUserData $userData) - { - // @TODO: store it in the session? - $this->userRoles = $userData->getUserDataArray('realm_access')['roles']; - } - - public function loadOidcUser(string $userIdentifier): UserInterface - { - return $this->loadUserByIdentifier($userIdentifier); - } } From a407e358740254b1adb77b38c62e43d77403b264 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 26 Aug 2024 12:01:31 +0200 Subject: [PATCH 03/15] Update README.md --- README.md | 30 ++++++++++++++++---------- src/Security/KeyCloakAuthenticator.php | 2 +- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0fb7cd1..d2ae26f 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,21 @@ $ composer require t3g/symfony-keycloak-bundle Update your security.yaml like this -# @TODO: UPDATE ```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 @@ -69,23 +68,32 @@ use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; class HomeController extends AbstractController { /** * Link to this controller to start the "connect" process. - * - * @Route("/login", name="login") - * @param ClientRegistry $clientRegistry - * @return RedirectResponse */ + #[Route(path: '/login', name: 'login', methods: ['GET'])] public function login(ClientRegistry $clientRegistry): RedirectResponse { return $clientRegistry ->getClient('keycloak') ->redirect([ - 'profile roles email', // the scopes you want to access - ], []); + 'openid', 'profile', 'roles', 'email', // the scopes you want to access + ]); + } + + /** + * A callback route is required to authenticate the user. + */ + #[IsGranted('ROLE_USER')] + #[Route(path: '/oauth/callback', name: 'oauth_callback', methods: ['GET'])] + public function checkLogin(): RedirectResponse + { + // fallback in case the authenticator does not redirect + return $this->redirectToRoute('dashboard'); } } ``` diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index 8794d8b..630c5a9 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -50,7 +50,7 @@ public function __construct(ClientRegistry $clientRegistry, RequestStack $reques public function supports(Request $request): ?bool { // @TODO: make configurable - return 'oauth_callback' === $request->attributes->get('_route'); + return 'oauth_callback' === $request->attributes->get('_route'); } public function authenticate(Request $request): Passport From 6fef6059b0541e0d960449c556285a9f7539410e Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 26 Aug 2024 12:07:15 +0200 Subject: [PATCH 04/15] Update composer dependencies --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 8b35628..c75329d 100644 --- a/composer.json +++ b/composer.json @@ -23,11 +23,11 @@ "php-http/cache-plugin": "^1.7", "php-http/curl-client": "^2.1", "php-http/httplug-bundle": "^1.17", - "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" + "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.4", + "symfony/framework-bundle": "^4.4 || ^5.4 || ^6.4", + "symfony/security-bundle": "^4.4 || ^5.4 || ^6.4", + "symfony/security-core": "^4.4 || ^5.4 || ^6.4", + "symfony/security-http": "^4.4 || ^5.4 || ^6.4" }, "autoload": { "psr-4": { From 293e6db2d234bbf46620a7bb79264e3df15da4a2 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 26 Aug 2024 14:23:35 +0200 Subject: [PATCH 05/15] Fix group evaluation --- src/Security/KeyCloakAuthenticator.php | 2 +- src/Security/KeyCloakUserProvider.php | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index 630c5a9..d6d5228 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -1,5 +1,5 @@ roleMapping, array_flip(array_map(static function ($v) { return str_replace('-', '_', $v); - }, $this->userRoles))); + }, $keycloakGroups))); $roles = array_merge($roles, $scopes, $this->defaultRoles); return new KeyCloakUser($identifier, array_values($roles), $email, $fullName, $fresh); From ea1f5c14032f75b2c9feb090885910f3efa4cd2a Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 26 Aug 2024 14:41:56 +0200 Subject: [PATCH 06/15] Drop Symfony 4 & 5 and PHP 7.4 support, fix cgl --- .ddev/config.yaml | 8 ++++---- .php-cs-fixer.dist.php | 4 ++-- composer.json | 12 ++++++------ src/EventSubscriber/RequestSubscriber.php | 3 ++- src/Security/KeyCloakAuthenticator.php | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) 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/.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/composer.json b/composer.json index c75329d..987f1d5 100644 --- a/composer.json +++ b/composer.json @@ -16,18 +16,18 @@ ], "minimum-stability": "stable", "require": { - "php": "^7.4 || ^8.0", + "php": "^8.1", "ext-json": "*", "ext-gmp": "*", "nyholm/psr7": "^1.2", "php-http/cache-plugin": "^1.7", "php-http/curl-client": "^2.1", "php-http/httplug-bundle": "^1.17", - "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.4", - "symfony/framework-bundle": "^4.4 || ^5.4 || ^6.4", - "symfony/security-bundle": "^4.4 || ^5.4 || ^6.4", - "symfony/security-core": "^4.4 || ^5.4 || ^6.4", - "symfony/security-http": "^4.4 || ^5.4 || ^6.4" + "symfony/dependency-injection": "^6.4", + "symfony/framework-bundle": "^6.4", + "symfony/security-bundle": "^6.4", + "symfony/security-core": "^6.4", + "symfony/security-http": "^6.4" }, "autoload": { "psr-4": { diff --git a/src/EventSubscriber/RequestSubscriber.php b/src/EventSubscriber/RequestSubscriber.php index fb97edd..4f1b6d6 100644 --- a/src/EventSubscriber/RequestSubscriber.php +++ b/src/EventSubscriber/RequestSubscriber.php @@ -1,7 +1,8 @@ session->set(self::SESSION_KEYCLOAK_ACCESS_TOKEN, $accessToken); return new SelfValidatingPassport( - new UserBadge($userData['preferred_username'], function() use ($accessToken, $userData) { + new UserBadge($userData['preferred_username'], function () use ($accessToken, $userData) { return $this->userProvider->loadUserByIdentifier( $userData['preferred_username'], $userData['realm_access']['roles'] ?? [], From eb822580c559ed72eff910679286bd0ef6dbea7b Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 26 Aug 2024 14:58:01 +0200 Subject: [PATCH 07/15] Add Symfony 5 again, update CI --- .github/workflows/ci.yml | 11 ++++------- composer.json | 10 +++++----- 2 files changed, 9 insertions(+), 12 deletions(-) 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/composer.json b/composer.json index 987f1d5..e7ddf65 100644 --- a/composer.json +++ b/composer.json @@ -23,11 +23,11 @@ "php-http/cache-plugin": "^1.7", "php-http/curl-client": "^2.1", "php-http/httplug-bundle": "^1.17", - "symfony/dependency-injection": "^6.4", - "symfony/framework-bundle": "^6.4", - "symfony/security-bundle": "^6.4", - "symfony/security-core": "^6.4", - "symfony/security-http": "^6.4" + "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": { From b7e9003fffae7a2d7146cf28bfa0eb9deb39a4a9 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Tue, 27 Aug 2024 08:37:39 +0200 Subject: [PATCH 08/15] Add dependency --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index e7ddf65..4132054 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "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", From 1f6f6f3ffff5cc876e491bbeab6daafd5062527d Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Tue, 27 Aug 2024 10:56:04 +0200 Subject: [PATCH 09/15] Provide configuration options for routes --- README.md | 11 ++++++- src/DependencyInjection/Configuration.php | 10 +++++++ .../T3GKeycloakExtension.php | 4 ++- src/Resources/config/services.yaml | 3 ++ src/Security/KeyCloakAuthenticator.php | 30 +++++++++---------- 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d2ae26f..7bd6c6b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ class HomeController extends AbstractController #[Route(path: '/login', name: 'login', methods: ['GET'])] public function login(ClientRegistry $clientRegistry): RedirectResponse { + if (null !== $this->getUser()) { + return $this->redirectToRoute('dashboard'); + } + return $clientRegistry ->getClient('keycloak') ->redirect([ @@ -86,7 +90,7 @@ class HomeController extends AbstractController } /** - * A callback route is required to authenticate the user. + * This route must match the authentication route in your bundle configuration. */ #[IsGranted('ROLE_USER')] #[Route(path: '/oauth/callback', name: 'oauth_callback', methods: ['GET'])] @@ -120,6 +124,11 @@ t3g_keycloak: # Defaults: - ROLE_USER - ROLE_OAUTH_USER + routes: + # redirect_route passed to keycloak + authentication: oauth_callback + # route to redirect to after successful authentication + success: dashboard ``` ### Role Mapping diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index ca8870f..96c30c6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -38,6 +38,16 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('routes')->addDefaultsIfNotSet() + ->children() + ->scalarNode('authentication') + ->defaultValue(null) + ->end() + ->scalarNode('success') + ->defaultValue(null) + ->end() + ->end() + ->end() ->end() ; diff --git a/src/DependencyInjection/T3GKeycloakExtension.php b/src/DependencyInjection/T3GKeycloakExtension.php index 838b0d1..b18229b 100644 --- a/src/DependencyInjection/T3GKeycloakExtension.php +++ b/src/DependencyInjection/T3GKeycloakExtension.php @@ -37,9 +37,11 @@ public function prepend(ContainerBuilder $container): void $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.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('httplug')) { diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index ab2e3c0..aa433c6 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -15,6 +15,9 @@ services: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator: class: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator public: true + arguments: + $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 5911b21..74c48df 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -35,22 +35,25 @@ class KeyCloakAuthenticator extends OAuth2Authenticator implements Authenticatio private SessionInterface $session; private RouterInterface $router; private UserProviderInterface $userProvider; + private ?string $routeAuthentication; + private ?string $routeSuccess; /** * @param KeyCloakUserProvider $userProvider */ - public function __construct(ClientRegistry $clientRegistry, RequestStack $requestStack, RouterInterface $router, UserProviderInterface $userProvider) + public function __construct(ClientRegistry $clientRegistry, RequestStack $requestStack, RouterInterface $router, UserProviderInterface $userProvider, ?string $routeAuthentication = null, ?string $routeSuccess = null) { $this->client = $clientRegistry->getClient('keycloak'); $this->session = $requestStack->getSession(); $this->router = $router; $this->userProvider = $userProvider; + $this->routeAuthentication = $routeAuthentication; + $this->routeSuccess = $routeSuccess; } public function supports(Request $request): ?bool { - // @TODO: make configurable - return 'oauth_callback' === $request->attributes->get('_route'); + return $this->routeAuthentication === $request->attributes->get('_route'); } public function authenticate(Request $request): Passport @@ -76,20 +79,21 @@ public function authenticate(Request $request): Passport public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { - // @TODO: make configurable + if (null === $this->routeSuccess) { + return null; + } + return new RedirectResponse( - $this->router->generate('dashboard'), + $this->router->generate($this->routeSuccess), Response::HTTP_TEMPORARY_REDIRECT ); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { - // @TODO: make configurable - return new RedirectResponse( - $this->router->generate('login'), - Response::HTTP_TEMPORARY_REDIRECT - ); + $message = strtr($exception->getMessageKey(), $exception->getMessageData()); + + return new Response($message, Response::HTTP_FORBIDDEN); } /** @@ -98,11 +102,7 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio */ public function start(Request $request, AuthenticationException $authException = null): Response { - // @TODO: make configurable - return new RedirectResponse( - $this->router->generate('login'), - Response::HTTP_TEMPORARY_REDIRECT - ); + return new RedirectResponse('/', Response::HTTP_TEMPORARY_REDIRECT); } private function getScopesFromToken(AccessToken $token): array From b24c5e26a52ddd2a8a13fb325c5eecd709f9fc86 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 2 Sep 2024 11:07:02 +0200 Subject: [PATCH 10/15] Provide default login/logout actions and move logic into a service class --- README.md | 69 ++++--------------- src/Controller/LoginController.php | 44 ++++++++++++ src/DependencyInjection/Configuration.php | 8 ++- .../T3GKeycloakExtension.php | 14 ++++ src/Resources/config/routes.xml | 9 +++ src/Resources/config/services.yaml | 9 +++ src/Service/RedirectService.php | 60 ++++++++++++++++ 7 files changed, 157 insertions(+), 56 deletions(-) create mode 100644 src/Controller/LoginController.php create mode 100644 src/Resources/config/routes.xml create mode 100644 src/Service/RedirectService.php diff --git a/README.md b/README.md index 7bd6c6b..d75b690 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ security: # config/routes.yaml logout: path: /logout + +login: + alias: t3g_keycloak_login ``` ## Step 3: Enable the Bundle @@ -48,60 +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 -getUser()) { - return $this->redirectToRoute('dashboard'); - } - - return $clientRegistry - ->getClient('keycloak') - ->redirect([ - 'openid', 'profile', 'roles', 'email', // the scopes you want to access - ]); - } - - /** - * This route must match the authentication route in your bundle configuration. - */ - #[IsGranted('ROLE_USER')] - #[Route(path: '/oauth/callback', name: 'oauth_callback', methods: ['GET'])] - public function checkLogin(): RedirectResponse - { - // fallback in case the authenticator does not redirect - return $this->redirectToRoute('dashboard'); - } -} -``` - # Configuration ```bash @@ -118,17 +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 - routes: - # redirect_route passed to keycloak - authentication: oauth_callback - # route to redirect to after successful authentication - success: dashboard + clientId: '%env(KEYCLOAK_CLIENT_ID)%' ``` ### Role Mapping @@ -139,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/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 96c30c6..d0bd7a9 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -36,15 +36,19 @@ 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(null) + ->defaultValue('t3g_keycloak_oauthCallback') ->end() ->scalarNode('success') - ->defaultValue(null) + ->defaultValue('home') ->end() ->end() ->end() diff --git a/src/DependencyInjection/T3GKeycloakExtension.php b/src/DependencyInjection/T3GKeycloakExtension.php index b18229b..fdb79f4 100644 --- a/src/DependencyInjection/T3GKeycloakExtension.php +++ b/src/DependencyInjection/T3GKeycloakExtension.php @@ -37,6 +37,7 @@ public function prepend(ContainerBuilder $container): void $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']); @@ -44,6 +45,19 @@ public function prepend(ContainerBuilder $container): void $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')) { $container->prependExtensionConfig( 'httplug', 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 aa433c6..e5fd985 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -12,6 +12,15 @@ services: $roleMapping: '%t3g_keycloak.keycloak.role_mapping%' $defaultRoles: '%t3g_keycloak.keycloak.default_roles%' + keycloak.typo3.com.login_controller: + class: T3G\Bundle\Keycloak\Controller\LoginController + + T3G\Bundle\Keycloak\Service\RedirectService: + class: T3G\Bundle\Keycloak\Service\RedirectService + public: true + arguments: + $clientId: '%t3g_keycloak.keycloak.clientId%' + T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator: class: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator public: true diff --git a/src/Service/RedirectService.php b/src/Service/RedirectService.php new file mode 100644 index 0000000..7e533c8 --- /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); + } +} From df14d47831bc8dbb47795d845b45dd7332c8b515 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 2 Sep 2024 12:50:10 +0200 Subject: [PATCH 11/15] Update dependencies and fix CGL --- composer.json | 1 + src/Service/RedirectService.php | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4132054..6188393 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "php-http/cache-plugin": "^1.7", "php-http/curl-client": "^2.1", "php-http/httplug-bundle": "^1.17", + "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", diff --git a/src/Service/RedirectService.php b/src/Service/RedirectService.php index 7e533c8..95e574c 100644 --- a/src/Service/RedirectService.php +++ b/src/Service/RedirectService.php @@ -12,7 +12,6 @@ use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use KnpU\OAuth2ClientBundle\Client\OAuth2Client; use Stevenmaguire\OAuth2\Client\Provider\Keycloak; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; From aeab0e5ab45608388da2a0cb8fc5c39523017909 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 2 Sep 2024 13:38:09 +0200 Subject: [PATCH 12/15] Fix DI in Symfony 5 --- src/Resources/config/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index e5fd985..1dd422d 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -25,6 +25,7 @@ services: class: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator public: true arguments: + $userProvider: '@keycloak.typo3.com.user.provider' $routeAuthentication: '%t3g_keycloak.routes.authentication%' $routeSuccess: '%t3g_keycloak.routes.success%' From d16031e72eda0c5c599a50e27cda210867a90aa1 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Tue, 3 Sep 2024 14:17:00 +0200 Subject: [PATCH 13/15] Fix Symfony 5 issues --- src/Resources/config/services.yaml | 5 ++ src/Security/KeyCloakAuthenticator.php | 22 +++----- src/Security/KeyCloakUserProvider.php | 16 +++++- src/Service/TokenService.php | 70 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 src/Service/TokenService.php diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 1dd422d..87d9f14 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -9,12 +9,16 @@ 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.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 @@ -25,6 +29,7 @@ services: class: T3G\Bundle\Keycloak\Security\KeyCloakAuthenticator public: true arguments: + $tokenService: '@keycloak.typo3.com.token_service' $userProvider: '@keycloak.typo3.com.user.provider' $routeAuthentication: '%t3g_keycloak.routes.authentication%' $routeSuccess: '%t3g_keycloak.routes.success%' diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index 74c48df..96d89e7 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -27,6 +27,7 @@ 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 OAuth2Authenticator implements AuthenticationEntrypointInterface { @@ -35,18 +36,20 @@ class KeyCloakAuthenticator extends OAuth2Authenticator implements Authenticatio private SessionInterface $session; private RouterInterface $router; private UserProviderInterface $userProvider; + private TokenService $tokenService; private ?string $routeAuthentication; private ?string $routeSuccess; /** * @param KeyCloakUserProvider $userProvider */ - public function __construct(ClientRegistry $clientRegistry, RequestStack $requestStack, RouterInterface $router, UserProviderInterface $userProvider, ?string $routeAuthentication = null, ?string $routeSuccess = null) + public function __construct(ClientRegistry $clientRegistry, RequestStack $requestStack, RouterInterface $router, UserProviderInterface $userProvider, TokenService $tokenService, ?string $routeAuthentication = null, ?string $routeSuccess = null) { $this->client = $clientRegistry->getClient('keycloak'); $this->session = $requestStack->getSession(); $this->router = $router; $this->userProvider = $userProvider; + $this->tokenService = $tokenService; $this->routeAuthentication = $routeAuthentication; $this->routeSuccess = $routeSuccess; } @@ -59,16 +62,15 @@ public function supports(Request $request): ?bool public function authenticate(Request $request): Passport { $accessToken = $this->fetchAccessToken($this->client); - /** @var array{realm_access: ?array{roles: ?string[]}, name?: ?string, preferred_username: string, email?: ?string} $userData */ - $userData = $this->client->fetchUserFromToken($accessToken)?->toArray(); $this->session->set(self::SESSION_KEYCLOAK_ACCESS_TOKEN, $accessToken); + $userData = $this->tokenService->fetchUserData(); return new SelfValidatingPassport( new UserBadge($userData['preferred_username'], function () use ($accessToken, $userData) { return $this->userProvider->loadUserByIdentifier( $userData['preferred_username'], $userData['realm_access']['roles'] ?? [], - $this->getScopesFromToken($accessToken), + $this->tokenService->getScopes(), $userData['email'] ?? null, $userData['name'] ?? null, true @@ -104,16 +106,4 @@ public function start(Request $request, AuthenticationException $authException = { return new RedirectResponse('/', Response::HTTP_TEMPORARY_REDIRECT); } - - private function getScopesFromToken(AccessToken $token): array - { - $roles = []; - $scopes = explode(' ', $token->getValues()['scope'] ?? ''); - - foreach ($scopes as $scope) { - $roles[] = 'ROLE_SCOPE_' . strtoupper(str_replace('.', '_', $scope)); - } - - return $roles; - } } diff --git a/src/Security/KeyCloakUserProvider.php b/src/Security/KeyCloakUserProvider.php index ef25438..ed1533f 100644 --- a/src/Security/KeyCloakUserProvider.php +++ b/src/Security/KeyCloakUserProvider.php @@ -13,14 +13,17 @@ use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +use T3G\Bundle\Keycloak\Service\TokenService; class KeyCloakUserProvider implements UserProviderInterface { + private TokenService $tokenService; private array $roleMapping; private array $defaultRoles; - public function __construct(array $roleMapping, array $defaultRoles = ['ROLE_USER', 'ROLE_OAUTH_USER']) + public function __construct(TokenService $tokenService, array $roleMapping, array $defaultRoles = ['ROLE_USER', 'ROLE_OAUTH_USER']) { + $this->tokenService = $tokenService; $this->roleMapping = $roleMapping; $this->defaultRoles = $defaultRoles; } @@ -80,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/TokenService.php b/src/Service/TokenService.php new file mode 100644 index 0000000..2301bdf --- /dev/null +++ b/src/Service/TokenService.php @@ -0,0 +1,70 @@ +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 + { + $roles = []; + $accessToken = $this->getAccessTokenFromSession(); + + if (null !== $accessToken) { + return $roles; + } + + $scopes = explode(' ', $accessToken->getValues()['scope'] ?? ''); + + foreach ($scopes as $scope) { + $roles[] = 'ROLE_SCOPE_' . strtoupper(str_replace('.', '_', $scope)); + } + + return $roles; + } + + public function getAccessTokenFromSession(): ?AccessToken + { + if ($this->session->has(KeyCloakAuthenticator::SESSION_KEYCLOAK_ACCESS_TOKEN)) { + return $this->session->get(KeyCloakAuthenticator::SESSION_KEYCLOAK_ACCESS_TOKEN); + } + + return null; + } +} From ae1819213740183758d8b462db2930fc8c7a1233 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Tue, 3 Sep 2024 15:51:51 +0200 Subject: [PATCH 14/15] Fix CGL --- src/Security/KeyCloakAuthenticator.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index 96d89e7..c3075bb 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -13,7 +13,6 @@ use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use KnpU\OAuth2ClientBundle\Client\OAuth2ClientInterface; use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator; -use League\OAuth2\Client\Token\AccessToken; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; From f70fde5868e2f3744eeba4965b434560487ef1d3 Mon Sep 17 00:00:00 2001 From: Marvin Buchmann Date: Mon, 9 Sep 2024 10:48:24 +0200 Subject: [PATCH 15/15] Code cleanup and comparison fix --- src/Security/KeyCloakAuthenticator.php | 18 ++++++++---------- src/Service/RedirectService.php | 3 ++- src/Service/TokenService.php | 15 +++++---------- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/Security/KeyCloakAuthenticator.php b/src/Security/KeyCloakAuthenticator.php index c3075bb..4fac813 100644 --- a/src/Security/KeyCloakAuthenticator.php +++ b/src/Security/KeyCloakAuthenticator.php @@ -65,16 +65,14 @@ public function authenticate(Request $request): Passport $userData = $this->tokenService->fetchUserData(); return new SelfValidatingPassport( - new UserBadge($userData['preferred_username'], function () use ($accessToken, $userData) { - return $this->userProvider->loadUserByIdentifier( - $userData['preferred_username'], - $userData['realm_access']['roles'] ?? [], - $this->tokenService->getScopes(), - $userData['email'] ?? null, - $userData['name'] ?? null, - true - ); - }) + 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 + )) ); } diff --git a/src/Service/RedirectService.php b/src/Service/RedirectService.php index 95e574c..d7476e9 100644 --- a/src/Service/RedirectService.php +++ b/src/Service/RedirectService.php @@ -1,4 +1,5 @@ -getAccessTokenFromSession(); - - if (null !== $accessToken) { - return $roles; + if (null === $accessToken) { + return []; } $scopes = explode(' ', $accessToken->getValues()['scope'] ?? ''); - foreach ($scopes as $scope) { - $roles[] = 'ROLE_SCOPE_' . strtoupper(str_replace('.', '_', $scope)); - } - - return $roles; + return array_map(static fn (string $scope) => 'ROLE_SCOPE_' . strtoupper(str_replace('.', '_', $scope)), $scopes); } public function getAccessTokenFromSession(): ?AccessToken