From 74f5a2115ea7af01778c48d7bdb0e6598e3a6942 Mon Sep 17 00:00:00 2001 From: konradoboza Date: Wed, 12 Jun 2024 16:09:09 +0200 Subject: [PATCH] IBX-8356: Reworked Ibexa\Core\MVC\Symfony\Security\Authentication\AuthenticatorInterface usages to comply with Symfony-based authentication --- .../graphql/PlatformMutation.types.yaml | 18 --- .../Resources/config/services/resolvers.yaml | 6 - .../Resources/config/services/services.yaml | 9 ++ src/lib/Mutation/Authentication.php | 76 ---------- src/lib/Security/JWTAuthenticator.php | 141 ++++++++++++++++++ .../JWTTokenMutationFormatEventSubscriber.php | 50 +++++++ src/lib/Security/JWTUser.php | 56 ------- .../NonAdminGraphQLRequestMatcher.php | 17 ++- 8 files changed, 210 insertions(+), 163 deletions(-) delete mode 100644 src/lib/Mutation/Authentication.php create mode 100644 src/lib/Security/JWTAuthenticator.php create mode 100644 src/lib/Security/JWTTokenMutationFormatEventSubscriber.php delete mode 100644 src/lib/Security/JWTUser.php diff --git a/src/bundle/Resources/config/graphql/PlatformMutation.types.yaml b/src/bundle/Resources/config/graphql/PlatformMutation.types.yaml index a9ee319..45a023f 100644 --- a/src/bundle/Resources/config/graphql/PlatformMutation.types.yaml +++ b/src/bundle/Resources/config/graphql/PlatformMutation.types.yaml @@ -24,14 +24,6 @@ PlatformMutation: language: type: RepositoryLanguage! description: "The language the content items must be created in" - createToken: - type: CreatedTokenPayload - resolve: '@=mutation("CreateToken", args)' - args: - username: - type: String! - password: - type: String! UploadedFilesPayload: type: object @@ -52,13 +44,3 @@ DeleteContentPayload: id: type: ID description: "Global ID" - -CreatedTokenPayload: - type: object - config: - fields: - token: - type: String - message: - type: String - description: "The reason why authentication has failed, if it has" diff --git a/src/bundle/Resources/config/services/resolvers.yaml b/src/bundle/Resources/config/services/resolvers.yaml index 8b55469..fde4e23 100644 --- a/src/bundle/Resources/config/services/resolvers.yaml +++ b/src/bundle/Resources/config/services/resolvers.yaml @@ -62,12 +62,6 @@ services: tags: - { name: overblog_graphql.resolver, alias: "Thumbnail", method: "resolveThumbnail" } - Ibexa\GraphQL\Mutation\Authentication: - arguments: - $authenticator: '@?ibexa.rest.session_authenticator' - tags: - - { name: overblog_graphql.mutation, alias: "CreateToken", method: "createToken" } - Ibexa\GraphQL\Mutation\UploadFiles: arguments: $repository: '@ibexa.siteaccessaware.repository' diff --git a/src/bundle/Resources/config/services/services.yaml b/src/bundle/Resources/config/services/services.yaml index 5e2dd63..048c9bb 100644 --- a/src/bundle/Resources/config/services/services.yaml +++ b/src/bundle/Resources/config/services/services.yaml @@ -50,3 +50,12 @@ services: $contentLoader: '@Ibexa\GraphQL\DataLoader\ContentLoader' tags: - { name: ibexa.field_type.image_asset.mapper.strategy, priority: 0 } + + Ibexa\GraphQL\Security\JWTAuthenticator: + arguments: + $userProvider: '@ibexa.security.user_provider' + + Ibexa\GraphQL\Security\JWTTokenMutationFormatEventSubscriber: + tags: + - name: kernel.event_subscriber + dispatcher: security.event_dispatcher.ibexa_jwt_graphql diff --git a/src/lib/Mutation/Authentication.php b/src/lib/Mutation/Authentication.php deleted file mode 100644 index 3b65a94..0000000 --- a/src/lib/Mutation/Authentication.php +++ /dev/null @@ -1,76 +0,0 @@ -tokenManager = $tokenManager; - $this->requestStack = $requestStack; - $this->authenticator = $authenticator; - } - - /** - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException - */ - public function createToken($args): array - { - $username = $args['username']; - $password = $args['password']; - - $request = $this->requestStack->getCurrentRequest(); - $request->attributes->set('username', $username); - $request->attributes->set('password', (string) $password); - - try { - $user = $this->getAuthenticator()->authenticate($request)->getUser(); - - $token = $this->tokenManager->create( - new JWTUser($user, $username) - ); - - return ['token' => $token]; - } catch (AuthenticationException $e) { - return ['message' => 'Wrong username or password', 'token' => null]; - } - } - - private function getAuthenticator(): AuthenticatorInterface - { - if (null === $this->authenticator) { - throw new \RuntimeException( - sprintf( - "No %s instance injected. Ensure 'ezpublish_rest_session' is configured under your firewall", - AuthenticatorInterface::class - ) - ); - } - - return $this->authenticator; - } -} diff --git a/src/lib/Security/JWTAuthenticator.php b/src/lib/Security/JWTAuthenticator.php new file mode 100644 index 0000000..44fe9f3 --- /dev/null +++ b/src/lib/Security/JWTAuthenticator.php @@ -0,0 +1,141 @@ +getContent(), true); + if (!isset($payload['query'])) { + return false; + } + + try { + $credentials = $this->extractCredentials($payload['query']); + } catch (Exception) { + return false; + } + + if (isset($credentials['username'], $credentials['password'])) { + $this->username = $credentials['username']; + $this->password = $credentials['password']; + + return true; + } + + return false; + } + + public function authenticate(Request $request): Passport + { + $passport = new Passport( + new UserBadge($this->username, [$this->userProvider, 'loadUserByUsername']), + new PasswordCredentials($this->password) + ); + + $user = $passport->getUser(); + if ($user instanceof IbexaUser) { + $this->permissionResolver->setCurrentUserReference($user->getAPIUser()); + } + + $passport->setAttribute('token', $this->tokenManager->create($user)); + + return $passport; + } + + /** + * @throws \JsonException + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return new Response( + json_encode( + [ + 'token' => $this->tokenManager->create($token->getUser()), + 'message' => null, + ], + JSON_THROW_ON_ERROR + ) + ); + } + + /** + * @throws \JsonException + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new Response( + json_encode( + [ + 'token' => null, + 'message' => $exception->getMessageKey(), + ], + JSON_THROW_ON_ERROR + ), + Response::HTTP_FORBIDDEN + ); + } + + public function isInteractive(): bool + { + return true; + } + + /** + * @return array + * + * @throws \Exception + */ + private function extractCredentials(string $graphqlQuery): array + { + $parsed = Parser::parse($graphqlQuery); + $credentials = []; + Visitor::visit( + $parsed, + [ + NodeKind::ARGUMENT => static function (ArgumentNode $node) use (&$credentials): void { + $credentials[$node->name->value] = (string)$node->value->value; + }, + ] + ); + + return $credentials; + } +} diff --git a/src/lib/Security/JWTTokenMutationFormatEventSubscriber.php b/src/lib/Security/JWTTokenMutationFormatEventSubscriber.php new file mode 100644 index 0000000..e71153f --- /dev/null +++ b/src/lib/Security/JWTTokenMutationFormatEventSubscriber.php @@ -0,0 +1,50 @@ + ['onAuthorizationFinishes', 10], + LoginFailureEvent::class => ['onAuthorizationFinishes', 10], + ]; + } + + /** + * @throws \JsonException + */ + public function onAuthorizationFinishes(LoginSuccessEvent|LoginFailureEvent $event): void + { + $response = $event->getResponse(); + $response->setContent( + $this->formatMutationResponseData($response->getContent()) + ); + } + + /** + * @throws \JsonException + */ + private function formatMutationResponseData(mixed $data): string + { + return json_encode([ + 'data' => [ + 'CreateToken' => json_decode($data, true, 512, JSON_THROW_ON_ERROR), + ], + ]); + } +} diff --git a/src/lib/Security/JWTUser.php b/src/lib/Security/JWTUser.php deleted file mode 100644 index a93fe62..0000000 --- a/src/lib/Security/JWTUser.php +++ /dev/null @@ -1,56 +0,0 @@ -wrappedUser = $wrappedUser; - $this->userIdentifier = $userIdentifier; - } - - public function getPassword(): ?string - { - return $this->wrappedUser->getPassword(); - } - - public function eraseCredentials(): void - { - $this->wrappedUser->eraseCredentials(); - } - - public function getRoles(): array - { - return $this->wrappedUser->getRoles(); - } - - public function getSalt(): ?string - { - return $this->wrappedUser->getSalt(); - } - - public function getUsername(): string - { - return $this->userIdentifier ?? $this->wrappedUser->getUsername(); - } - - public function getWrappedUser(): UserInterface - { - return $this->wrappedUser; - } -} diff --git a/src/lib/Security/NonAdminGraphQLRequestMatcher.php b/src/lib/Security/NonAdminGraphQLRequestMatcher.php index 1d88950..f90de2f 100644 --- a/src/lib/Security/NonAdminGraphQLRequestMatcher.php +++ b/src/lib/Security/NonAdminGraphQLRequestMatcher.php @@ -16,16 +16,19 @@ * Security request matcher that excludes admin+graphql requests. * Needed because the admin uses GraphQL without a JWT. */ -class NonAdminGraphQLRequestMatcher implements RequestMatcherInterface +final readonly class NonAdminGraphQLRequestMatcher implements RequestMatcherInterface { - /** @var string[][] */ - private $siteAccessGroups; - - public function __construct(array $siteAccessGroups) - { - $this->siteAccessGroups = $siteAccessGroups; + /** + * @param string[][] $siteAccessGroups + */ + public function __construct( + private array $siteAccessGroups + ) { } + /** + * @throws \Ibexa\AdminUi\Exception\InvalidArgumentException + */ public function matches(Request $request): bool { return