Skip to content

Commit 07ffd8b

Browse files
authored
Merge pull request #2151 from cultuurnet/III-6844-match-apikeys
III-6844 match apiKeys
2 parents cb74d37 + d7ef864 commit 07ffd8b

12 files changed

+338
-3
lines changed

app/Authentication/AuthServiceProvider.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
use CultuurNet\UDB3\Impersonator;
2222
use CultuurNet\UDB3\Role\UserPermissionsServiceProvider;
2323
use CultuurNet\UDB3\Role\ValueObjects\Permission;
24+
use CultuurNet\UDB3\User\ApiKeysMatchedToClientIds;
25+
use CultuurNet\UDB3\User\ClientIdResolver;
2426
use CultuurNet\UDB3\User\CurrentUser;
27+
use CultuurNet\UDB3\User\InMemoryApiKeysMatchedToClientIds;
2528
use League\Container\DefinitionContainerInterface;
2629

2730
final class AuthServiceProvider extends AbstractServiceProvider
@@ -36,6 +39,7 @@ protected function getProvidedServiceNames(): array
3639
ConsumerReadRepository::class,
3740
Consumer::class,
3841
'impersonator',
42+
ApiKeysMatchedToClientIds::class,
3943
];
4044
}
4145

@@ -65,6 +69,8 @@ function () use ($container): RequestAuthenticatorMiddleware {
6569
$container->get(ConsumerReadRepository::class),
6670
new ConsumerIsInPermissionGroup((string) $container->get('config')['api_key']['group_id']),
6771
$container->get(UserPermissionsServiceProvider::USER_PERMISSIONS_READ_REPOSITORY),
72+
$container->get(ClientIdResolver::class),
73+
$container->get('config')['match_api_keys_to_client_ids'] ? $container->get(ApiKeysMatchedToClientIds::class) : null
6874
);
6975

7076
// 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 {
191197
'impersonator',
192198
fn () => new Impersonator()
193199
);
200+
201+
$container->addShared(
202+
ApiKeysMatchedToClientIds::class,
203+
fn () => new InMemoryApiKeysMatchedToClientIds(
204+
file_exists(__DIR__ . '/../../config.api_keys_matched_to_client_ids.php') ? require __DIR__ . '/../../config.api_keys_matched_to_client_ids.php' : []
205+
)
206+
);
194207
}
195208

196209
private function createUitIdV2JwtValidator(DefinitionContainerInterface $container): UitIdV2JwtValidator

app/Keycloak/KeycloakServiceProvider.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
use CultuurNet\UDB3\Cache\CacheFactory;
88
use CultuurNet\UDB3\Container\AbstractServiceProvider;
9+
use CultuurNet\UDB3\User\ClientIdResolver;
910
use CultuurNet\UDB3\User\Keycloak\CachedUserIdentityResolver;
11+
use CultuurNet\UDB3\User\Keycloak\KeycloakClientIdResolver;
1012
use CultuurNet\UDB3\User\Keycloak\KeycloakUserIdentityResolver;
1113
use CultuurNet\UDB3\User\ManagementToken\ManagementTokenProvider;
1214
use GuzzleHttp\Client;
@@ -17,6 +19,7 @@ protected function getProvidedServiceNames(): array
1719
{
1820
return [
1921
CachedUserIdentityResolver::class,
22+
ClientIdResolver::class,
2023
];
2124
}
2225

@@ -42,5 +45,17 @@ function () use ($container): CachedUserIdentityResolver {
4245
);
4346
}
4447
);
48+
49+
$container->add(
50+
ClientIdResolver::class,
51+
function () use ($container): ClientIdResolver {
52+
return new KeycloakClientIdResolver(
53+
new Client(),
54+
$container->get('config')['keycloak']['domain'],
55+
$container->get('config')['keycloak']['realm'],
56+
$container->get(ManagementTokenProvider::class)->token()
57+
);
58+
}
59+
);
4560
}
4661
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Feature: Test authentication with apiKeys matched to clientIds
2+
3+
Background:
4+
Given I am using the UDB3 base URL
5+
And I am not using an UiTID v1 API key
6+
And I send and accept "application/json"
7+
8+
Scenario: I can create offers with an apiKey matched to a clientId that has the entry scope
9+
Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientIdWithEntryScope"
10+
And I am authorized as JWT provider user "invoerder_1"
11+
And I create a minimal place and save the "url" as "placeUrl"
12+
And the response status should be "201"
13+
When I create a minimal permanent event and save the "url" as "eventUrl"
14+
Then the response status should be "201"
15+
16+
Scenario: I cannot create offers with an apiKey matched to a clientId that only has the search scope
17+
Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientIdWithSearchScope"
18+
And I am authorized as JWT provider user "invoerder_1"
19+
And I set the JSON request payload from "places/place-with-required-fields.json"
20+
When I send a POST request to "/places/"
21+
And the response status should be "403"
22+
And the JSON response should be:
23+
"""
24+
{
25+
"type": "https:\/\/api.publiq.be\/probs\/auth\/forbidden",
26+
"title": "Forbidden",
27+
"status": 403,
28+
"detail": "Given API key is not authorized to use Entry API."
29+
}
30+
"""

