Skip to content

Commit

Permalink
IBX-8356: Reworked Ibexa\Core\MVC\Symfony\Security\Authentication\Aut…
Browse files Browse the repository at this point in the history
…henticatorInterface usages to comply with Symfony-based authentication
  • Loading branch information
konradoboza committed Jun 12, 2024
1 parent 4fb3e4a commit d5946ba
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 134 deletions.
2 changes: 0 additions & 2 deletions src/bundle/Resources/config/services/resolvers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ services:
- { 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" }

Expand Down
8 changes: 8 additions & 0 deletions src/bundle/Resources/config/services/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ services:

Ibexa\GraphQL\InputMapper\ContentCollectionFilterBuilder: ~

Ibexa\GraphQL\Security\NonAdminGraphqlRequestSpecification: ~

Ibexa\GraphQL\Security\NonAdminGraphQLRequestMatcher:
arguments:
$siteAccessGroups: '%ibexa.site_access.groups%'
Expand Down Expand Up @@ -50,3 +52,9 @@ services:
$contentLoader: '@Ibexa\GraphQL\DataLoader\ContentLoader'
tags:
- { name: ibexa.field_type.image_asset.mapper.strategy, priority: 0 }

Ibexa\GraphQL\Security\EventSubscriber\JsonLoginPayloadSubscriber:
arguments:
$siteAccessGroups: '%ibexa.site_access.groups%'
tags:
- name: kernel.event_subscriber
61 changes: 1 addition & 60 deletions src/lib/Mutation/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,69 +8,10 @@

namespace Ibexa\GraphQL\Mutation;

use Ibexa\Core\MVC\Symfony\Security\Authentication\AuthenticatorInterface;
use Ibexa\GraphQL\Security\JWTUser;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

final class Authentication
{
/** @var \Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface */
private $tokenManager;

/** @var \Symfony\Component\HttpFoundation\RequestStack */
private $requestStack;

/** @var \Ibexa\Core\MVC\Symfony\Security\Authentication\AuthenticatorInterface|null */
private $authenticator;

public function __construct(
JWTTokenManagerInterface $tokenManager,
RequestStack $requestStack,
?AuthenticatorInterface $authenticator = null
) {
$this->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;
return [];
}
}
102 changes: 102 additions & 0 deletions src/lib/Security/EventSubscriber/JsonLoginPayloadSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\GraphQL\Security\EventSubscriber;

use Exception;
use GraphQL\Language\AST\ArgumentNode;
use GraphQL\Language\AST\NodeKind;
use GraphQL\Language\Parser;
use GraphQL\Language\Visitor;
use Ibexa\GraphQL\Security\NonAdminGraphqlRequestSpecification;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;

final readonly class JsonLoginPayloadSubscriber implements EventSubscriberInterface
{
/**
* @param string[][] $siteAccessGroups
*/
public function __construct(
private array $siteAccessGroups,
) {
}

public static function getSubscribedEvents(): array
{
return [
RequestEvent::class => ['mapToJsonLoginPayload', 10],
];
}

/**
* @throws \JsonException
* @throws \Ibexa\AdminUi\Exception\InvalidArgumentException
*/
public function mapToJsonLoginPayload(RequestEvent $event): void
{
$request = $event->getRequest();
if (!$this->isNonAdminGraphqlRequest($request)) {
return;
}

$payload = json_decode($request->getContent(), true);
if (!isset($payload['query'])) {
return;
}

$credentials = [];
try {
$credentials = $this->extractCredentials($payload['query']);
} catch (Exception) {
//do nothing, empty credentials are sent further
}

$request->initialize(
$request->query->all(),
$request->request->all(),
$request->attributes->all(),
$request->cookies->all(),
$request->files->all(),
$request->server->all(),
json_encode($credentials, JSON_THROW_ON_ERROR),
);
}

/**
* @throws \Ibexa\AdminUi\Exception\InvalidArgumentException
*/
private function isNonAdminGraphqlRequest(Request $request): bool
{
return (new NonAdminGraphqlRequestSpecification($this->siteAccessGroups))->isSatisfiedBy($request);
}

/**
* @throws \Exception
* @throws \GraphQL\Error\SyntaxError
*
* @return array<string, string>
*/
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;
}
}
56 changes: 0 additions & 56 deletions src/lib/Security/JWTUser.php

This file was deleted.

27 changes: 11 additions & 16 deletions src/lib/Security/NonAdminGraphQLRequestMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,28 @@

namespace Ibexa\GraphQL\Security;

use Ibexa\AdminUi\Specification\SiteAccess\IsAdmin;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;

/**
* 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
$request->attributes->get('_route') === 'overblog_graphql_endpoint' &&
!$this->isAdminSiteAccess($request);
}

private function isAdminSiteAccess(Request $request): bool
{
return (new IsAdmin($this->siteAccessGroups))->isSatisfiedBy($request->attributes->get('siteaccess'));
return (new NonAdminGraphqlRequestSpecification($this->siteAccessGroups))->isSatisfiedBy($request);
}
}
48 changes: 48 additions & 0 deletions src/lib/Security/NonAdminGraphqlRequestSpecification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\GraphQL\Security;

use Ibexa\AdminUi\Specification\SiteAccess\IsAdmin;
use Ibexa\Contracts\Core\Specification\AbstractSpecification;
use Symfony\Component\HttpFoundation\Request;

final class NonAdminGraphqlRequestSpecification extends AbstractSpecification
{
/**
* @param string[][] $siteAccessGroups
*/
public function __construct(
private array $siteAccessGroups
) {
}

/**
* @throws \Ibexa\AdminUi\Exception\InvalidArgumentException
*/
public function isSatisfiedBy($item): bool
{
if (!$item instanceof Request) {
return false;
}

return
$item->attributes->get('_route') === 'overblog_graphql_endpoint' &&
!$this->isAdminSiteAccess($item);
}

/**
* @throws \Ibexa\AdminUi\Exception\InvalidArgumentException
*/
private function isAdminSiteAccess(Request $request): bool
{
return (new IsAdmin($this->siteAccessGroups))->isSatisfiedBy(
$request->attributes->get('siteaccess')
);
}
}

0 comments on commit d5946ba

Please sign in to comment.