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 19, 2024
1 parent 4fb3e4a commit 0bb638e
Show file tree
Hide file tree
Showing 8 changed files with 206 additions and 172 deletions.
18 changes: 0 additions & 18 deletions src/bundle/Resources/config/graphql/PlatformMutation.types.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
6 changes: 0 additions & 6 deletions src/bundle/Resources/config/services/resolvers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 6 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,7 @@ 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'
76 changes: 0 additions & 76 deletions src/lib/Mutation/Authentication.php

This file was deleted.

141 changes: 141 additions & 0 deletions src/lib/Security/JWTAuthenticator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?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 Exception;
use GraphQL\Language\AST\ArgumentNode;
use GraphQL\Language\AST\NodeKind;
use GraphQL\Language\Parser;
use GraphQL\Language\Visitor;
use Ibexa\Contracts\Core\Repository\PermissionResolver;
use Ibexa\Core\MVC\Symfony\Security\User\APIUserProviderInterface;
use Ibexa\Core\MVC\Symfony\Security\UserInterface as IbexaUser;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;

final class JWTAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface
{
private string $username;

private string $password;

public function __construct(
private readonly JWTTokenManagerInterface $tokenManager,
private readonly APIUserProviderInterface $userProvider,
private readonly PermissionResolver $permissionResolver,
) {
}

public function supports(Request $request): ?bool
{
$payload = json_decode($request->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;
}

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new Response(
$this->formatMutationResponseData([
'token' => $this->tokenManager->create($token->getUser()),
'message' => null,
])
);
}

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new Response(
$this->formatMutationResponseData([
'token' => null,
'message' => $exception->getMessageKey(),
]),
Response::HTTP_FORBIDDEN
);
}

public function isInteractive(): bool
{
return true;
}

/**
* @return array<string, string>
*
* @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;
}

/**
* @param array<string, mixed> $data
*/
private function formatMutationResponseData(array $data): string
{
return json_encode([
'data' => [
'CreateToken' => $data,
],
]);
}
}
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);
}
}
Loading

0 comments on commit 0bb638e

Please sign in to comment.