features/search/auth.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Feature: Test the Search API v3 authentication
3939
"""
4040

4141
Scenario: Search with an API key that will be matched to a client id
42-
Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientId"
42+
Given I am using an UiTID v1 API key of consumer "apiKeyMatchedToClientIdWithSearchScope"
4343
When I send a GET request to "/events"
4444
Then the response status should be "200"
4545

src/Http/Auth/RequestAuthenticatorMiddleware.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,26 @@
1313
use CultuurNet\UDB3\ApiGuard\Consumer\ConsumerReadRepository as ApiKeyConsumerReadRepository;
1414
use CultuurNet\UDB3\ApiGuard\Consumer\Specification\ConsumerSpecification as ApiKeyConsumerSpecification;
1515
use CultuurNet\UDB3\Http\ApiProblem\ApiProblem;
16-
use CultuurNet\UDB3\Http\Auth\Jwt\JwtValidator;
1716
use CultuurNet\UDB3\Http\Auth\Jwt\JsonWebToken;
17+
use CultuurNet\UDB3\Http\Auth\Jwt\JwtValidator;
1818
use CultuurNet\UDB3\Role\ReadModel\Permissions\UserPermissionsReadRepositoryInterface;
1919
use CultuurNet\UDB3\Role\ValueObjects\Permission;
20+
use CultuurNet\UDB3\User\ApiKeysMatchedToClientIds;
21+
use CultuurNet\UDB3\User\ClientIdResolver;
2022
use CultuurNet\UDB3\User\CurrentUser;
23+
use CultuurNet\UDB3\User\Exceptions\UnmatchedApiKey;
2124
use InvalidArgumentException;
2225
use Psr\Http\Message\ResponseInterface;
2326
use Psr\Http\Message\ServerRequestInterface;
2427
use Psr\Http\Server\MiddlewareInterface;
2528
use Psr\Http\Server\RequestHandlerInterface;
29+
use Psr\Log\LoggerAwareTrait;
30+
use Psr\Log\NullLogger;
2631

2732
final class RequestAuthenticatorMiddleware implements MiddlewareInterface
2833
{
34+
use LoggerAwareTrait;
35+
2936
private const BEARER = 'Bearer ';
3037

3138
/** @var PublicRouteRule[] */
@@ -44,20 +51,29 @@ final class RequestAuthenticatorMiddleware implements MiddlewareInterface
4451
private ApiKeyConsumerSpecification $apiKeyConsumerPermissionCheck;
4552
private UserPermissionsReadRepositoryInterface $userPermissionReadRepository;
4653

54+
private ClientIdResolver $clientIdResolver;
55+
56+
private ?ApiKeysMatchedToClientIds $apiKeysMatchedToClientIds;
57+
4758
public function __construct(
4859
JwtValidator $uitIdV1JwtValidator,
4960
JwtValidator $uitIdV2JwtValidator,
5061
ApiKeyAuthenticator $apiKeyAuthenticator,
5162
ApiKeyConsumerReadRepository $apiKeyConsumerReadRepository,
5263
ApiKeyConsumerSpecification $apiKeyConsumerPermissionCheck,
53-
UserPermissionsReadRepositoryInterface $userPermissionsReadRepository
64+
UserPermissionsReadRepositoryInterface $userPermissionsReadRepository,
65+
ClientIdResolver $clientIdResolver,
66+
?ApiKeysMatchedToClientIds $apiKeysMatchedToClientIds = null
5467
) {
5568
$this->uitIdV1JwtValidator = $uitIdV1JwtValidator;
5669
$this->uitIdV2JwtValidator = $uitIdV2JwtValidator;
5770
$this->apiKeyAuthenticator = $apiKeyAuthenticator;
5871
$this->apiKeyConsumerReadRepository = $apiKeyConsumerReadRepository;
5972
$this->apiKeyConsumerPermissionCheck = $apiKeyConsumerPermissionCheck;
6073
$this->userPermissionReadRepository = $userPermissionsReadRepository;
74+
$this->clientIdResolver = $clientIdResolver;
75+
$this->apiKeysMatchedToClientIds = $apiKeysMatchedToClientIds;
76+
$this->logger = new NullLogger();
6177
}
6278

6379
public function addPublicRoute(string $pathPattern, array $methods = [], ?string $excludeQueryParam = null): void
@@ -155,6 +171,18 @@ private function authenticateApiKey(ServerRequestInterface $request): void
155171
);
156172
}
157173

174+
if ($this->apiKeysMatchedToClientIds !== null) {
175+
try {
176+
$clientId = $this->apiKeysMatchedToClientIds->getClientId($this->apiKey->toString());
177+
if (!$this->clientIdResolver->hasEntryAccess($clientId)) {
178+
throw ApiProblem::forbidden('Given API key is not authorized to use Entry API.');
179+
}
180+
return;
181+
} catch (UnmatchedApiKey $unmatchedApiKey) {
182+
$this->logger->warning($unmatchedApiKey->getMessage());
183+
}
184+
}
185+
158186
try {
159187
$this->apiKeyAuthenticator->authenticate($this->apiKey);
160188
} catch (ApiKeyAuthenticationException $e) {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CultuurNet\UDB3\User;
6+
7+
interface ApiKeysMatchedToClientIds
8+
{
9+
public function getClientId(string $apiKey): string;
10+
}

src/User/ClientIdResolver.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CultuurNet\UDB3\User;
6+
7+
interface ClientIdResolver
8+
{
9+
public function hasEntryAccess(string $clientId): bool;
10+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CultuurNet\UDB3\User\Exceptions;
6+
7+
use Exception;
8+
9+
final class UnmatchedApiKey extends Exception
10+
{
11+
public function __construct(readonly string $apiKey)
12+
{
13+
parent::__construct($this->apiKey . ' could not be matched to a clientId.');
14+
}
15+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CultuurNet\UDB3\User;
6+
7+
use CultuurNet\UDB3\User\Exceptions\UnmatchedApiKey;
8+
9+
final class InMemoryApiKeysMatchedToClientIds implements ApiKeysMatchedToClientIds
10+
{
11+
public function __construct(readonly array $apiKeysMatchedToClientIds)
12+
{
13+
}
14+
15+
public function getClientId(string $apiKey): string
16+
{
17+
if (!array_key_exists($apiKey, $this->apiKeysMatchedToClientIds)) {
18+
throw new UnmatchedApiKey($apiKey);
19+
}
20+
return $this->apiKeysMatchedToClientIds[$apiKey];
21+
}
22+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CultuurNet\UDB3\User\Keycloak;
6+
7+
use CultuurNet\UDB3\Json;
8+
use CultuurNet\UDB3\User\ClientIdResolver;
9+
use GuzzleHttp\Exception\ConnectException;
10+
use GuzzleHttp\Psr7\Request;
11+
use GuzzleHttp\Psr7\Uri;
12+
use Psr\Http\Client\ClientInterface;
13+
14+
final class KeycloakClientIdResolver implements ClientIdResolver
15+
{
16+
public function __construct(
17+
readonly ClientInterface $client,
18+
readonly string $domain,
19+
readonly string $realm,
20+
readonly string $token
21+
) {
22+
}
23+
24+
public function hasEntryAccess(string $clientId): bool
25+
{
26+
$request = new Request(
27+
'GET',
28+
(new Uri($this->domain))
29+
->withPath('/admin/realms/' . $this->realm . '/clients/')
30+
->withQuery(http_build_query([
31+
'clientId' => $clientId,
32+
])),
33+
[
34+
'Authorization' => 'Bearer ' . $this->token,
35+
]
36+
);
37+
38+
$response = $this->client->sendRequest($request);
39+
40+
if ($response->getStatusCode() !== 200) {
41+
$message = 'Keycloak error when getting metadata: ' . $response->getStatusCode();
42+
43+
if ($response->getStatusCode() >= 500) {
44+
throw new ConnectException(
45+
$message,
46+
new Request('GET', '/admin/realms/' . $this->realm . '/clients/')
47+
);
48+
}
49+
return false;
50+
}
51+
52+
$contents = Json::decodeAssociatively($response->getBody()->getContents());
53+
54+
if (count($contents) !== 1) {
55+
return false;
56+
}
57+
58+
if (!isset($contents[0]['defaultClientScopes'])) {
59+
return false;
60+
}
61+
62+
return in_array('publiq-api-entry-scope', $contents[0]['defaultClientScopes']);
63+
}
64+
}

0 commit comments

Comments
 (0)