diff --git a/app/Authentication/AuthServiceProvider.php b/app/Authentication/AuthServiceProvider.php index 9c3de33499..78375f002a 100644 --- a/app/Authentication/AuthServiceProvider.php +++ b/app/Authentication/AuthServiceProvider.php @@ -21,7 +21,10 @@ use CultuurNet\UDB3\Impersonator; use CultuurNet\UDB3\Role\UserPermissionsServiceProvider; use CultuurNet\UDB3\Role\ValueObjects\Permission; +use CultuurNet\UDB3\User\ApiKeysMatchedToClientIds; +use CultuurNet\UDB3\User\ClientIdResolver; use CultuurNet\UDB3\User\CurrentUser; +use CultuurNet\UDB3\User\InMemoryApiKeysMatchedToClientIds; use League\Container\DefinitionContainerInterface; final class AuthServiceProvider extends AbstractServiceProvider @@ -36,6 +39,7 @@ protected function getProvidedServiceNames(): array ConsumerReadRepository::class, Consumer::class, 'impersonator', + ApiKeysMatchedToClientIds::class, ]; } @@ -65,6 +69,8 @@ function () use ($container): RequestAuthenticatorMiddleware { $container->get(ConsumerReadRepository::class), new ConsumerIsInPermissionGroup((string) $container->get('config')['api_key']['group_id']), $container->get(UserPermissionsServiceProvider::USER_PERMISSIONS_READ_REPOSITORY), + $container->get(ClientIdResolver::class), + $container->get('config')['match_api_keys_to_client_ids'] ? $container->get(ApiKeysMatchedToClientIds::class) : null ); // We can not expect the ids of events, places and organizers to be correctly formatted as UUIDs, @@ -191,6 +197,13 @@ static function () use ($container): ?Consumer { 'impersonator', fn () => new Impersonator() ); + + $container->addShared( + ApiKeysMatchedToClientIds::class, + fn () => new InMemoryApiKeysMatchedToClientIds( + file_exists(__DIR__ . '/../../config.api_keys_matched_to_client_ids.php') ? require __DIR__ . '/../../config.api_keys_matched_to_client_ids.php' : [] + ) + ); } private function createUitIdV2JwtValidator(DefinitionContainerInterface $container): UitIdV2JwtValidator diff --git a/app/Keycloak/KeycloakServiceProvider.php b/app/Keycloak/KeycloakServiceProvider.php index adc4ee2cbd..ad87e4579a 100644 --- a/app/Keycloak/KeycloakServiceProvider.php +++ b/app/Keycloak/KeycloakServiceProvider.php @@ -6,7 +6,9 @@ use CultuurNet\UDB3\Cache\CacheFactory; use CultuurNet\UDB3\Container\AbstractServiceProvider; +use CultuurNet\UDB3\User\ClientIdResolver; use CultuurNet\UDB3\User\Keycloak\CachedUserIdentityResolver; +use CultuurNet\UDB3\User\Keycloak\KeycloakClientIdResolver; use CultuurNet\UDB3\User\Keycloak\KeycloakUserIdentityResolver; use CultuurNet\UDB3\User\ManagementToken\ManagementTokenProvider; use GuzzleHttp\Client; @@ -17,6 +19,7 @@ protected function getProvidedServiceNames(): array { return [ CachedUserIdentityResolver::class, + ClientIdResolver::class, ]; } @@ -42,5 +45,17 @@ function () use ($container): CachedUserIdentityResolver { ); } ); + + $container->add( + ClientIdResolver::class, + function () use ($container): ClientIdResolver { + return new KeycloakClientIdResolver( + new Client(), + $container->get('config')['keycloak']['domain'], + $container->get('config')['keycloak']['realm'], + $container->get(ManagementTokenProvider::class)->token() + ); + } + ); } } diff --git a/features/auth/matched-apikeys.feature b/features/auth/matched-apikeys.feature new file mode 100644 index 0000000000..1f017a8413 --- /dev/null +++ b/features/auth/matched-apikeys.feature @@ -0,0 +1,30 @@ +Feature: Test authentication with apiKeys matched to clientIds + + Background: + Given I am using the UDB3 base URL + And I am not using an UiTID v1 API key + And I send and accept "application/json" + + Scenario: I can create offers with an apiKey matched to a clientId that has the entry scope + Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientIdWithEntryScope" + And I am authorized as JWT provider user "invoerder_1" + And I create a minimal place and save the "url" as "placeUrl" + And the response status should be "201" + When I create a minimal permanent event and save the "url" as "eventUrl" + Then the response status should be "201" + + Scenario: I cannot create offers with an apiKey matched to a clientId that only has the search scope + Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientIdWithSearchScope" + And I am authorized as JWT provider user "invoerder_1" + And I set the JSON request payload from "places/place-with-required-fields.json" + When I send a POST request to "/places/" + And the response status should be "403" + And the JSON response should be: + """ + { + "type": "https:\/\/api.publiq.be\/probs\/auth\/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "Given API key is not authorized to use Entry API." + } + """ diff --git a/features/search/auth.feature b/features/search/auth.feature index 6b1f4b8c62..4d91f94cae 100644 --- a/features/search/auth.feature +++ b/features/search/auth.feature @@ -39,7 +39,7 @@ Feature: Test the Search API v3 authentication """ Scenario: Search with an API key that will be matched to a client id - Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientId" + Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientIdWithSearchScope" When I send a GET request to "/events" Then the response status should be "200" diff --git a/src/Http/Auth/RequestAuthenticatorMiddleware.php b/src/Http/Auth/RequestAuthenticatorMiddleware.php index b61b7a51f1..919b47c1dc 100644 --- a/src/Http/Auth/RequestAuthenticatorMiddleware.php +++ b/src/Http/Auth/RequestAuthenticatorMiddleware.php @@ -13,19 +13,26 @@ use CultuurNet\UDB3\ApiGuard\Consumer\ConsumerReadRepository as ApiKeyConsumerReadRepository; use CultuurNet\UDB3\ApiGuard\Consumer\Specification\ConsumerSpecification as ApiKeyConsumerSpecification; use CultuurNet\UDB3\Http\ApiProblem\ApiProblem; -use CultuurNet\UDB3\Http\Auth\Jwt\JwtValidator; use CultuurNet\UDB3\Http\Auth\Jwt\JsonWebToken; +use CultuurNet\UDB3\Http\Auth\Jwt\JwtValidator; use CultuurNet\UDB3\Role\ReadModel\Permissions\UserPermissionsReadRepositoryInterface; use CultuurNet\UDB3\Role\ValueObjects\Permission; +use CultuurNet\UDB3\User\ApiKeysMatchedToClientIds; +use CultuurNet\UDB3\User\ClientIdResolver; use CultuurNet\UDB3\User\CurrentUser; +use CultuurNet\UDB3\User\Exceptions\UnmatchedApiKey; use InvalidArgumentException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; final class RequestAuthenticatorMiddleware implements MiddlewareInterface { + use LoggerAwareTrait; + private const BEARER = 'Bearer '; /** @var PublicRouteRule[] */ @@ -44,13 +51,19 @@ final class RequestAuthenticatorMiddleware implements MiddlewareInterface private ApiKeyConsumerSpecification $apiKeyConsumerPermissionCheck; private UserPermissionsReadRepositoryInterface $userPermissionReadRepository; + private ClientIdResolver $clientIdResolver; + + private ?ApiKeysMatchedToClientIds $apiKeysMatchedToClientIds; + public function __construct( JwtValidator $uitIdV1JwtValidator, JwtValidator $uitIdV2JwtValidator, ApiKeyAuthenticator $apiKeyAuthenticator, ApiKeyConsumerReadRepository $apiKeyConsumerReadRepository, ApiKeyConsumerSpecification $apiKeyConsumerPermissionCheck, - UserPermissionsReadRepositoryInterface $userPermissionsReadRepository + UserPermissionsReadRepositoryInterface $userPermissionsReadRepository, + ClientIdResolver $clientIdResolver, + ?ApiKeysMatchedToClientIds $apiKeysMatchedToClientIds = null ) { $this->uitIdV1JwtValidator = $uitIdV1JwtValidator; $this->uitIdV2JwtValidator = $uitIdV2JwtValidator; @@ -58,6 +71,9 @@ public function __construct( $this->apiKeyConsumerReadRepository = $apiKeyConsumerReadRepository; $this->apiKeyConsumerPermissionCheck = $apiKeyConsumerPermissionCheck; $this->userPermissionReadRepository = $userPermissionsReadRepository; + $this->clientIdResolver = $clientIdResolver; + $this->apiKeysMatchedToClientIds = $apiKeysMatchedToClientIds; + $this->logger = new NullLogger(); } public function addPublicRoute(string $pathPattern, array $methods = [], ?string $excludeQueryParam = null): void @@ -155,6 +171,18 @@ private function authenticateApiKey(ServerRequestInterface $request): void ); } + if ($this->apiKeysMatchedToClientIds !== null) { + try { + $clientId = $this->apiKeysMatchedToClientIds->getClientId($this->apiKey->toString()); + if (!$this->clientIdResolver->hasEntryAccess($clientId)) { + throw ApiProblem::forbidden('Given API key is not authorized to use Entry API.'); + } + return; + } catch (UnmatchedApiKey $unmatchedApiKey) { + $this->logger->warning($unmatchedApiKey->getMessage()); + } + } + try { $this->apiKeyAuthenticator->authenticate($this->apiKey); } catch (ApiKeyAuthenticationException $e) { diff --git a/src/User/ApiKeysMatchedToClientIds.php b/src/User/ApiKeysMatchedToClientIds.php new file mode 100644 index 0000000000..4022dabc28 --- /dev/null +++ b/src/User/ApiKeysMatchedToClientIds.php @@ -0,0 +1,10 @@ +apiKey . ' could not be matched to a clientId.'); + } +} diff --git a/src/User/InMemoryApiKeysMatchedToClientIds.php b/src/User/InMemoryApiKeysMatchedToClientIds.php new file mode 100644 index 0000000000..7d44994a94 --- /dev/null +++ b/src/User/InMemoryApiKeysMatchedToClientIds.php @@ -0,0 +1,22 @@ +apiKeysMatchedToClientIds)) { + throw new UnmatchedApiKey($apiKey); + } + return $this->apiKeysMatchedToClientIds[$apiKey]; + } +} diff --git a/src/User/Keycloak/KeycloakClientIdResolver.php b/src/User/Keycloak/KeycloakClientIdResolver.php new file mode 100644 index 0000000000..fa35e305df --- /dev/null +++ b/src/User/Keycloak/KeycloakClientIdResolver.php @@ -0,0 +1,64 @@ +domain)) + ->withPath('/admin/realms/' . $this->realm . '/clients/') + ->withQuery(http_build_query([ + 'clientId' => $clientId, + ])), + [ + 'Authorization' => 'Bearer ' . $this->token, + ] + ); + + $response = $this->client->sendRequest($request); + + if ($response->getStatusCode() !== 200) { + $message = 'Keycloak error when getting metadata: ' . $response->getStatusCode(); + + if ($response->getStatusCode() >= 500) { + throw new ConnectException( + $message, + new Request('GET', '/admin/realms/' . $this->realm . '/clients/') + ); + } + return false; + } + + $contents = Json::decodeAssociatively($response->getBody()->getContents()); + + if (count($contents) !== 1) { + return false; + } + + if (!isset($contents[0]['defaultClientScopes'])) { + return false; + } + + return in_array('publiq-api-entry-scope', $contents[0]['defaultClientScopes']); + } +} diff --git a/tests/User/InMemoryApiKeysMatchedToClientIdsTest.php b/tests/User/InMemoryApiKeysMatchedToClientIdsTest.php new file mode 100644 index 0000000000..efb6be512b --- /dev/null +++ b/tests/User/InMemoryApiKeysMatchedToClientIdsTest.php @@ -0,0 +1,43 @@ +apiKeysMatchedToClientIds = new InMemoryApiKeysMatchedToClientIds( + ['existing_api_key' => 'existing_client_id'] + ); + } + + /** + * @test + */ + public function it_matches_api_keys(): void + { + $this->assertEquals( + 'existing_client_id', + $this->apiKeysMatchedToClientIds->getClientId('existing_api_key') + ); + } + + /** + * @test + */ + public function it_throws_when_it_cannot_find_a_match(): void + { + $this->expectException(UnmatchedApiKey::class); + $this->expectExceptionMessage('unkown_api_key could not be matched to a clientId.'); + + $this->apiKeysMatchedToClientIds->getClientId('unkown_api_key'); + } +} diff --git a/tests/User/Keycloak/KeycloakClientIdResolverTest.php b/tests/User/Keycloak/KeycloakClientIdResolverTest.php new file mode 100644 index 0000000000..a9ddd57eaa --- /dev/null +++ b/tests/User/Keycloak/KeycloakClientIdResolverTest.php @@ -0,0 +1,85 @@ +client = $this->createMock(ClientInterface::class); + $this->clientIdResolver = new KeycloakClientIdResolver( + $this->client, + 'http://keycloak', + 'realm', + 'token' + ); + } + + /** + * @test + */ + public function it_can_check_if_a_client_has_entry_access(): void + { + $this->client->expects($this->any()) + ->method('sendRequest') + ->with($this->callback(function (RequestInterface $request) { + return $request->getUri()->getPath() === '/admin/realms/realm/clients/' && + $request->getMethod() === 'GET' && + $request->getHeaderLine('Authorization') === 'Bearer token'; + })) + ->willReturn( + new Response( + 200, + [], + Json::encode([ + 0 => [ + 'defaultClientScopes' => ['publiq-api-entry-scope'], + ], + ]) + ) + ); + + $this->assertTrue($this->clientIdResolver->hasEntryAccess('entry_api_key')); + } + + /** + * @test + */ + public function it_can_check_if_a_client_does_not_have_entry_access(): void + { + $this->client->expects($this->any()) + ->method('sendRequest') + ->with($this->callback(function (RequestInterface $request) { + return $request->getUri()->getPath() === '/admin/realms/realm/clients/' && + $request->getMethod() === 'GET' && + $request->getHeaderLine('Authorization') === 'Bearer token'; + })) + ->willReturn( + new Response( + 200, + [], + Json::encode([ + 0 => [ + 'defaultClientScopes' => ['publiq-api-search-scope'], + ], + ]) + ) + ); + + $this->assertFalse($this->clientIdResolver->hasEntryAccess('entry_api_key')); + } +}