Skip to content
Snippets Groups Projects
Commit 1489b467 authored by Reiter, Christoph's avatar Reiter, Christoph :snake:
Browse files

Start moving over keycloak specifics from the core bundle

parent e985b6a4
No related branches found
No related tags found
No related merge requests found
Pipeline #52155 passed
Showing
with 5208 additions and 1593 deletions
This diff is collapsed.
......@@ -12,3 +12,5 @@ parameters:
excludes_analyse:
- tests/bootstrap.php
ignoreErrors:
- message: '#.*NodeDefinition::children.*#'
path: ./src/DependencyInjection
\ No newline at end of file
......@@ -13,6 +13,7 @@
<server name="SHELL_VERBOSITY" value="-1"/>
<server name="SYMFONY_PHPUNIT_REMOVE" value=""/>
<server name="SYMFONY_PHPUNIT_VERSION" value="9"/>
<server name="KERNEL_CLASS" value="DBP\API\KeycloakBundle\Tests\Kernel"/>
</php>
<testsuites>
<testsuite name="Project Test Suite">
......
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder('dbp_keycloak');
$treeBuilder->getRootNode()
->children()
->scalarNode('server_url')->end()
->scalarNode('realm')->end()
->scalarNode('client_id')->end()
->scalarNode('client_secret')->end()
->scalarNode('audience')->end()
->booleanNode('local_validation')->defaultTrue()->end()
->end();
return $treeBuilder;
}
}
......@@ -4,12 +4,28 @@ declare(strict_types=1);
namespace DBP\API\KeycloakBundle\DependencyInjection;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
class DbpKeycloakExtension extends ConfigurableExtension
{
public function loadInternal(array $mergedConfig, ContainerBuilder $container)
{
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__.'/../Resources/config')
);
$loader->load('services.yaml');
$certCacheDef = $container->register('dbp_api.cache.keycloak.keycloak_cert', FilesystemAdapter::class);
$certCacheDef->setArguments(['core-keycloak-cert', 60, '%kernel.cache_dir%/dbp/keycloak-keycloak-cert']);
$certCacheDef->addTag('cache.pool');
$definition = $container->getDefinition('DBP\API\KeycloakBundle\Keycloak\KeycloakBearerUserProvider');
$definition->addMethodCall('setConfig', [$mergedConfig['keycloak'] ?? []]);
$definition->addMethodCall('setCertCache', [$certCacheDef]);
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Helpers;
use GuzzleHttp\MessageFormatter;
use GuzzleHttp\Middleware;
use Psr\Log\LoggerInterface;
class Tools
{
public static function createLoggerMiddleware(LoggerInterface $logger): callable
{
return Middleware::log(
$logger,
new MessageFormatter('[{method}] {uri}: CODE={code}, ERROR={error}, CACHE={res_header_X-Kevinrob-Cache}')
);
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
class Keycloak
{
/**
* @var string
*/
private $authServerUrl = null;
/**
* @var string
*/
private $realm = null;
/**
* @var string
*/
private $clientId = null;
/**
* @var string
*/
private $clientSecret = null;
public function __construct(string $serverUrl, string $realm, string $cliendId = null, string $clientSecret = null)
{
$this->authServerUrl = $serverUrl;
$this->realm = $realm;
$this->clientId = $cliendId;
$this->clientSecret = $clientSecret;
}
public function getClientId()
{
return $this->clientId;
}
public function getClientSecret()
{
return $this->clientSecret;
}
public function getBaseUrlWithRealm()
{
return sprintf('%s/realms/%s', $this->authServerUrl, $this->realm);
}
public function getBaseAuthorizationUrl(): string
{
return sprintf('%s/protocol/openid-connect/auth', $this->getBaseUrlWithRealm());
}
public function getBaseAccessTokenUrl(array $params): string
{
return sprintf('%s/protocol/openid-connect/token', $this->getBaseUrlWithRealm());
}
public function getTokenIntrospectionUrl(): string
{
return sprintf('%s/protocol/openid-connect/token/introspect', $this->getBaseUrlWithRealm());
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class KeycloakBearerAuthenticator extends AbstractAuthenticator implements LoggerAwareInterface
{
use LoggerAwareTrait;
private $userProvider;
public function __construct(KeycloakBearerUserProviderInterface $userProvider)
{
$this->userProvider = $userProvider;
}
public function supports(Request $request): ?bool
{
return $request->headers->has('Authorization');
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_FORBIDDEN);
}
public function authenticate(Request $request): PassportInterface
{
$auth = $request->headers->get('Authorization', '');
if ($auth === '') {
throw new BadCredentialsException('Token is not present in the request headers');
}
$token = trim(preg_replace('/^(?:\s+)?Bearer\s/', '', $auth));
return new SelfValidatingPassport(new UserBadge($token, function ($token) {
return $this->userProvider->loadUserByToken($token);
}));
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
use Symfony\Component\Security\Core\User\UserInterface;
class KeycloakBearerUser implements UserInterface
{
/**
* @var string[]
*/
private $roles;
/**
* @var string|null
*/
private $identifier;
public function __construct(?string $identifier, array $roles)
{
$this->roles = $roles;
$this->identifier = $identifier;
}
public function getRoles(): array
{
return $this->roles;
}
public function getPassword()
{
return null;
}
public function getSalt()
{
return null;
}
public function getUsername(): string
{
return $this->getUserIdentifier();
}
public function getUserIdentifier(): string
{
return $this->identifier ?? '';
}
public function eraseCredentials()
{
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
use DBP\API\CoreBundle\API\UserSessionInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Security\Core\User\UserInterface;
class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
private $config;
private $certCachePool;
private $personCachePool;
private $userSession;
public function __construct(UserSessionInterface $userSession)
{
$this->userSession = $userSession;
$this->config = [];
}
public function setConfig(array $config)
{
$this->config = $config;
}
public function setCertCache(?CacheItemPoolInterface $cachePool)
{
$this->certCachePool = $cachePool;
}
public function loadUserByToken(string $accessToken): UserInterface
{
$config = $this->config;
$keycloak = new Keycloak(
$config['server_url'], $config['realm'],
$config['client_id'], $config['client_secret']);
if ($config['local_validation']) {
$validator = new KeycloakLocalTokenValidator($keycloak, $this->certCachePool);
} else {
$validator = new KeycloakRemoteTokenValidator($keycloak);
}
$validator->setLogger($this->logger);
$jwt = $validator->validate($accessToken);
if (($config['audience'] ?? '') !== '') {
$validator::checkAudience($jwt, $config['audience']);
}
return $this->loadUserByValidatedToken($jwt);
}
public function loadUserByValidatedToken(array $jwt): UserInterface
{
$session = $this->userSession;
$session->setSessionToken($jwt);
$identifier = $session->getUserIdentifier();
$userRoles = $session->getUserRoles();
return new KeycloakBearerUser(
$identifier,
$userRoles
);
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
use Symfony\Component\Security\Core\User\UserInterface;
interface KeycloakBearerUserProviderInterface
{
public function loadUserByToken(string $accessToken): UserInterface;
public function loadUserByValidatedToken(array $jwt): UserInterface;
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
use DBP\API\KeycloakBundle\Helpers\Tools;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Jose\Component\Core\JWKSet;
use Jose\Easy\Load;
use Jose\Easy\Validate;
use Kevinrob\GuzzleCache\CacheMiddleware;
use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage;
use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy;
use Psr\Cache\CacheItemPoolInterface;
class KeycloakLocalTokenValidator extends KeycloakTokenValidatorBase
{
private $keycloak;
private $cachePool;
private $clientHandler;
/* The duration the public keycloak cert is cached */
private const CERT_CACHE_TTL_SECONDS = 3600;
/* The leeway given for time based checks for token validation, in case the clocks of the server are out of sync */
private const LOCAL_LEEWAY_SECONDS = 120;
public function __construct(Keycloak $keycloak, ?CacheItemPoolInterface $cachePool)
{
$this->keycloak = $keycloak;
$this->cachePool = $cachePool;
$this->clientHandler = null;
}
/**
* Replace the guzzle client handler for testing.
*
* @param object $handler
*/
public function setClientHandler(?object $handler)
{
$this->clientHandler = $handler;
}
/**
* Fetches the JWKs from the keycloak server and caches them.
*
* @throws TokenValidationException
*/
private function fetchJWKs(): array
{
$provider = $this->keycloak;
$certsUrl = sprintf('%s/protocol/openid-connect/certs', $provider->getBaseUrlWithRealm());
$stack = HandlerStack::create($this->clientHandler);
if ($this->logger !== null) {
$stack->push(Tools::createLoggerMiddleware($this->logger));
}
$options = [
'handler' => $stack,
'headers' => [
'Accept' => 'application/json',
],
];
$client = new Client($options);
if ($this->cachePool !== null) {
$cacheMiddleWare = new CacheMiddleware(
new GreedyCacheStrategy(
new Psr6CacheStorage($this->cachePool),
self::CERT_CACHE_TTL_SECONDS
)
);
$stack->push($cacheMiddleWare);
}
try {
$response = $client->request('GET', $certsUrl);
} catch (\Exception $e) {
throw new TokenValidationException('Cert fetching failed: '.$e->getMessage());
}
try {
$jwks = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new TokenValidationException('Cert fetching, invalid json: '.$e->getMessage());
}
return $jwks;
}
/**
* Validates the token locally using the public JWK of the keycloak server.
*
* This is faster because everything can be cached, but tokens/sessions revoked on the keycloak server
* will still be considered valid as long as they are not expired.
*
* @return array the token
*
* @throws TokenValidationException
*/
public function validate(string $accessToken): array
{
$jwks = $this->fetchJWKs();
$issuer = $this->keycloak->getBaseUrlWithRealm();
// Checks not needed/used here:
// * sub(): This is the keycloak user ID by default, nothing we know beforehand
// * jti(): Nothing we know beforehand
// * aud(): The audience needs to be checked afterwards with checkAudience()
try {
$keySet = JWKSet::createFromKeyData($jwks);
$validate = Load::jws($accessToken);
$validate = $validate
->algs(['RS256', 'RS512'])
->keyset($keySet)
->exp(self::LOCAL_LEEWAY_SECONDS)
->iat(self::LOCAL_LEEWAY_SECONDS)
->nbf(self::LOCAL_LEEWAY_SECONDS)
->iss($issuer);
assert($validate instanceof Validate);
$jwtResult = $validate->run();
} catch (\Exception $e) {
throw new TokenValidationException('Token validation failed: '.$e->getMessage());
}
$jwt = $jwtResult->claims->all();
// XXX: Keycloak will add extra data to the token returned by introspection, mirror this behaviour here
// to avoid breakage when switching between local/remote validation.
// https://github.com/keycloak/keycloak/blob/8225157a1cecef30034530aa/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java#L59
if (isset($jwt['preferred_username'])) {
$jwt['username'] = $jwt['preferred_username'];
}
if (!isset($jwt['username'])) {
$jwt['username'] = null;
}
if (isset($jwt['azp'])) {
$jwt['client_id'] = $jwt['azp'];
}
$jwt['active'] = true;
return $jwt;
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
use DBP\API\KeycloakBundle\Helpers\Tools;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
class KeycloakRemoteTokenValidator extends KeycloakTokenValidatorBase
{
private $keycloak;
private $clientHandler;
public function __construct(Keycloak $keycloak)
{
$this->keycloak = $keycloak;
$this->clientHandler = null;
}
/**
* Replace the guzzle client handler for testing.
*
* @param object $handler
*/
public function setClientHandler(?object $handler)
{
$this->clientHandler = $handler;
}
/**
* Validates the token with the Keycloak introspection endpoint.
*
* @return array the token
*
* @throws TokenValidationException
*/
public function validate(string $accessToken): array
{
$stack = HandlerStack::create($this->clientHandler);
$options = [
'handler' => $stack,
'headers' => [
'Accept' => 'application/json',
],
];
$client = new Client($options);
if ($this->logger !== null) {
$stack->push(Tools::createLoggerMiddleware($this->logger));
}
$provider = $this->keycloak;
$client_secret = $provider->getClientSecret();
$client_id = $provider->getClientId();
if (!$client_secret || !$client_id) {
throw new TokenValidationException('Keycloak client ID or secret not set!');
}
try {
// keep in mind that even if we are doing this request with a different client id the data returned will be
// from the client id of token $accessToken (that's important for mapped attributes)
$response = $client->request('POST', $provider->getTokenIntrospectionUrl(), [
'auth' => [$client_id, $client_secret],
'form_params' => [
'token' => $accessToken,
],
]);
} catch (\Exception $e) {
throw new TokenValidationException('Keycloak introspection failed');
}
try {
$jwt = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new TokenValidationException('Cert fetching, invalid json: '.$e->getMessage());
}
if (!$jwt['active']) {
throw new TokenValidationException('The token does not exist or is not valid anymore');
}
return $jwt;
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
abstract class KeycloakTokenValidatorBase implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* Validates the token and returns the parsed token.
*
* @return array the token
*
* @throws TokenValidationException
*/
abstract public function validate(string $accessToken): array;
/**
* Verifies that the token was created for the given audience.
* If not then throws TokenValidationException.
*
* @param array $jwt The access token
* @param string $audience The audience string
*
* @throws TokenValidationException
*/
public static function checkAudience(array $jwt, string $audience): void
{
$value = $jwt['aud'] ?? [];
if (\is_string($value)) {
if ($value !== $audience) {
throw new TokenValidationException('Bad audience');
}
} elseif (\is_array($value)) {
if (!\in_array($audience, $value, true)) {
throw new TokenValidationException('Bad audience');
}
} else {
throw new TokenValidationException('Bad audience');
}
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Keycloak;
class TokenValidationException extends \Exception
{
}
services:
DBP\API\KeycloakBundle\Keycloak\KeycloakBearerAuthenticator:
autowire: true
autoconfigure: true
DBP\API\KeycloakBundle\Keycloak\KeycloakBearerUserProvider:
autowire: true
autoconfigure: true
DBP\API\KeycloakBundle\Service\KeycloakUserSession:
autowire: true
autoconfigure: true
DBP\API\KeycloakBundle\Keycloak\KeycloakBearerUserProviderInterface:
'@DBP\API\KeycloakBundle\Keycloak\KeycloakBearerUserProvider'
DBP\API\CoreBundle\API\UserSessionInterface:
'@DBP\API\KeycloakBundle\Service\KeycloakUserSession'
Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface:
'@DBP\API\KeycloakBundle\Keycloak\KeycloakBearerAuthenticator'
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Service;
use DBP\API\CoreBundle\API\UserSessionInterface;
class KeycloakUserSession implements UserSessionInterface
{
/**
* @var ?array
*/
private $jwt;
public function __construct()
{
$this->jwt = null;
}
public function getUserIdentifier(): ?string
{
assert($this->jwt !== null);
if (self::isServiceAccountToken($this->jwt)) {
return null;
}
return $this->jwt['username'] ?? null;
}
public function getUserRoles(): array
{
assert($this->jwt !== null);
$scopes = [];
if ($this->jwt['scope'] ?? '' !== '') {
$scopes = explode(' ', $this->jwt['scope']);
}
$roles = [];
foreach ($scopes as $scope) {
$roles[] = 'ROLE_SCOPE_'.mb_strtoupper($scope);
}
return $roles;
}
/**
* Given a token returns if the token was generated through a client credential flow.
*/
public static function isServiceAccountToken(array $jwt): bool
{
if (!array_key_exists('scope', $jwt)) {
throw new \RuntimeException('Token missing scope key');
}
$scope = $jwt['scope'];
// XXX: This is the main difference I found compared to other flows, but that's a Keycloak
// implementation detail I guess.
$has_openid_scope = in_array('openid', explode(' ', $scope), true);
return !$has_openid_scope;
}
public function setSessionToken(?array $jwt): void
{
$this->jwt = $jwt;
}
public function getSessionLoggingId(): string
{
$unknown = 'unknown';
if ($this->jwt === null) {
return $unknown;
}
assert($this->jwt !== null);
// We want to know where the request is coming from and which requests likely belong together for debugging
// purposes while still preserving the privacy of the user.
// The session ID gets logged in the Keycloak event log under 'code_id' and stays the same during a login
// session. When the event in keycloak expires it's no longer possible to map the ID to a user.
// The keycloak client ID is in azp, so add that too, and hash it with the user ID so we get different
// user ids for different clients for the same session.
$jwt = $this->jwt;
$client = $jwt['azp'] ?? $unknown;
if (!isset($jwt['session_state'])) {
$user = $unknown;
} else {
// TODO: If we'd have an app secret we could hash that in too
$user = substr(hash('sha256', $client.$jwt['session_state']), 0, 6);
}
return $client.'-'.$user;
}
public function getSessionCacheKey(): string
{
assert($this->jwt !== null);
return hash('sha256', $this->getUserIdentifier().'.'.json_encode($this->jwt));
}
public function getSessionTTL(): int
{
assert($this->jwt !== null);
return max($this->jwt['exp'] - $this->jwt['iat'], 0);
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Tests;
use ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle;
use DBP\API\CoreBundle\DbpCoreBundle;
use DBP\API\KeycloakBundle\DbpKeycloakBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function registerBundles(): iterable
{
yield new FrameworkBundle();
yield new SecurityBundle();
yield new TwigBundle();
yield new NelmioCorsBundle();
yield new ApiPlatformBundle();
yield new DbpKeycloakBundle();
yield new DbpCoreBundle();
}
protected function configureRoutes(RoutingConfigurator $routes)
{
$routes->import('@DbpCoreBundle/Resources/config/routing.yaml');
}
protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader)
{
$c->loadFromExtension('framework', [
'test' => true,
'secret' => '',
]);
}
}
<?php
declare(strict_types=1);
namespace DBP\API\KeycloakBundle\Tests;
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
class KernelTest extends ApiTestCase
{
public function testBasics()
{
$client = static::createClient();
$this->assertNotNull($client);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment