diff --git a/api/.env b/api/.env index e5b72fdac..5512e6033 100644 --- a/api/.env +++ b/api/.env @@ -18,10 +18,11 @@ TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 TRUSTED_HOSTS=^(localhost|php)$ OIDC_SERVER_URL=https://localhost/oidc/realms/demo -OIDC_SERVER_URL_INTERNAL=http://php/oidc/realms/demo +OIDC_SERVER_URL_INTERNAL=http://keycloak:8080/oidc/realms/demo OIDC_SWAGGER_CLIENT_ID=api-platform-swagger OIDC_API_CLIENT_ID=api-platform-api OIDC_API_CLIENT_SECRET=sEocbxCy7iFS8NzYzWyQ71QgxTDZ9fnU +OIDC_AUD=api-platform ###> symfony/framework-bundle ### APP_ENV=dev diff --git a/api/.env.test b/api/.env.test index e3d5d8bdb..d568771b8 100644 --- a/api/.env.test +++ b/api/.env.test @@ -4,6 +4,7 @@ APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots +OIDC_JWK='{"kty": "EC","d": "cT3_vKHaGOAhhmzR0Jbi1ko40dNtpjtaiWzm_7VNwLA","use": "sig","crv": "P-256","x": "n6PnJPqNK5nP-ymwwsOIqZvjiCKFNzRyqWA8KNyBsDo","y": "bQSmMlDXOmtgyS1rhsKUmqlxq-8Kw0Iw9t50cSloTMM","alg": "ES256"}' # API Platform distribution TRUSTED_HOSTS=^example\.com|localhost$ diff --git a/api/Dockerfile b/api/Dockerfile index ee11ee073..a0498e6a3 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -27,12 +27,14 @@ RUN apt-get update; \ file \ gettext \ git \ + zip \ ; \ rm -rf /var/lib/apt/lists/* RUN set -eux; \ install-php-extensions \ apcu \ + bcmath \ intl \ opcache \ zip \ diff --git a/api/composer.json b/api/composer.json index 7759f4508..d122f6d42 100644 --- a/api/composer.json +++ b/api/composer.json @@ -33,6 +33,7 @@ "symfony/uid": "7.0.*", "symfony/validator": "7.0.*", "symfony/yaml": "7.0.*", + "web-token/jwt-bundle": "^3.3", "web-token/jwt-library": "^3.3", "webonyx/graphql-php": "^15.8", "zenstruck/foundry": "^1.36" @@ -108,7 +109,11 @@ "symfony": { "allow-contrib": false, "require": "7.0.*", - "docker": false + "docker": false, + "endpoint": [ + "https://api.github.com/repos/Spomky-Labs/recipes/contents/index.json?ref=main", + "flex://defaults" + ] } } } diff --git a/api/composer.lock b/api/composer.lock index c0e9500c7..82ca4b18a 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a9f45095d3c9c2465f5fdbd4ed26c0a", + "content-hash": "fcefc5290ee2c6a1aa412d0bfecf2cad", "packages": [ { "name": "api-platform/core", @@ -7103,6 +7103,87 @@ ], "time": "2023-11-21T18:54:41+00:00" }, + { + "name": "web-token/jwt-bundle", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-bundle.git", + "reference": "9a83923fe5069f83a2ef1215071d4fe1a7435591" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-bundle/zipball/9a83923fe5069f83a2ef1215071d4fe1a7435591", + "reference": "9a83923fe5069f83a2ef1215071d4fe1a7435591", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "web-token/jwt-library": "^3.3" + }, + "suggest": { + "symfony/serializer": "Use the Symfony serializer to serialize/unserialize JWS and JWE tokens." + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Jose\\Bundle\\JoseFramework\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-bundle/contributors" + } + ], + "description": "JWT Bundle of the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-bundle/tree/3.3.1" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2024-02-22T08:15:45+00:00" + }, { "name": "web-token/jwt-library", "version": "3.3.1", diff --git a/api/config/bundles.php b/api/config/bundles.php index 24080a7f5..aafe128b0 100644 --- a/api/config/bundles.php +++ b/api/config/bundles.php @@ -18,4 +18,5 @@ DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true], Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['all' => true], + Jose\Bundle\JoseFramework\JoseFrameworkBundle::class => ['all' => true], ]; diff --git a/api/config/packages/framework.yaml b/api/config/packages/framework.yaml index 74579b6ac..2610ba9e5 100644 --- a/api/config/packages/framework.yaml +++ b/api/config/packages/framework.yaml @@ -34,9 +34,3 @@ when@test: test: true #session: # storage_factory_id: session.storage.factory.mock_file - - services: - App\Tests\Api\Mock\: - resource: '../../tests/Api/Mock/' - autowire: true - autoconfigure: true diff --git a/api/config/packages/jose.yaml b/api/config/packages/jose.yaml new file mode 100644 index 000000000..1f1b7ab35 --- /dev/null +++ b/api/config/packages/jose.yaml @@ -0,0 +1,44 @@ +jose: + jws: + serializers: + oidc: + serializers: ['jws_compact'] + is_public: true + loaders: + oidc: + serializers: ['jws_compact'] + signature_algorithms: ['HS256', 'RS256', 'ES256'] + header_checkers: ['alg', 'iat', 'nbf', 'exp', 'aud', 'iss'] + is_public: true + +services: + _defaults: + autowire: true + autoconfigure: true + + Jose\Component\Checker\AlgorithmChecker: + arguments: + $supportedAlgorithms: ['HS256', 'RS256', 'ES256'] + tags: + - name: 'jose.checker.header' + alias: 'alg' + Jose\Component\Checker\AudienceChecker: + arguments: + $audience: '%env(OIDC_AUD)%' + tags: + - name: 'jose.checker.header' + alias: 'aud' + Jose\Component\Checker\IssuerChecker: + arguments: + $issuers: ['%env(OIDC_SERVER_URL)%'] + tags: + - name: 'jose.checker.header' + alias: 'iss' + +when@test: + jose: + jws: + builders: + oidc: + signature_algorithms: ['HS256', 'RS256', 'ES256'] + is_public: true diff --git a/api/config/packages/security.yaml b/api/config/packages/security.yaml index b48296ec5..653cf9442 100644 --- a/api/config/packages/security.yaml +++ b/api/config/packages/security.yaml @@ -1,7 +1,3 @@ -parameters: - app.oidc.jwk: '{"kty": "EC","d": "cT3_vKHaGOAhhmzR0Jbi1ko40dNtpjtaiWzm_7VNwLA","use": "sig","crv": "P-256","x": "n6PnJPqNK5nP-ymwwsOIqZvjiCKFNzRyqWA8KNyBsDo","y": "bQSmMlDXOmtgyS1rhsKUmqlxq-8Kw0Iw9t50cSloTMM","alg": "ES256"}' - app.oidc.aud: 'api-platform' - security: # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: @@ -32,10 +28,14 @@ when@prod: &prod firewalls: main: access_token: - token_handler: - oidc_user_info: - claim: email - base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/protocol/openid-connect/userinfo' + token_handler: App\Security\Http\AccessToken\Oidc\OidcDiscoveryTokenHandler + # todo support Discovery in Symfony +# oidc: +# claim: 'email' +# base_uri: '%env(OIDC_SERVER_URL)%' +# audience: '%env(OIDC_AUD)%' +# cache: '@cache.app' # default +# cache_ttl: 3600 # default when@dev: *prod @@ -47,16 +47,7 @@ when@test: token_handler: oidc: claim: email - audience: '%app.oidc.aud%' + audience: '%env(OIDC_AUD)%' issuers: [ '%env(OIDC_SERVER_URL)%' ] algorithm: 'ES256' - key: '%app.oidc.jwk%' - # required by App\Tests\Api\Trait\SecurityTrait - parameters: - app.oidc.issuer: '%env(OIDC_SERVER_URL)%' - services: - app.security.jwk: - parent: 'security.access_token_handler.oidc.jwk' - public: true - arguments: - $json: '%app.oidc.jwk%' + key: '%env(OIDC_JWK)%' diff --git a/api/config/services.yaml b/api/config/services.yaml index 2d6a76f94..f5e1a973d 100644 --- a/api/config/services.yaml +++ b/api/config/services.yaml @@ -22,3 +22,11 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + +when@test: + services: + App\Tests\Api\Security\: + resource: '../tests/Api/Security/' + autowire: true + autoconfigure: true + public: true diff --git a/api/src/Command/BooksImportCommand.php b/api/src/Command/BooksImportCommand.php index 5996a2caf..f624e2568 100644 --- a/api/src/Command/BooksImportCommand.php +++ b/api/src/Command/BooksImportCommand.php @@ -84,7 +84,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->progressFinish(); - $output->write($this->serializer->serialize($data, 'json', [JsonEncode::OPTIONS => JSON_PRETTY_PRINT])); + $output->write($this->serializer->serialize($data, 'json', [JsonEncode::OPTIONS => \JSON_PRETTY_PRINT])); return Command::SUCCESS; } diff --git a/api/src/DataFixtures/Story/DefaultStory.php b/api/src/DataFixtures/Story/DefaultStory.php index 3482ccb5f..c87bee9f7 100644 --- a/api/src/DataFixtures/Story/DefaultStory.php +++ b/api/src/DataFixtures/Story/DefaultStory.php @@ -14,7 +14,9 @@ final class DefaultStory extends Story { - public function __construct(private readonly DecoderInterface $decoder) {} + public function __construct(private readonly DecoderInterface $decoder) + { + } public function build(): void { diff --git a/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php b/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php index bf7c31a43..bf8ed3dc0 100644 --- a/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php +++ b/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php @@ -16,9 +16,11 @@ */ final readonly class BookmarkQueryCollectionExtension implements QueryCollectionExtensionInterface { - public function __construct(private Security $security) {} + public function __construct(private Security $security) + { + } - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( Bookmark::class !== $resourceClass diff --git a/api/src/Doctrine/Orm/Filter/NameFilter.php b/api/src/Doctrine/Orm/Filter/NameFilter.php index 21d18220e..cb31c64fa 100644 --- a/api/src/Doctrine/Orm/Filter/NameFilter.php +++ b/api/src/Doctrine/Orm/Filter/NameFilter.php @@ -31,7 +31,7 @@ public function getDescription(string $resourceClass): array /** * @param string|null $value */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ('name' !== $property) { return; diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php index f1b0b89c6..442e8a504 100644 --- a/api/src/Entity/Review.php +++ b/api/src/Entity/Review.php @@ -14,7 +14,6 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; -use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\State\CreateProvider; use App\Repository\ReviewRepository; use App\Serializer\IriTransformerNormalizer; diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php index 6e9cb7fb5..05c2063a0 100644 --- a/api/src/Entity/User.php +++ b/api/src/Entity/User.php @@ -101,7 +101,9 @@ public function getId(): ?Uuid return $this->id; } - public function eraseCredentials(): void {} + public function eraseCredentials(): void + { + } /** * @return array diff --git a/api/src/Security/Core/UserProvider.php b/api/src/Security/Core/UserProvider.php index 99f7e0779..55b27e36e 100644 --- a/api/src/Security/Core/UserProvider.php +++ b/api/src/Security/Core/UserProvider.php @@ -17,7 +17,9 @@ */ final readonly class UserProvider implements AttributesBasedUserProviderInterface { - public function __construct(private ManagerRegistry $registry, private UserRepository $repository) {} + public function __construct(private ManagerRegistry $registry, private UserRepository $repository) + { + } public function refreshUser(UserInterface $user): UserInterface { diff --git a/api/src/Security/Http/AccessToken/Oidc/OidcDiscoveryTokenHandler.php b/api/src/Security/Http/AccessToken/Oidc/OidcDiscoveryTokenHandler.php new file mode 100644 index 000000000..89262ad9a --- /dev/null +++ b/api/src/Security/Http/AccessToken/Oidc/OidcDiscoveryTokenHandler.php @@ -0,0 +1,98 @@ +cache->get('oidc.configuration', function (ItemInterface $item): string { + $item->expiresAfter($this->ttl); + $response = HttpClient::create()->request('GET', rtrim($this->issuer, '/') . '/.well-known/openid-configuration'); + + return $response->getContent(); + }), true, 512, \JSON_THROW_ON_ERROR); + + $keyset = JWKSet::createFromJson( + $this->cache->get('oidc.jwkSet', function (ItemInterface $item) use ($oidcConfiguration): string { + $item->expiresAfter($this->ttl); + $client = HttpClient::create(); + $response = $client->request('GET', $oidcConfiguration['jwks_uri']); + // we only need signature key + $keys = array_filter($response->toArray()['keys'], static fn (array $key) => 'sig' === $key['use']); + + return json_encode(['keys' => $keys]); + }) + ); + + try { + // Decode the token + $signature = 0; + $jws = $this->jwsLoader->loadAndVerifyWithKeySet( + token: $accessToken, + keyset: $keyset, + signature: $signature, + ); + + $claims = json_decode($jws->getPayload(), true); + if (empty($claims[$this->claim])) { + throw new MissingClaimException(sprintf('"%s" claim not found.', $this->claim)); + } + + // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate + return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims); + } catch (\Exception $e) { + $this->logger?->error('An error occurred while decoding and validating the token.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } +} diff --git a/api/src/Security/Voter/OIDCPermissionVoter.php b/api/src/Security/Voter/OIDCPermissionVoter.php index de2ab5589..8c5458925 100644 --- a/api/src/Security/Voter/OIDCPermissionVoter.php +++ b/api/src/Security/Voter/OIDCPermissionVoter.php @@ -1,5 +1,7 @@ iriConverter->getIriFromResource($subject); } - if (!is_string($subject)) { + if (!\is_string($subject)) { throw new \InvalidArgumentException(sprintf('Invalid subject type, expected "string" or "object", got "%s".', get_debug_type($subject))); } diff --git a/api/src/Security/Voter/OIDCRoleVoter.php b/api/src/Security/Voter/OIDCRoleVoter.php index 83e277bf2..55f5fb53e 100644 --- a/api/src/Security/Voter/OIDCRoleVoter.php +++ b/api/src/Security/Voter/OIDCRoleVoter.php @@ -1,13 +1,17 @@ strtolower($role), $response->toArray()['realm_access']['roles'] ?? []); - - return in_array(strtolower($attribute), $roles, true); - } catch (ExceptionInterface) { + } catch (HttpExceptionInterface) { + // OIDC server said no! return false; + } catch (ExceptionInterface) { + // OIDC server doesn't seem to answer: check roles in token (if present) + $jws = $this->jwsSerializerManager->unserialize($accessToken); + $claims = json_decode($jws->getPayload(), true); + $roles = array_map(static fn (string $role): string => strtolower($role), $claims['realm_access']['roles'] ?? []); } + + return \in_array(strtolower($attribute), $roles, true); } } diff --git a/api/src/Security/Voter/OIDCVoterTrait.php b/api/src/Security/Voter/OIDCVoterTrait.php index 03a390477..ce03ddae2 100644 --- a/api/src/Security/Voter/OIDCVoterTrait.php +++ b/api/src/Security/Voter/OIDCVoterTrait.php @@ -1,5 +1,7 @@ reviews = $this->router->generate('_api_/books/{bookId}/reviews{._format}_get_collection', [ 'bookId' => $object->getId(), @@ -39,7 +40,7 @@ public function normalize(mixed $object, string $format = null, array $context = return $this->normalizer->normalize($object, $format, [self::class => true] + $context); } - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof Book && !isset($context[self::class]); } diff --git a/api/src/Serializer/IriTransformerNormalizer.php b/api/src/Serializer/IriTransformerNormalizer.php index 9cd4c1777..85b0e787a 100644 --- a/api/src/Serializer/IriTransformerNormalizer.php +++ b/api/src/Serializer/IriTransformerNormalizer.php @@ -21,9 +21,10 @@ final class IriTransformerNormalizer implements NormalizerInterface, NormalizerA public function __construct( private readonly IriConverterInterface $iriConverter, private readonly OperationMetadataFactoryInterface $operationMetadataFactory - ) {} + ) { + } - public function normalize(mixed $object, string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { /** @var array $data */ $data = $this->normalizer->normalize($object, $format, $context + [self::class => true]); @@ -54,7 +55,7 @@ public function normalize(mixed $object, string $format = null, array $context = return $data; } - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return \is_object($data) && !is_iterable($data) diff --git a/api/src/State/Processor/BookPersistProcessor.php b/api/src/State/Processor/BookPersistProcessor.php index c7de6dfd5..7d41be469 100644 --- a/api/src/State/Processor/BookPersistProcessor.php +++ b/api/src/State/Processor/BookPersistProcessor.php @@ -29,7 +29,8 @@ public function __construct( private ProcessorInterface $mercureProcessor, private HttpClientInterface $client, private DecoderInterface $decoder - ) {} + ) { + } /** * @param Book $data diff --git a/api/src/State/Processor/BookRemoveProcessor.php b/api/src/State/Processor/BookRemoveProcessor.php index 8340c21e1..eb6fc6e11 100644 --- a/api/src/State/Processor/BookRemoveProcessor.php +++ b/api/src/State/Processor/BookRemoveProcessor.php @@ -29,7 +29,8 @@ public function __construct( private ProcessorInterface $mercureProcessor, private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private IriConverterInterface $iriConverter - ) {} + ) { + } /** * @param Book $data diff --git a/api/src/State/Processor/BookmarkPersistProcessor.php b/api/src/State/Processor/BookmarkPersistProcessor.php index 904c5b0b7..9f8639527 100644 --- a/api/src/State/Processor/BookmarkPersistProcessor.php +++ b/api/src/State/Processor/BookmarkPersistProcessor.php @@ -25,7 +25,8 @@ public function __construct( private ProcessorInterface $persistProcessor, private Security $security, private ClockInterface $clock - ) {} + ) { + } /** * @param Bookmark $data diff --git a/api/src/State/Processor/MercureProcessor.php b/api/src/State/Processor/MercureProcessor.php index e28973c3c..200406d44 100644 --- a/api/src/State/Processor/MercureProcessor.php +++ b/api/src/State/Processor/MercureProcessor.php @@ -31,7 +31,8 @@ public function __construct( private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, #[Autowire('%api_platform.formats%')] private array $formats - ) {} + ) { + } /** * @param array{item_uri_template?: string, topics?: array, mercure_data?: string} $context diff --git a/api/src/State/Processor/ReviewPersistProcessor.php b/api/src/State/Processor/ReviewPersistProcessor.php index f9e55ddd4..867433f70 100644 --- a/api/src/State/Processor/ReviewPersistProcessor.php +++ b/api/src/State/Processor/ReviewPersistProcessor.php @@ -29,7 +29,8 @@ public function __construct( private ProcessorInterface $mercureProcessor, private Security $security, private ClockInterface $clock - ) {} + ) { + } /** * @param Review $data diff --git a/api/src/State/Processor/ReviewRemoveProcessor.php b/api/src/State/Processor/ReviewRemoveProcessor.php index 070dd7783..bf91259e8 100644 --- a/api/src/State/Processor/ReviewRemoveProcessor.php +++ b/api/src/State/Processor/ReviewRemoveProcessor.php @@ -29,7 +29,8 @@ public function __construct( private ProcessorInterface $mercureProcessor, private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private IriConverterInterface $iriConverter - ) {} + ) { + } /** * @param Review $data diff --git a/api/src/Validator/UniqueUserBook.php b/api/src/Validator/UniqueUserBook.php index c6be64555..11349681b 100644 --- a/api/src/Validator/UniqueUserBook.php +++ b/api/src/Validator/UniqueUserBook.php @@ -11,7 +11,7 @@ final class UniqueUserBook extends Constraint { public string $message = 'The book is already related to the current user.'; - public function __construct(array $options = null, string $message = null, array $groups = null, mixed $payload = null) + public function __construct(?array $options = null, ?string $message = null, ?array $groups = null, mixed $payload = null) { parent::__construct($options ?? [], $groups, $payload); diff --git a/api/src/Validator/UniqueUserBookValidator.php b/api/src/Validator/UniqueUserBookValidator.php index b8616652c..8429d8b4e 100644 --- a/api/src/Validator/UniqueUserBookValidator.php +++ b/api/src/Validator/UniqueUserBookValidator.php @@ -23,7 +23,8 @@ public function __construct( private readonly Security $security, private readonly ManagerRegistry $registry, private readonly PropertyAccessorInterface $propertyAccessor - ) {} + ) { + } /** * @param Bookmark|Review|null $value diff --git a/api/symfony.lock b/api/symfony.lock index 207e56264..f9e709027 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -525,6 +525,15 @@ "twig/twig": { "version": "v3.3.7" }, + "web-token/jwt-bundle": { + "version": "3.3", + "recipe": { + "repo": "github.com/Spomky-Labs/recipes", + "branch": "tree", + "version": "3.0", + "ref": "e9872ca728053c5a09ef09ec4712d430f30895d6" + } + }, "willdurand/negotiation": { "version": "3.0.0" }, diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php index 5f02001e4..e2b0f6785 100644 --- a/api/tests/Api/Admin/BookTest.php +++ b/api/tests/Api/Admin/BookTest.php @@ -12,21 +12,20 @@ use App\Enum\BookCondition; use App\Repository\BookRepository; use App\Tests\Api\Admin\Trait\UsersDataProviderTrait; -use App\Tests\Api\Trait\SecurityTrait; +use App\Tests\Api\Security\TokenGenerator; use App\Tests\Api\Trait\SerializerTrait; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mercure\Update; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; -use PHPUnit\Framework\Attributes\Test; final class BookTest extends ApiTestCase { use Factories; use ResetDatabase; - use SecurityTrait; use SerializerTrait; use UsersDataProviderTrait; @@ -43,7 +42,7 @@ public function asNonAdminUserICannotGetACollectionOfBooks(int $expectedCode, st { $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -63,12 +62,12 @@ public function asNonAdminUserICannotGetACollectionOfBooks(int $expectedCode, st #[Test] #[DataProvider(methodName: 'getUrls')] - public function asAdminUserICanGetACollectionOfBooks(FactoryCollection $factory, string $url, int $hydraTotalItems, int $itemsPerPage = null): void + public function asAdminUserICanGetACollectionOfBooks(FactoryCollection $factory, string $url, int $hydraTotalItems, ?int $itemsPerPage = null): void { // Cannot use Factory as data provider because BookFactory has a service dependency $factory->create(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -135,7 +134,7 @@ public function asAdminUserICanGetACollectionOfBooksOrderedByTitle(): void BookFactory::createOne(['title' => 'The Wandering Earth']); BookFactory::createOne(['title' => 'Ball Lightning']); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -157,7 +156,7 @@ public function asAnyUserICannotGetAnInvalidBook(?UserFactory $userFactory): voi $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -183,7 +182,7 @@ public function asNonAdminUserICannotGetABook(int $expectedCode, string $hydraDe $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -207,7 +206,7 @@ public function asAdminUserICanGetABook(): void { $book = BookFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -231,7 +230,7 @@ public function asNonAdminUserICannotCreateABook(int $expectedCode, string $hydr { $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -262,7 +261,7 @@ public function asNonAdminUserICannotCreateABook(int $expectedCode, string $hydr #[DataProvider(methodName: 'getInvalidDataOnCreate')] public function asAdminUserICannotCreateABookWithInvalidData(array $data, int $statusCode, array $expected): void { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -315,11 +314,11 @@ public static function getInvalidData(): iterable [ '@type' => 'ConstraintViolationList', 'hydra:title' => 'An error occurred', - 'hydra:description' => 'condition: This value should be of type '.BookCondition::class.'.', + 'hydra:description' => 'condition: This value should be of type ' . BookCondition::class . '.', 'violations' => [ [ 'propertyPath' => 'condition', - 'hint' => 'The data must belong to a backed enumeration of type '.BookCondition::class, + 'hint' => 'The data must belong to a backed enumeration of type ' . BookCondition::class, ], ], ], @@ -333,11 +332,11 @@ public static function getInvalidData(): iterable [ '@type' => 'ConstraintViolationList', 'hydra:title' => 'An error occurred', - 'hydra:description' => 'condition: This value should be of type '.BookCondition::class.'.', + 'hydra:description' => 'condition: This value should be of type ' . BookCondition::class . '.', 'violations' => [ [ 'propertyPath' => 'condition', - 'hint' => 'The data must belong to a backed enumeration of type '.BookCondition::class, + 'hint' => 'The data must belong to a backed enumeration of type ' . BookCondition::class, ], ], ], @@ -368,7 +367,7 @@ public static function getInvalidData(): iterable #[Test] public function asAdminUserICanCreateABook(): void { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -429,7 +428,7 @@ public function asNonAdminUserICannotUpdateBook(int $expectedCode, string $hydra $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -461,7 +460,7 @@ public function asAdminUserICannotUpdateAnInvalidBook(): void { BookFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -485,7 +484,7 @@ public function asAdminUserICannotUpdateABookWithInvalidData(array $data, int $s { $book = BookFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -516,7 +515,7 @@ public function asAdminUserICanUpdateABook(): void ]); self::getMercureHub()->reset(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -576,7 +575,7 @@ public function asNonAdminUserICannotDeleteABook(int $expectedCode, string $hydr $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -599,7 +598,7 @@ public function asAdminUserICannotDeleteAnInvalidBook(): void { BookFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -618,7 +617,7 @@ public function asAdminUserICanDeleteABook(): void self::getMercureHub()->reset(); $id = $book->getId(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php index 4f91f445b..b761be05f 100644 --- a/api/tests/Api/Admin/ReviewTest.php +++ b/api/tests/Api/Admin/ReviewTest.php @@ -13,21 +13,20 @@ use App\Entity\Review; use App\Entity\User; use App\Tests\Api\Admin\Trait\UsersDataProviderTrait; -use App\Tests\Api\Trait\SecurityTrait; +use App\Tests\Api\Security\TokenGenerator; use App\Tests\Api\Trait\SerializerTrait; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mercure\Update; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; -use PHPUnit\Framework\Attributes\Test; final class ReviewTest extends ApiTestCase { use Factories; use ResetDatabase; - use SecurityTrait; use SerializerTrait; use UsersDataProviderTrait; @@ -44,7 +43,7 @@ public function asNonAdminUserICannotGetACollectionOfReviews(int $expectedCode, { $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -64,11 +63,11 @@ public function asNonAdminUserICannotGetACollectionOfReviews(int $expectedCode, #[Test] #[DataProvider(methodName: 'getAdminUrls')] - public function asAdminUserICanGetACollectionOfReviews(FactoryCollection $factory, callable|string $url, int $hydraTotalItems, int $itemsPerPage = null): void + public function asAdminUserICanGetACollectionOfReviews(FactoryCollection $factory, callable|string $url, int $hydraTotalItems, ?int $itemsPerPage = null): void { $factory->create(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -151,7 +150,7 @@ public function asNonAdminUserICannotGetAReview(int $expectedCode, string $hydra $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -172,7 +171,7 @@ public function asNonAdminUserICannotGetAReview(int $expectedCode, string $hydra #[Test] public function asAdminUserICannotGetAnInvalidReview(): void { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -186,7 +185,7 @@ public function asAdminUserICanGetAReview(): void { $review = ReviewFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -205,7 +204,7 @@ public function asNonAdminUserICannotUpdateAReview(int $expectedCode, string $hy $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -235,7 +234,7 @@ public function asNonAdminUserICannotUpdateAReview(int $expectedCode, string $hy #[Test] public function asAdminUserICannotUpdateAnInvalidReview(): void { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -264,7 +263,7 @@ public function asAdminUserICanUpdateAReview(): void $review = ReviewFactory::createOne(['book' => $book]); $user = UserFactory::createOneAdmin(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $user->email, ]); @@ -325,7 +324,7 @@ public function asNonAdminUserICannotDeleteAReview(int $expectedCode, string $hy $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -346,7 +345,7 @@ public function asNonAdminUserICannotDeleteAReview(int $expectedCode, string $hy #[Test] public function asAdminUserICannotDeleteAnInvalidReview(): void { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -365,7 +364,7 @@ public function asAdminUserICanDeleteAReview(): void $id = $review->getId(); $bookId = $review->book->getId(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); diff --git a/api/tests/Api/Admin/UserTest.php b/api/tests/Api/Admin/UserTest.php index 4c4bf40af..067e6abd1 100644 --- a/api/tests/Api/Admin/UserTest.php +++ b/api/tests/Api/Admin/UserTest.php @@ -9,19 +9,18 @@ use App\DataFixtures\Factory\UserFactory; use App\Repository\UserRepository; use App\Tests\Api\Admin\Trait\UsersDataProviderTrait; -use App\Tests\Api\Trait\SecurityTrait; +use App\Tests\Api\Security\TokenGenerator; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; -use PHPUnit\Framework\Attributes\Test; final class UserTest extends ApiTestCase { use Factories; use ResetDatabase; - use SecurityTrait; use UsersDataProviderTrait; private Client $client; @@ -37,7 +36,7 @@ public function asNonAdminUserICannotGetACollectionOfUsers(int $expectedCode, st { $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -57,11 +56,11 @@ public function asNonAdminUserICannotGetACollectionOfUsers(int $expectedCode, st #[Test] #[DataProvider(methodName: 'getAdminUrls')] - public function asAdminUserICanGetACollectionOfUsers(FactoryCollection $factory, callable|string $url, int $hydraTotalItems, int $itemsPerPage = null): void + public function asAdminUserICanGetACollectionOfUsers(FactoryCollection $factory, callable|string $url, int $hydraTotalItems, ?int $itemsPerPage = null): void { $factory->create(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -113,7 +112,7 @@ public function asNonAdminUserICannotGetAUser(int $expectedCode, string $hydraDe $options = []; if ($userFactory) { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -136,7 +135,7 @@ public function asAdminUserICanGetAUser(): void { $user = UserFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOneAdmin()->email, ]); @@ -161,7 +160,7 @@ public function asAUserIAmUpdatedOnLogin(): void ])->disableAutoRefresh(); $sub = Uuid::v7()->__toString(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'sub' => $sub, 'email' => $user->email, 'given_name' => 'Chuck', diff --git a/api/tests/Api/BookTest.php b/api/tests/Api/BookTest.php index 884115952..e29886dce 100644 --- a/api/tests/Api/BookTest.php +++ b/api/tests/Api/BookTest.php @@ -10,11 +10,11 @@ use App\DataFixtures\Factory\ReviewFactory; use App\Enum\BookCondition; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use Symfony\Component\HttpFoundation\Response; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; -use PHPUnit\Framework\Attributes\Test; final class BookTest extends ApiTestCase { diff --git a/api/tests/Api/BookmarkTest.php b/api/tests/Api/BookmarkTest.php index 0011c35fd..679a12937 100644 --- a/api/tests/Api/BookmarkTest.php +++ b/api/tests/Api/BookmarkTest.php @@ -12,7 +12,7 @@ use App\Entity\Book; use App\Entity\Bookmark; use App\Repository\BookmarkRepository; -use App\Tests\Api\Trait\SecurityTrait; +use App\Tests\Api\Security\TokenGenerator; use App\Tests\Api\Trait\SerializerTrait; use PHPUnit\Framework\Attributes\Test; use Symfony\Component\HttpFoundation\Response; @@ -25,7 +25,6 @@ final class BookmarkTest extends ApiTestCase { use Factories; use ResetDatabase; - use SecurityTrait; use SerializerTrait; private Client $client; @@ -62,7 +61,7 @@ public function asAUserICanGetACollectionOfMyBookmarksWithoutFilters(): void $user = UserFactory::createOne(); BookmarkFactory::createMany(35, ['user' => $user]); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $user->email, 'authorize' => true, ]); @@ -106,7 +105,7 @@ public function asAnonymousICannotCreateABookmark(): void #[Test] public function asAUserICannotCreateABookmarkWithInvalidData(): void { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, 'authorize' => true, ]); @@ -130,11 +129,11 @@ public function asAUserICannotCreateABookmarkWithInvalidData(): void self::assertJsonContains([ '@type' => 'ConstraintViolationList', 'hydra:title' => 'An error occurred', - 'hydra:description' => 'book: This value should be of type '.Book::class.'.', + 'hydra:description' => 'book: This value should be of type ' . Book::class . '.', 'violations' => [ [ 'propertyPath' => 'book', - 'hint' => 'Item not found for "/books/'.$uuid.'".', + 'hint' => 'Item not found for "/books/' . $uuid . '".', ], ], ]); @@ -150,7 +149,7 @@ public function asAUserICanCreateABookmark(): void $user = UserFactory::createOne(); self::getMercureHub()->reset(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $user->email, 'authorize' => true, ]); @@ -197,7 +196,7 @@ public function asAUserICannotCreateADuplicateBookmark(): void $user = UserFactory::createOne(); BookmarkFactory::createOne(['book' => $book, 'user' => $user]); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $user->email, 'authorize' => true, ]); @@ -245,7 +244,7 @@ public function asAUserICannotDeleteABookmarkOfAnotherUser(): void { $bookmark = BookmarkFactory::createOne(['user' => UserFactory::createOne()]); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, 'authorize' => false, ]); @@ -267,7 +266,7 @@ public function asAUserICannotDeleteABookmarkOfAnotherUser(): void #[Test] public function asAUserICannotDeleteAnInvalidBookmark(): void { - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, ]); @@ -290,7 +289,7 @@ public function asAUserICanDeleteMyBookmark(): void $id = $bookmark->getId(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $bookmark->user->email, 'authorize' => true, ]); diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php index e506d529c..1a6ee8468 100644 --- a/api/tests/Api/ReviewTest.php +++ b/api/tests/Api/ReviewTest.php @@ -13,7 +13,7 @@ use App\Entity\Review; use App\Entity\User; use App\Repository\ReviewRepository; -use App\Tests\Api\Trait\SecurityTrait; +use App\Tests\Api\Security\TokenGenerator; use App\Tests\Api\Trait\SerializerTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -27,7 +27,6 @@ final class ReviewTest extends ApiTestCase { use Factories; use ResetDatabase; - use SecurityTrait; use SerializerTrait; private Client $client; @@ -162,7 +161,7 @@ public function asAUserICannotAddAReviewOnABookWithInvalidData(array $data, int { $book = BookFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, 'authorize' => true, ]); @@ -211,7 +210,7 @@ public function asAUserICannotAddAReviewWithValidDataOnAnInvalidBook(): void ReviewFactory::createMany(5, ['book' => $book]); $user = UserFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $user->email, ]); @@ -248,7 +247,7 @@ public function asAUserICanAddAReviewOnABook(): void $user = UserFactory::createOne(); self::getMercureHub()->reset(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $user->email, 'authorize' => true, ]); @@ -303,7 +302,7 @@ public function asAUserICannotAddADuplicateReviewOnABook(): void $user = UserFactory::createOne(); ReviewFactory::createOne(['book' => $book, 'user' => $user]); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $user->email, 'authorize' => true, ]); @@ -394,7 +393,7 @@ public function asAUserICannotUpdateABookReviewOfAnotherUser(): void { $review = ReviewFactory::createOne(['user' => UserFactory::createOne()]); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, 'authorize' => false, ]); @@ -425,7 +424,7 @@ public function asAUserICannotUpdateAnInvalidBookReview(): void { $book = BookFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, ]); @@ -452,7 +451,7 @@ public function asAUserICanUpdateMyBookReview(): void $review = ReviewFactory::createOne(); self::getMercureHub()->reset(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $review->user->email, 'authorize' => true, ]); @@ -510,7 +509,7 @@ public function asAUserICannotDeleteABookReviewOfAnotherUser(): void { $review = ReviewFactory::createOne(['user' => UserFactory::createOne()]); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, 'authorize' => false, ]); @@ -534,7 +533,7 @@ public function asAUserICannotDeleteAnInvalidBookReview(): void { $book = BookFactory::createOne(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => UserFactory::createOne()->email, ]); @@ -556,7 +555,7 @@ public function asAUserICanDeleteMyBookReview(): void $id = $review->getId(); $bookId = $review->book->getId(); - $token = $this->generateToken([ + $token = self::getContainer()->get(TokenGenerator::class)->generateToken([ 'email' => $review->user->email, 'authorize' => true, ]); diff --git a/api/tests/Api/Security/TokenGenerator.php b/api/tests/Api/Security/TokenGenerator.php new file mode 100644 index 000000000..773454f9e --- /dev/null +++ b/api/tests/Api/Security/TokenGenerator.php @@ -0,0 +1,68 @@ +jwk = JWK::createFromJson(json: $jwk); + } + + public function generateToken(array $claims): string + { + // Defaults + $time = time(); + $sub = Uuid::v7()->__toString(); + $claims += [ + 'sub' => $sub, + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => $this->issuer, + 'aud' => $this->audience, + 'given_name' => 'John', + 'family_name' => 'DOE', + ]; + if (empty($claims['sub'])) { + $claims['sub'] = $sub; + } + if (empty($claims['iat'])) { + $claims['iat'] = $time; + } + if (empty($claims['nbf'])) { + $claims['nbf'] = $time; + } + if (empty($claims['exp'])) { + $claims['exp'] = $time + 3600; + } + + return $this->jwsSerializerManager->serialize( + name: 'jws_compact', + jws: $this->jwsBuilder + ->withPayload(json_encode($claims)) + ->addSignature($this->jwk, ['alg' => $this->jwk->get('alg')]) + ->build(), + ); + } +} diff --git a/api/tests/Api/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php similarity index 91% rename from api/tests/Api/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php rename to api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php index 2aa893ddb..2accd186f 100644 --- a/api/tests/Api/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php +++ b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php @@ -1,6 +1,8 @@ baseUri.'protocol/openid-connect/token/introspect' === $url)) { + if (!('POST' === $method && $this->baseUri . 'protocol/openid-connect/token/introspect' === $url)) { return $this->decorated->request($method, $url, $options); } @@ -44,7 +46,7 @@ private function handleRequest(string $method, string $url, array $options): Res $claims = json_decode($jws->getPayload(), true); // "authorize" custom claim set in the test - if (array_key_exists('authorize', $claims)) { + if (\array_key_exists('authorize', $claims)) { return $claims['authorize'] ? $this->getValidMock($claims) : $this->getInvalidMock(); } diff --git a/api/tests/Api/Mock/KeycloakProtocolOpenIdConnectTokenMock.php b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenMock.php similarity index 92% rename from api/tests/Api/Mock/KeycloakProtocolOpenIdConnectTokenMock.php rename to api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenMock.php index a20b15f72..bc4e151b5 100644 --- a/api/tests/Api/Mock/KeycloakProtocolOpenIdConnectTokenMock.php +++ b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenMock.php @@ -1,6 +1,8 @@ baseUri.'protocol/openid-connect/token' === $url)) { + if (!('POST' === $method && $this->baseUri . 'protocol/openid-connect/token' === $url)) { return $this->decorated->request($method, $url, $options); } @@ -39,7 +41,7 @@ private function handleRequest(string $method, string $url, array $options): Res $claims = json_decode($jws->getPayload(), true); // "authorize" custom claim set in the test - if (array_key_exists('authorize', $claims)) { + if (\array_key_exists('authorize', $claims)) { return $claims['authorize'] ? $this->getValidMock() : $this->getInvalidMock(); } diff --git a/api/tests/Api/Mock/NotImplementedMock.php b/api/tests/Api/Security/Voter/Mock/NotImplementedMock.php similarity index 87% rename from api/tests/Api/Mock/NotImplementedMock.php rename to api/tests/Api/Security/Voter/Mock/NotImplementedMock.php index 0518e502a..b9f44e913 100644 --- a/api/tests/Api/Mock/NotImplementedMock.php +++ b/api/tests/Api/Security/Voter/Mock/NotImplementedMock.php @@ -1,6 +1,8 @@ get('security.access_token_handler.oidc.signature.ES256'); - $jwk = $container->get('app.security.jwk'); - $audience = $container->getParameter('app.oidc.aud'); - $issuer = $container->getParameter('app.oidc.issuer'); - - // Defaults - $time = time(); - $sub = Uuid::v7()->__toString(); - $claims += [ - 'sub' => $sub, - 'iat' => $time, - 'nbf' => $time, - 'exp' => $time + 3600, - 'iss' => $issuer, - 'aud' => $audience, - 'given_name' => 'John', - 'family_name' => 'DOE', - ]; - if (empty($claims['sub'])) { - $claims['sub'] = $sub; - } - if (empty($claims['iat'])) { - $claims['iat'] = $time; - } - if (empty($claims['nbf'])) { - $claims['nbf'] = $time; - } - if (empty($claims['exp'])) { - $claims['exp'] = $time + 3600; - } - - return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([ - $signatureAlgorithm, - ])))->create() - ->withPayload(json_encode($claims)) - ->addSignature($jwk, ['alg' => $signatureAlgorithm->name()]) - ->build() - ); - } -} diff --git a/api/tests/Api/Trait/SerializerTrait.php b/api/tests/Api/Trait/SerializerTrait.php index 4b333a06d..d9ee0144f 100644 --- a/api/tests/Api/Trait/SerializerTrait.php +++ b/api/tests/Api/Trait/SerializerTrait.php @@ -19,7 +19,7 @@ public static function serialize(mixed $data, string $format, array $context = [ static::fail('A client must have Serializer enabled to make serialization. Did you forget to require symfony/serializer?'); } - public static function getOperationNormalizationContext(string $resourceClass, string $operationName = null): array + public static function getOperationNormalizationContext(string $resourceClass, ?string $operationName = null): array { if ($resourceMetadataFactoryCollection = static::getContainer()->get('api_platform.metadata.resource.metadata_collection_factory')) { $operation = $resourceMetadataFactoryCollection->create($resourceClass)->getOperation($operationName); diff --git a/api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php b/api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php index 1394dcf5f..597b855cb 100644 --- a/api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php +++ b/api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php @@ -9,11 +9,11 @@ use App\Doctrine\Orm\Extension\BookmarkQueryCollectionExtension; use App\Entity\Bookmark; use Doctrine\ORM\QueryBuilder; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\User\UserInterface; -use PHPUnit\Framework\Attributes\Test; final class BookmarkQueryCollectionExtensionTest extends TestCase { diff --git a/api/tests/Security/Core/UserProviderTest.php b/api/tests/Security/Core/UserProviderTest.php index d201abc70..e4eb1a69b 100644 --- a/api/tests/Security/Core/UserProviderTest.php +++ b/api/tests/Security/Core/UserProviderTest.php @@ -10,12 +10,12 @@ use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Uid\Uuid; -use PHPUnit\Framework\Attributes\Test; final class UserProviderTest extends TestCase { diff --git a/api/tests/Serializer/BookNormalizerTest.php b/api/tests/Serializer/BookNormalizerTest.php index 27e5dfbe5..86bde68e2 100644 --- a/api/tests/Serializer/BookNormalizerTest.php +++ b/api/tests/Serializer/BookNormalizerTest.php @@ -8,12 +8,12 @@ use App\Enum\BookCondition; use App\Repository\ReviewRepository; use App\Serializer\BookNormalizer; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Uid\Uuid; -use PHPUnit\Framework\Attributes\Test; final class BookNormalizerTest extends TestCase { diff --git a/api/tests/Serializer/IriTransformerNormalizerTest.php b/api/tests/Serializer/IriTransformerNormalizerTest.php index 76c872909..aef28b883 100644 --- a/api/tests/Serializer/IriTransformerNormalizerTest.php +++ b/api/tests/Serializer/IriTransformerNormalizerTest.php @@ -5,15 +5,14 @@ namespace App\Tests\Serializer; use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use App\Serializer\IriTransformerNormalizer; use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use PHPUnit\Framework\Attributes\Test; final class IriTransformerNormalizerTest extends TestCase { diff --git a/api/tests/State/Processor/BookPersistProcessorTest.php b/api/tests/State/Processor/BookPersistProcessorTest.php index 3c0f6c4ad..69b31bb9e 100644 --- a/api/tests/State/Processor/BookPersistProcessorTest.php +++ b/api/tests/State/Processor/BookPersistProcessorTest.php @@ -8,13 +8,12 @@ use ApiPlatform\State\ProcessorInterface; use App\Entity\Book; use App\State\Processor\BookPersistProcessor; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; -use PHPUnit\Framework\Attributes\Test; final class BookPersistProcessorTest extends TestCase { diff --git a/api/tests/State/Processor/BookRemoveProcessorTest.php b/api/tests/State/Processor/BookRemoveProcessorTest.php index b3c45870c..02628ee4f 100644 --- a/api/tests/State/Processor/BookRemoveProcessorTest.php +++ b/api/tests/State/Processor/BookRemoveProcessorTest.php @@ -5,7 +5,6 @@ namespace App\Tests\State\Processor; use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operation; @@ -14,10 +13,9 @@ use ApiPlatform\State\ProcessorInterface; use App\Entity\Book; use App\State\Processor\BookRemoveProcessor; -use App\State\Processor\MercureProcessor; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\Attributes\Test; final class BookRemoveProcessorTest extends TestCase { diff --git a/api/tests/State/Processor/BookmarkPersistProcessorTest.php b/api/tests/State/Processor/BookmarkPersistProcessorTest.php index 8ca5f95e5..a88790852 100644 --- a/api/tests/State/Processor/BookmarkPersistProcessorTest.php +++ b/api/tests/State/Processor/BookmarkPersistProcessorTest.php @@ -9,12 +9,12 @@ use App\Entity\Bookmark; use App\Entity\User; use App\State\Processor\BookmarkPersistProcessor; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Clock\MockClock; -use PHPUnit\Framework\Attributes\Test; final class BookmarkPersistProcessorTest extends TestCase { diff --git a/api/tests/State/Processor/MercureProcessorTest.php b/api/tests/State/Processor/MercureProcessorTest.php index 0a2c4996f..3cf76d8d4 100644 --- a/api/tests/State/Processor/MercureProcessorTest.php +++ b/api/tests/State/Processor/MercureProcessorTest.php @@ -13,13 +13,13 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use App\Entity\Book; use App\State\Processor\MercureProcessor; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubRegistry; use Symfony\Component\Mercure\Update; use Symfony\Component\Serializer\SerializerInterface; -use PHPUnit\Framework\Attributes\Test; final class MercureProcessorTest extends TestCase { diff --git a/api/tests/State/Processor/ReviewPersistProcessorTest.php b/api/tests/State/Processor/ReviewPersistProcessorTest.php index 816c4811f..56d71c892 100644 --- a/api/tests/State/Processor/ReviewPersistProcessorTest.php +++ b/api/tests/State/Processor/ReviewPersistProcessorTest.php @@ -10,12 +10,12 @@ use App\Entity\Review; use App\Entity\User; use App\State\Processor\ReviewPersistProcessor; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Clock\MockClock; -use PHPUnit\Framework\Attributes\Test; final class ReviewPersistProcessorTest extends TestCase { diff --git a/api/tests/State/Processor/ReviewRemoveProcessorTest.php b/api/tests/State/Processor/ReviewRemoveProcessorTest.php index 8d9de5d89..cfd60bc76 100644 --- a/api/tests/State/Processor/ReviewRemoveProcessorTest.php +++ b/api/tests/State/Processor/ReviewRemoveProcessorTest.php @@ -5,7 +5,6 @@ namespace App\Tests\State\Processor; use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operation; @@ -13,11 +12,10 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\ProcessorInterface; use App\Entity\Review; -use App\State\Processor\MercureProcessor; use App\State\Processor\ReviewRemoveProcessor; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\Attributes\Test; final class ReviewRemoveProcessorTest extends TestCase { diff --git a/compose.override.yaml b/compose.override.yaml index 26c5cc99e..f07decc7a 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -58,7 +58,7 @@ services: keycloak-config-cli: image: bitnami/keycloak-config-cli:5-debian-11 environment: - KEYCLOAK_URL: http://php/oidc/ + KEYCLOAK_URL: http://keycloak:8080/oidc/ KEYCLOAK_USER: ${KEYCLOAK_ADMIN_USER:-admin} KEYCLOAK_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-!ChangeMe!} KEYCLOAK_AVAILABILITYCHECK_ENABLED: "true" diff --git a/compose.yaml b/compose.yaml index 25e573059..8bc1a26ba 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,7 +19,7 @@ services: MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} OIDC_SERVER_URL: ${OIDC_SERVER_URL:-https://localhost/oidc/realms/demo} - OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://php/oidc/realms/demo} + OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://keycloak:8080/oidc/realms/demo} ports: # HTTP - target: 80 @@ -42,7 +42,7 @@ services: NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost/api/auth} NEXTAUTH_URL_INTERNAL: http://127.0.0.1:3000/api/auth OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-api-platform-pwa} - OIDC_SERVER_URL: ${OIDC_SERVER_URL_INTERNAL:-http://php/oidc/realms/demo} + OIDC_SERVER_URL: ${OIDC_SERVER_URL_INTERNAL:-http://keycloak:8080/oidc/realms/demo} ###> doctrine/doctrine-bundle ### database: diff --git a/helm/api-platform/keycloak/config/realm-demo.json b/helm/api-platform/keycloak/config/realm-demo.json index 17a271de5..29d7a3918 100755 --- a/helm/api-platform/keycloak/config/realm-demo.json +++ b/helm/api-platform/keycloak/config/realm-demo.json @@ -83,6 +83,14 @@ ] } ], + "clientScopes": [ + { + "name": "roles", + "attributes": { + "include.in.token.scope": "true" + } + } + ], "clients": [ { "id": "6832d039-5543-4e66-afc5-bc5057e8234d",