Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/Authentication/AuthServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
use CultuurNet\UDB3\Impersonator;
use CultuurNet\UDB3\Role\UserPermissionsServiceProvider;
use CultuurNet\UDB3\Role\ValueObjects\Permission;
use CultuurNet\UDB3\Temp\ApiKeysMatchedToClientIds;
use CultuurNet\UDB3\Temp\InMemoryApiKeysMatchedToClientIds;
use CultuurNet\UDB3\User\ClientIdResolver;
use CultuurNet\UDB3\User\CurrentUser;
use League\Container\DefinitionContainerInterface;

Expand All @@ -36,6 +39,7 @@ protected function getProvidedServiceNames(): array
ConsumerReadRepository::class,
Consumer::class,
'impersonator',
ApiKeysMatchedToClientIds::class,
];
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions app/Keycloak/KeycloakServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +19,7 @@ protected function getProvidedServiceNames(): array
{
return [
CachedUserIdentityResolver::class,
ClientIdResolver::class,
];
}

Expand All @@ -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()
);
}
);
}
}
30 changes: 30 additions & 0 deletions features/auth/matched-apikeys.feature
Original file line number Diff line number Diff line change
@@ -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 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 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."
}
"""
2 changes: 1 addition & 1 deletion features/search/auth.feature
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Feature: Test the Search API v3 authentication
Then the response status should be "401"

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"

Expand Down
30 changes: 29 additions & 1 deletion src/Http/Auth/RequestAuthenticatorMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,22 @@
use CultuurNet\UDB3\Http\Auth\Jwt\JsonWebToken;
use CultuurNet\UDB3\Role\ReadModel\Permissions\UserPermissionsReadRepositoryInterface;
use CultuurNet\UDB3\Role\ValueObjects\Permission;
use CultuurNet\UDB3\Temp\ApiKeysMatchedToClientIds;
use CultuurNet\UDB3\Temp\UnmatchedApiKey;
use CultuurNet\UDB3\User\ClientIdResolver;
use CultuurNet\UDB3\User\CurrentUser;
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[] */
Expand All @@ -44,20 +51,29 @@ 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;
$this->apiKeyAuthenticator = $apiKeyAuthenticator;
$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
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions src/Temp/ApiKeysMatchedToClientIds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace CultuurNet\UDB3\Temp;

interface ApiKeysMatchedToClientIds
{
public function getClientId(string $apiKey): string;
}
20 changes: 20 additions & 0 deletions src/Temp/InMemoryApiKeysMatchedToClientIds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace CultuurNet\UDB3\Temp;

final class InMemoryApiKeysMatchedToClientIds implements ApiKeysMatchedToClientIds
{
public function __construct(readonly array $apiKeysMatchedToClientIds)
{
}

public function getClientId(string $apiKey): string
{
if (!array_key_exists($apiKey, $this->apiKeysMatchedToClientIds)) {
throw new UnmatchedApiKey($apiKey);
}
return $this->apiKeysMatchedToClientIds[$apiKey];
}
}
15 changes: 15 additions & 0 deletions src/Temp/UnmatchedApiKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace CultuurNet\UDB3\Temp;

use Exception;

final class UnmatchedApiKey extends Exception
{
public function __construct(readonly string $apiKey)
{
parent::__construct($this->apiKey . ' could not be matched to a clientId.');
}
}
10 changes: 10 additions & 0 deletions src/User/ClientIdResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace CultuurNet\UDB3\User;

interface ClientIdResolver
{
public function hasEntryAccess(string $clientId): bool;
}
64 changes: 64 additions & 0 deletions src/User/Keycloak/KeycloakClientIdResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace CultuurNet\UDB3\User\Keycloak;

use CultuurNet\UDB3\Json;
use CultuurNet\UDB3\User\ClientIdResolver;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Uri;
use Psr\Http\Client\ClientInterface;

final class KeycloakClientIdResolver implements ClientIdResolver
{
public function __construct(
readonly ClientInterface $client,
readonly string $domain,
readonly string $realm,
readonly string $token
) {
}

public function hasEntryAccess(string $clientId): bool
{
$request = new Request(
'GET',
(new Uri($this->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']);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Quality: Magic string

The scope name 'publiq-api-entry-scope' is hardcoded. Consider:

  1. Making it a class constant
  2. Or making it configurable via constructor/config

This would make testing easier and the code more maintainable if the scope name changes.

}
}